123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { computeVaultId } from "../shared/sync-identity";161718import { triggerSyncIfReachable } from "./sync-trigger";19import { clearPairedDevices } from "./paired-devices";20import {21 rootKeyToWords,22 wordsToRootKeyMaterial,23 deriveRecoveryKey,24} from "../shared/recovery-words";25import type {26 AccentColorName,27 Canary,28 ManualSummonRecord,29 PasskeyData,30 SavedIdentity,31 StoredVault,32 VaultEntry,33 VaultHistory,34 VaultSnapshot,35} from "../shared/types";36import { decrypt, encrypt, zeroKey } from "./aes";37import { clearAutoLock, touchAutoLock } from "./alarms";38import {39 clearRecoveryConfirmPending,40 setRecoveryConfirmPending,41} from "./recovery-pending";42import {43 clearPairSecrets,44 dropSessionCache,45 reconcilePairSecrets,46 setSessionAllowed,47} from "./pair-secrets";4849type EntryDraft = Omit<50 VaultEntry,51 "id" | "createdAt" | "modifiedAt" | "deletedAt"52>;535455565758const SLOT_A = "peach_vault_a";59const SLOT_B = "peach_vault_b";60616263const PAD_BLOCK = 1024;6465666768const PASSWORD_HISTORY_MAX = 50;6970717273747576777879const DURESS_SLOT_SIZE = 262_144;8081let derivedKey: Uint8Array | null = null;8283848586let rootKey: Uint8Array | null = null;87let entries: VaultEntry[] = [];88899091let tombstones = new Map<string, number>();92let domainIndex: Map<string, VaultEntry[]> = new Map();93let vaultMode: "real" | "decoy" = "real";94959697let duressConfigured = false;9899100101102103104105106107108let lastUnlockFailureReason: string | null = null;109110export function consumeLastUnlockFailureReason(): string | null {111 const r = lastUnlockFailureReason;112 lastUnlockFailureReason = null;113 return r;114}115116117118119120export type TextSize = "small" | "medium" | "large";121122export interface VaultSettings {123 accentColor?: AccentColorName;124 theme?: "dark" | "light";125 autoLockMinutes?: number;126 clipboardClearSeconds?: number;127 lockOnClose?: boolean;128 breachMonitoring?: boolean;129 passkeyProviderEnabled?: boolean;130 trashRetentionDays?: number;131 manualSummonDomains?: Record<string, ManualSummonRecord>;132 manualSummonSalt?: string;133 declinedCardFingerprints?: string[];134135 136 metadataVersion?: 1;137 138139140141142 textSize?: TextSize;143 144145146147148 reduceMotion?: boolean;149 150 pinEnabled?: boolean;151 152 pinPreferred?: boolean;153 154155156157158159160 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;161}162163interface StoredPinWrap {164 version: 1;165 salt: string;166 nonce: string;167 ciphertext: string;168 tag: string;169}170171const PIN_WRAP_STORAGE = "peach_pin_wrap";172const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";173const PIN_MIN_LENGTH = 4;174const PIN_MAX_LENGTH = 12;175const PIN_MAX_ATTEMPTS = 3;176177let settings: VaultSettings = {};178const CARD_DECLINE_MAX = 256;179180const CARD_DECLINE_DIGEST_HEX = 32;181182async function metadataDigest(value: string, key: string): Promise<string> {183 const bytes = new TextEncoder().encode(`${key}:${value}`);184 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));185 return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join("");186}187188let inMemoryPinWrap: StoredPinWrap | null = null;189let inMemoryPinFailedAttempts = 0;190let sessionPinStorageFailed = false;191192export function getSettings(): VaultSettings {193 return settings;194}195196export async function ensureSettingsLoaded(): Promise<VaultSettings> {197 await loadSettings();198 return settings;199}200201export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {202 await loadSettings();203 204 205 206 207 const safe = Object.fromEntries(208 Object.entries(patch).filter(209 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),210 ),211 ) as Partial<VaultSettings>;212 settings = { ...settings, ...safe };213 await persistSettings();214}215216function isValidPin(pin: string): boolean {217 return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);218}219220221export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {222 if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;223 if (!(await verifyMainPassword(currentPassword))) return false;224225 const salt = generateSalt();226 const pinKey = await deriveKey(pin, salt);227 try {228 const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");229 await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });230 await updateSettings({ pinEnabled: true });231 return true;232 } finally {233 zeroKey(pinKey);234 }235}236237async function clearPinUnlockStorage(): Promise<void> {238 inMemoryPinWrap = null;239 inMemoryPinFailedAttempts = 0;240 sessionPinStorageFailed = false;241 if (chrome.storage.session) {242 await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);243 }244 await updateSettings({ pinEnabled: false });245}246247248export async function disablePinUnlock(currentPassword: string): Promise<boolean> {249 if (!derivedKey || vaultMode !== "real") return false;250 if (!(await verifyMainPassword(currentPassword))) return false;251 await clearPinUnlockStorage();252 return true;253}254255256export async function unlockWithPin(pin: string): Promise<boolean> {257 if (!isValidPin(pin)) return false;258 await loadSettings();259 if (!settings.pinEnabled) return false;260261 const { stored, failedAttempts } = await loadPinState();262 if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;263 if (264 !stored ||265 stored.version !== 1 ||266 !stored.salt ||267 !stored.nonce ||268 !stored.ciphertext ||269 !stored.tag270 ) return false;271272 const pinKey = await deriveKey(pin, unhex(stored.salt));273 let unwrappedKey: Uint8Array | null = null;274 try {275 const masterKeyHex = await decrypt(276 pinKey,277 stored.nonce,278 stored.ciphertext,279 stored.tag,280 "pin_wrap_v1",281 );282 if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;283 unwrappedKey = unhex(masterKeyHex);284 const result = await unlockWithKey(masterKeyHex);285 if (!result.ok) return false;286 await resetPinFailedAttempts();287 return true;288 } catch {289 await recordPinFailure(failedAttempts + 1);290 return false;291 } finally {292 zeroKey(pinKey);293 if (unwrappedKey) zeroKey(unwrappedKey);294 }295}296297async function savePinWrap(stored: StoredPinWrap): Promise<void> {298 inMemoryPinWrap = stored;299 inMemoryPinFailedAttempts = 0;300 sessionPinStorageFailed = !chrome.storage.session;301 if (!chrome.storage.session) return;302 try {303 await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });304 sessionPinStorageFailed = false;305 } catch {306 307 sessionPinStorageFailed = true;308 }309}310311async function loadPinState(): Promise<{312 stored: StoredPinWrap | undefined;313 failedAttempts: number;314}> {315 if (!chrome.storage.session) {316 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };317 }318 try {319 const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);320 return {321 stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??322 (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),323 failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"324 ? result[PIN_ATTEMPTS_STORAGE]325 : 0,326 };327 } catch {328 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };329 }330}331332async function recordPinFailure(failedAttempts: number): Promise<void> {333 inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);334 if (!chrome.storage.session) return;335 try {336 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });337 } catch { }338}339340async function resetPinFailedAttempts(): Promise<void> {341 inMemoryPinFailedAttempts = 0;342 if (!chrome.storage.session) return;343 try {344 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });345 } catch { }346}347348export async function getPinUnlockStatus(): Promise<{349 configured: boolean;350 available: boolean;351 attemptsRemaining: number;352}> {353 await loadSettings();354 if (!settings.pinEnabled) {355 return { configured: false, available: false, attemptsRemaining: 0 };356 }357 const { stored, failedAttempts } = await loadPinState();358 const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;359 return {360 configured: true,361 available,362 attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,363 };364}365366async function persistSettings(): Promise<void> {367 try {368 await chrome.storage.local.set({369 peach_settings: {370 ...settings,371 manualSummonDomains: undefined,372 manualSummonSalt: undefined,373 cardFingerprintSalt: undefined,374 declinedCardFingerprints: settings.declinedCardFingerprints ?? [],375 metadataVersion: 1,376 },377 });378 } catch (err) {379 throw mapStorageError(err);380 }381}382383function mapStorageError(err: unknown): Error {384 const message = err instanceof Error ? err.message : String(err);385 if (/QUOTA_BYTES|quota/i.test(message)) {386 return new Error("STORAGE_QUOTA_EXCEEDED");387 }388 return err instanceof Error ? err : new Error(message);389}390391async function loadSettings(): Promise<void> {392 const result = await chrome.storage.local.get("peach_settings");393 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};394}395396397398function fireSyncTrigger(reason: "vault_change"): void {399 void Promise.resolve()400 .then(() => triggerSyncIfReachable({ reason }))401 .catch(() => {402 403 });404}405406let lockedSyncRefreshHook: (() => Promise<void>) | null = null;407let lockedSyncUnlockHook: (() => Promise<void>) | null = null;408let decoySyncTeardownHook: (() => void) | null = null;409410export function setLockedSyncHooks(hooks: {411 refresh: () => Promise<void>;412 afterUnlock: () => Promise<void>;413 onDecoy?: () => void;414}): void {415 lockedSyncRefreshHook = hooks.refresh;416 lockedSyncUnlockHook = hooks.afterUnlock;417 decoySyncTeardownHook = hooks.onDecoy ?? null;418}419420async function refreshLockedSyncState(): Promise<void> {421 if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;422 await lockedSyncRefreshHook().catch((error) => {423 console.warn("[Peach sync] locked spool refresh failed:", error);424 });425}426427428429430431export async function checkBreach(password: string): Promise<number> {432 433 434 const encoder = new TextEncoder();435 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));436 const hashHex = Array.from(new Uint8Array(hashBuffer))437 .map((b) => b.toString(16).padStart(2, "0"))438 .join("")439 .toUpperCase();440441 const prefix = hashHex.slice(0, 5);442 const suffix = hashHex.slice(5);443444 try {445 446 447 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {448 headers: { "Add-Padding": "true" },449 });450 if (!res.ok) return 0;451 const text = await res.text();452 const match = text.split("\r\n").find((l) => l.startsWith(suffix));453 return match ? parseInt(match.split(":")[1], 10) : 0;454 } catch {455 return 0; 456 }457}458459export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {460 461 const key = await cardDeclineKey();462 if (!key) return;463 const fingerprint = await cardFingerprint(last4, expMonth, expYear);464 await loadSettings();465 settings = {466 ...settings,467 declinedCardFingerprints: [468 469 470 ...(settings.declinedCardFingerprints ?? []).filter(471 (stored) => stored.length === CARD_DECLINE_DIGEST_HEX,472 ),473 await metadataDigest(fingerprint, key),474 ].slice(-CARD_DECLINE_MAX),475 };476 await persistSettings();477}478479export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {480 await loadSettings();481 const key = await cardDeclineKey();482 if (!key) return false;483 const fingerprint = await cardFingerprint(last4, expMonth, expYear);484 const candidate = await metadataDigest(fingerprint, key);485 return (settings.declinedCardFingerprints ?? []).some(486 (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate,487 );488}489490491492493494495496497498499500501async function cardDeclineKey(): Promise<string | null> {502 const root = getRootKeyHex();503 if (!root) return null;504 const digest = new Uint8Array(505 await crypto.subtle.digest(506 "SHA-256",507 new TextEncoder().encode(`peach-card-decline-v1:${root}`),508 ),509 );510 return hex(digest);511}512513async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {514 const encoder = new TextEncoder();515 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));516 return Array.from(new Uint8Array(buf).slice(0, 8))517 .map((b) => b.toString(16).padStart(2, "0"))518 .join("");519}520521function hex(buf: Uint8Array): string {522 return Array.from(buf)523 .map((b) => b.toString(16).padStart(2, "0"))524 .join("");525}526527function unhex(s: string): Uint8Array {528 const out = new Uint8Array(s.length / 2);529 for (let i = 0; i < s.length; i += 2) {530 out[i / 2] = parseInt(s.slice(i, i + 2), 16);531 }532 return out;533}534535536537538539540541542543544545546547548549550551552553554async function generateAndWrapRootKey(): Promise<{555 rawRootKey: string; 556 recoverySalt: string;557 wrappedRootKey: string;558 wrappedNonce: string;559 wrappedTag: string;560}> {561 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));562563 564 const words = await rootKeyToWords(rootKeyBytes);565 const wordMaterial = await wordsToRootKeyMaterial(words);566 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));567 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);568569 570 571 const nonce = crypto.getRandomValues(new Uint8Array(12));572 const encoded = new TextEncoder().encode(hex(rootKeyBytes));573 const raw = await crypto.subtle.encrypt(574 {575 name: "AES-GCM",576 iv: nonce,577 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,578 tagLength: 128,579 },580 recoveryKey,581 encoded582 );583 584 const rawU8 = new Uint8Array(raw);585 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));586 const tag = hex(rawU8.slice(rawU8.length - 16));587588 return {589 rawRootKey: hex(rootKeyBytes),590 recoverySalt: hex(recoverySalt),591 wrappedRootKey: ciphertext,592 wrappedNonce: hex(nonce),593 wrappedTag: tag,594 };595}596597async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{598 recoverySalt: string;599 wrappedRootKey: string;600 wrappedNonce: string;601 wrappedTag: string;602}> {603 if (rootKeyBytes.length !== 32) {604 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");605 }606607 const words = await rootKeyToWords(rootKeyBytes);608 const wordMaterial = await wordsToRootKeyMaterial(words);609 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));610 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);611 const nonce = crypto.getRandomValues(new Uint8Array(12));612 const encoded = new TextEncoder().encode(hex(rootKeyBytes));613 const raw = await crypto.subtle.encrypt(614 {615 name: "AES-GCM",616 iv: nonce,617 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,618 tagLength: 128,619 },620 recoveryKey,621 encoded,622 );623 const rawU8 = new Uint8Array(raw);624 return {625 recoverySalt: hex(recoverySalt),626 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),627 wrappedNonce: hex(nonce),628 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),629 };630}631632633634635636637export async function unwrapRootKeyForRecovery(638 wrappedRootKey: string,639 wrappedNonce: string,640 wrappedTag: string,641 recoverySaltHex: string,642 words: string[]643): Promise<void> {644 const wordMaterial = await wordsToRootKeyMaterial(words);645 const recoverySalt = unhex(recoverySaltHex);646 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);647648 649 650 651 652 653 const nonceRaw = unhex(wrappedNonce);654 const ciphertextRaw = unhex(wrappedRootKey);655 const tagRaw = unhex(wrappedTag);656 657 658 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;659 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);660 combined.set(ciphertextRaw, 0);661 combined.set(tagRaw, ciphertextRaw.length);662 const raw = await crypto.subtle.decrypt(663 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },664 recoveryKey,665 combined666 );667 const decryptedHex = new TextDecoder().decode(raw);668 rootKey = unhex(decryptedHex);669}670671672673674675676677678679680export async function rewrapRootKeyForPassword(681 newDerivedKey: Uint8Array,682): Promise<void> {683 if (!rootKey) throw new Error("root key not set");684 const slotA = await loadSlot(SLOT_A);685 if (!slotA) throw new Error("missing slot A");686 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);687 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });688}689690691692693694695async function reencryptVaultForNewKey(696 newKey: Uint8Array,697 slotA: StoredVault698): Promise<void> {699 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);700 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");701 await writeSlot(SLOT_A, {702 ...slotA,703 nonce,704 ciphertext,705 tag,706 });707}708709710711712713714export async function changePasswordWithNewKey(715 newSalt: Uint8Array,716 newKey: Uint8Array717): Promise<void> {718 const epoch = vaultEpoch;719 const throwIfStale = () => {720 if (epoch === vaultEpoch) return;721 zeroKey(newKey);722 throw new Error("vault_locked");723 };724 const slotA = await loadSlot(SLOT_A);725 throwIfStale();726 if (!slotA) throw new Error("missing slot A");727 728 729 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });730 throwIfStale();731 if (derivedKey) zeroKey(derivedKey);732 derivedKey = newKey;733 734 735 736 await saveSessionKey(newKey, "real", epoch);737 throwIfStale();738 739 740 741 742 if (rootKey) {743 const slotAfter = await loadSlot(SLOT_A);744 throwIfStale();745 if (slotAfter) {746 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);747 throwIfStale();748 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });749 throwIfStale();750 }751 }752 if (!duressConfigured) {753 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));754 throwIfStale();755 }756 await clearPinUnlockStorage();757 throwIfStale();758 await stampLocalVaultMutation();759 throwIfStale();760}761762763764765766767export { deriveKey, generateSalt };768769export async function loadSlotA(): Promise<StoredVault | null> {770 return loadSlot(SLOT_A);771}772773774775776777778779780781782async function deriveRootKeyFromDerivedKey(783 derivedKeyBytes: Uint8Array,784 slotA: StoredVault,785): Promise<Uint8Array | null> {786 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {787 return null;788 }789 try {790 const rootHex = await decrypt(791 derivedKeyBytes,792 slotA.pw_root_nonce,793 slotA.pw_root_ciphertext,794 slotA.pw_root_tag,795 "pw_root_v1",796 );797 return unhex(rootHex);798 } catch {799 return null;800 }801}802803804805async function wrapRootKeyForDerivedKey(806 derivedKeyBytes: Uint8Array,807 rootKeyBytes: Uint8Array,808): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {809 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");810 return {811 pw_root_ciphertext: ciphertext,812 pw_root_nonce: nonce,813 pw_root_tag: tag,814 };815}816817818export async function getRecoveryWords(): Promise<string[]> {819 if (!rootKey) {820 throw new Error("recovery not set up");821 }822 return rootKeyToWords(rootKey);823}824825async function loadSlot(key: string): Promise<StoredVault | null> {826 const result = await chrome.storage.local.get(key);827 return (result as Record<string, StoredVault | undefined>)[key] ?? null;828}829830async function writeSlot(key: string, vault: StoredVault): Promise<void> {831 try {832 await chrome.storage.local.set({ [key]: vault });833 } catch (err) {834 throw mapStorageError(err);835 }836}837838839840841842843844845846847export async function rollbackAdoptedVault(): Promise<void> {848 await lock();849 try {850 await chrome.storage.local.remove([851 SLOT_A,852 SLOT_B,853 "peach_last_modified_at",854 ]);855 } catch (err) {856 throw mapStorageError(err);857 }858}859860async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {861 const raw = await chrome.storage.local.get("peach_last_modified_at");862 const previous = Number(863 (raw as Record<string, unknown>).peach_last_modified_at,864 );865 const next = Math.max(866 Date.now(),867 Number.isFinite(previous) ? previous + 1 : 0,868 );869 try {870 await chrome.storage.local.set({871 [SLOT_A]: slotA,872 [SLOT_B]: slotB,873 peach_last_modified_at: String(next),874 });875 } catch (err) {876 throw mapStorageError(err);877 }878}879880881882883884885886887let lastEntryTimestamp = 0;888function monotonicEntryTimestamp(currentModifiedAt?: number): number {889 const base = Math.max(890 Date.now(),891 (currentModifiedAt ?? 0) + 1,892 lastEntryTimestamp + 1,893 );894 lastEntryTimestamp = base;895 return base;896}897898async function stampLocalVaultMutation(): Promise<void> {899 const raw = await chrome.storage.local.get("peach_last_modified_at");900 const previous = Number(901 (raw as Record<string, unknown>).peach_last_modified_at,902 );903 const next = Math.max(904 Date.now(),905 Number.isFinite(previous) ? previous + 1 : 0,906 );907 await chrome.storage.local.set({908 peach_last_modified_at: String(next),909 });910}911912913914915916917918919920921922function padPlaintext(json: string, minBytes = 0): string {923 const encoded = new TextEncoder().encode(json);924 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);925 926 const target = Math.max(bucket, minBytes);927 const padLen = target - encoded.length;928 return json + " ".repeat(padLen);929}930931932933934935936937938939940941const CSPRNG_CHUNK = 65_536;942943function fillRandom(buf: Uint8Array): Uint8Array {944 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {945 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);946 const chunk = new Uint8Array(end - offset);947 crypto.getRandomValues(chunk);948 buf.set(chunk, offset);949 }950 return buf;951}952953function generateNoiseBlob(ciphertextBytes: number): StoredVault {954 const salt = crypto.getRandomValues(new Uint8Array(16));955 const nonce = crypto.getRandomValues(new Uint8Array(12));956 const cipher = fillRandom(new Uint8Array(ciphertextBytes));957 const tag = crypto.getRandomValues(new Uint8Array(16));958 return {959 version: 1,960 salt: hex(salt),961 nonce: hex(nonce),962 ciphertext: hex(cipher),963 tag: hex(tag),964 };965}966967968969970971type VaultPayload = {972 entries: VaultEntry[];973 tombstones?: Record<string, number>;974 duressConfigured?: boolean;975};976977type DecryptedPayload = {978 entries: VaultEntry[];979 tombstones: Map<string, number>;980 duressConfigured: boolean;981};982983function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {984 const envelope: VaultPayload = {985 entries,986 tombstones: Object.fromEntries(tombstones),987 duressConfigured: duress,988 };989 return padPlaintext(JSON.stringify(envelope), minBytes);990}991992function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {993 const result = new Map<string, number>();994 if (raw && typeof raw === "object" && !Array.isArray(raw)) {995 for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {996 if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {997 result.set(id, timestamp);998 }999 }1000 }1001 1002 1003 for (const entry of allEntries) {1004 if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {1005 continue;1006 }1007 if ((result.get(entry.id) ?? 0) < entry.deletedAt) {1008 result.set(entry.id, entry.deletedAt);1009 }1010 }1011 return result;1012}10131014function reconcileTombstones(): void {1015 const byId = new Map(entries.map((entry) => [entry.id, entry]));1016 for (const [id] of tombstones) {1017 if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);1018 }1019 for (const entry of entries) {1020 if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {1021 if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {1022 tombstones.set(entry.id, entry.deletedAt);1023 }1024 }1025 }1026}10271028function decodePayload(plain: string): DecryptedPayload {1029 const parsed = JSON.parse(plain) as VaultPayload;1030 if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {1031 throw new Error("invalid vault payload");1032 }1033 const normalized = parsed.entries.map(normalizeEntry);1034 return {1035 entries: normalized,1036 tombstones: decodeTombstones(parsed.tombstones, normalized),1037 duressConfigured: parsed.duressConfigured === true,1038 };1039}1040104110421043104410451046104710481049105010511052105310541055105610571058105910601061function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1062 let merged = false;1063 const now = Date.now();10641065 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1066 const byRpId = new Map<string, VaultEntry[]>();1067 for (const e of candidates) {1068 const rpId = e.passkey!.rpId;1069 const list = byRpId.get(rpId);1070 if (list) list.push(e);1071 else byRpId.set(rpId, [e]);1072 }10731074 for (const [rpId, group] of byRpId) {1075 if (group.length === 0) continue;1076 group.sort((a, b) => {1077 const aT =1078 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1079 const bT =1080 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1081 return bT - aT;1082 });1083 const winner = group[0]!;10841085 1086 1087 1088 const loginTarget = entries.find((e) => {1089 if (e.deletedAt || e.passkey || e.type !== "login") return false;1090 if (e.url && extractDomain(e.url) === rpId) return true;1091 for (const extra of e.additionalUrls ?? []) {1092 if (extractDomain(extra) === rpId) return true;1093 }1094 return false;1095 });10961097 if (loginTarget) {1098 loginTarget.passkey = winner.passkey;1099 1100 1101 1102 if (!loginTarget.totp && winner.totp) {1103 loginTarget.totp = winner.totp;1104 loginTarget.totpAlgorithm = winner.totpAlgorithm;1105 loginTarget.totpDigits = winner.totpDigits;1106 loginTarget.totpPeriod = winner.totpPeriod;1107 }1108 loginTarget.modifiedAt = now;1109 for (const dup of group) {1110 if (!dup.deletedAt) {1111 dup.deletedAt = now;1112 dup.modifiedAt = now;1113 }1114 }1115 merged = true;1116 } else if (group.length > 1) {1117 for (let i = 1; i < group.length; i += 1) {1118 const dup = group[i]!;1119 if (!dup.deletedAt) {1120 dup.deletedAt = now;1121 dup.modifiedAt = now;1122 }1123 }1124 merged = true;1125 }1126 }11271128 return { merged };1129}11301131113211331134113511361137function hasValidVaultSchema(v: unknown): v is StoredVault {1138 if (typeof v !== "object" || v === null) return false;1139 const r = v as Record<string, unknown>;1140 return (1141 r.version === 1 &&1142 typeof r.salt === "string" &&1143 typeof r.nonce === "string" &&1144 typeof r.ciphertext === "string" &&1145 typeof r.tag === "string"1146 );1147}11481149export type TryDecryptResult =1150 | { status: "ok"; payload: DecryptedPayload }1151 | { status: "wrong_key" }1152 | { status: "corrupted"; reason: string };1153115411551156115711581159export async function tryDecryptSlot(1160 vault: StoredVault,1161 key: Uint8Array,1162 aadTag?: string,1163): Promise<DecryptedPayload | null> {1164 const res = await tryDecryptSlotResult(vault, key, aadTag);1165 return res.status === "ok" ? res.payload : null;1166}11671168export async function tryDecryptSlotResult(1169 vault: StoredVault,1170 key: Uint8Array,1171 aadTag?: string,1172): Promise<TryDecryptResult> {1173 if (!hasValidVaultSchema(vault)) {1174 return { status: "corrupted", reason: "invalid_slot_schema" };1175 }1176 let plain: string;1177 try {1178 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1179 } catch {1180 return { status: "wrong_key" };1181 }1182 try {1183 const parsed = JSON.parse(plain) as unknown;1184 if (typeof parsed !== "object" || parsed === null) {1185 return { status: "corrupted", reason: "invalid_payload_shape" };1186 }1187 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1188 return { status: "corrupted", reason: "invalid_payload_shape" };1189 }1190 return { status: "ok", payload: decodePayload(plain) };1191 } catch {1192 return { status: "corrupted", reason: "invalid_payload_json" };1193 }1194}119511961197119811991200function buildDomainIndexSync(all: VaultEntry[]): void {1201 domainIndex = new Map();12021203 for (const entry of all) {1204 if (entry.deletedAt) continue;1205 if (entry.type === "note" || entry.type === "card") continue;12061207 1208 1209 1210 1211 const domains = new Set<string>();1212 const primary = extractDomain(entry.url ?? "");1213 if (primary) domains.add(primary);1214 for (const extra of entry.additionalUrls ?? []) {1215 const d = extractDomain(extra);1216 if (d) domains.add(d);1217 }1218 if (domains.size === 0) continue;12191220 for (const domain of domains) {1221 const bucket = domainIndex.get(domain) ?? [];1222 bucket.push(entry);1223 domainIndex.set(domain, bucket);1224 }1225 }1226}12271228export async function createVault(password: string): Promise<void> {1229 const salt = generateSalt();1230 derivedKey = await deriveKey(password, salt);1231 entries = [];1232 tombstones = new Map();1233 vaultMode = "real";1234 duressConfigured = false;1235 buildDomainIndexSync(entries);12361237 1238 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1239 await generateAndWrapRootKey();1240 rootKey = unhex(rawRootKey);12411242 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1243 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");12441245 1246 1247 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);12481249 const slotA: StoredVault = {1250 version: 1,1251 salt: hex(salt),1252 nonce,1253 ciphertext,1254 tag,1255 recovery_salt: recoverySalt,1256 recovery_wrapped_root_key: wrappedRootKey,1257 recovery_nonce: wrappedNonce,1258 recovery_tag: wrappedTag,1259 ...pwRoot,1260 };1261 await writeSlot(SLOT_A, slotA);12621263 1264 1265 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1266 await stampLocalVaultMutation();12671268 touchAutoLock();1269}1270127112721273127412751276export async function createVaultFromRootKey(1277 password: string,1278 rootKeyHex: string,1279): Promise<{ vaultId: string }> {1280 if (typeof password !== "string" || password.length === 0) {1281 throw new Error("ADOPT_INVALID_PASSWORD");1282 }1283 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1284 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1285 }12861287 const rootKeyBytes = unhex(rootKeyHex);1288 if (rootKeyBytes.length !== 32) {1289 rootKeyBytes.fill(0);1290 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1291 }12921293 let nextDerivedKey: Uint8Array | null = null;1294 let sessionRootKey: Uint8Array | null = null;1295 try {1296 if ((await loadSlot(SLOT_A)) !== null) {1297 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1298 }12991300 const nextEntries: VaultEntry[] = [];1301 const nextDuressConfigured = false;1302 const vaultId = await computeVaultId(rootKeyBytes);1303 const salt = generateSalt();1304 nextDerivedKey = await deriveKey(password, salt);1305 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);13061307 sessionRootKey = new Uint8Array(rootKeyBytes);1308 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1309 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1310 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1311 const slotA: StoredVault = {1312 version: 1,1313 salt: hex(salt),1314 nonce,1315 ciphertext,1316 tag,1317 recovery_salt: recovery.recoverySalt,1318 recovery_wrapped_root_key: recovery.wrappedRootKey,1319 recovery_nonce: recovery.wrappedNonce,1320 recovery_tag: recovery.wrappedTag,1321 ...pwRoot,1322 };1323 const slotB = generateNoiseBlob(ciphertext.length / 2);1324 await commitCreatedVaultSlots(slotA, slotB);13251326 const previousDerivedKey = derivedKey;1327 const previousRootKey = rootKey;1328 derivedKey = nextDerivedKey;1329 rootKey = sessionRootKey;1330 nextDerivedKey = null;1331 sessionRootKey = null;1332 entries = nextEntries;1333 tombstones = new Map();1334 vaultMode = "real";1335 duressConfigured = nextDuressConfigured;1336 buildDomainIndexSync(entries);1337 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1338 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1339 touchAutoLock();13401341 return { vaultId };1342 } finally {1343 rootKeyBytes.fill(0);1344 if (nextDerivedKey) zeroKey(nextDerivedKey);1345 if (sessionRootKey) sessionRootKey.fill(0);1346 }1347}13481349export async function hasVault(): Promise<boolean> {1350 return (await loadSlot(SLOT_A)) !== null;1351}1352135313541355135613571358export async function isDuressEnabled(): Promise<boolean> {1359 return vaultMode === "real" && duressConfigured;1360}13611362export async function setupDuress(duressPassword: string): Promise<void> {1363 if (!derivedKey || vaultMode !== "real") {1364 throw new Error("vault must be unlocked in real mode");1365 }1366 1367 1368 1369 const slotA = await loadSlot(SLOT_A);1370 if (!slotA) throw new Error("missing slot A");13711372 const decoySalt = generateSalt();1373 const decoyKey = await deriveKey(duressPassword, decoySalt);1374 try {1375 1376 1377 1378 1379 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1380 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1381 await writeSlot(SLOT_B, {1382 version: 1,1383 salt: hex(decoySalt),1384 nonce,1385 ciphertext,1386 tag,1387 });1388 } finally {1389 zeroKey(decoyKey);1390 }13911392 1393 1394 duressConfigured = true;1395 await persistSlotA();1396}13971398export async function disableDuress(): Promise<void> {1399 if (!derivedKey || vaultMode !== "real") {1400 throw new Error("vault must be unlocked in real mode");1401 }1402 1403 1404 1405 const slotA = await loadSlot(SLOT_A);1406 if (slotA) {1407 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1408 }14091410 duressConfigured = false;1411 await persistSlotA();1412}14131414141514161417141814191420export async function verifyMainPassword(password: string): Promise<boolean> {1421 const slotA = await loadSlot(SLOT_A);1422 if (!slotA) return false;1423 const key = await deriveKey(password, unhex(slotA.salt));1424 try {1425 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1426 } finally {1427 zeroKey(key);1428 }1429}14301431export async function unlock(password: string): Promise<boolean> {1432 return attemptUnlockWithDerive(password, deriveKey);1433}14341435async function attemptUnlockWithDerive(1436 password: string,1437 derive: typeof deriveKey,1438): Promise<boolean> {1439 const epoch = vaultEpoch;1440 const stale = () => epoch !== vaultEpoch;1441 1442 1443 1444 1445 1446 1447 const [slotA, slotB] = await Promise.all([1448 loadSlot(SLOT_A),1449 loadSlot(SLOT_B),1450 ]);14511452 1453 const [keyA, keyB] = await Promise.all([1454 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1455 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1456 ]);14571458 1459 1460 1461 const [resultA, resultB] = await Promise.all([1462 keyA && slotA1463 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1464 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1465 keyB && slotB1466 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1467 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1468 ]);1469 const payloadA = resultA.status === "ok" ? resultA.payload : null;1470 const payloadB = resultB.status === "ok" ? resultB.payload : null;14711472 if (stale()) {1473 if (keyA) zeroKey(keyA);1474 if (keyB) zeroKey(keyB);1475 return false;1476 }14771478 if (payloadA && keyA) {1479 if (stale()) {1480 zeroKey(keyA);1481 if (keyB) zeroKey(keyB);1482 return false;1483 }1484 derivedKey = keyA;1485 vaultMode = "real";1486 entries = payloadA.entries;1487 tombstones = payloadA.tombstones;1488 duressConfigured = payloadA.duressConfigured;1489 buildDomainIndexSync(entries);1490 await loadSettings();1491 if (stale()) {1492 await lock();1493 return false;1494 }1495 touchAutoLock();1496 if (keyB) zeroKey(keyB);14971498 1499 1500 rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1501 if (!rootKey) {1502 await lock();1503 return false;1504 }15051506 1507 1508 await reconcilePairSecrets(rootKey);1509 if (stale()) {1510 await lock();1511 return false;1512 }1513 await lockedSyncUnlockHook?.().catch((error) => {1514 console.warn("[Peach sync] locked inbox drain failed:", error);1515 });1516 if (stale()) {1517 await lock();1518 return false;1519 }1520 await resetPinFailedAttempts();1521 if (stale()) {1522 await lock();1523 return false;1524 }1525 await saveSessionKey(keyA, "real", epoch);1526 if (stale()) return false;1527 return true;1528 }15291530 if (payloadB && keyB) {1531 if (stale()) {1532 zeroKey(keyB);1533 if (keyA) zeroKey(keyA);1534 return false;1535 }1536 derivedKey = keyB;1537 vaultMode = "decoy";1538 decoySyncTeardownHook?.();1539 entries = payloadB.entries;1540 tombstones = payloadB.tombstones;1541 duressConfigured = false;1542 buildDomainIndexSync(entries);1543 await loadSettings();1544 touchAutoLock();1545 if (keyA) zeroKey(keyA);1546 1547 await dropSessionCache();1548 if (stale()) {1549 await lock();1550 return false;1551 }1552 await saveSessionKey(keyB, "decoy", epoch);1553 if (stale()) return false;1554 return true;1555 }15561557 if (keyA) zeroKey(keyA);1558 if (keyB) zeroKey(keyB);1559 1560 1561 if (resultA.status === "corrupted") {1562 lastUnlockFailureReason = resultA.reason;1563 } else if (resultB.status === "corrupted") {1564 lastUnlockFailureReason = resultB.reason;1565 }1566 return false;1567}15681569157015711572157315741575const SESSION_KEY_STORAGE = "peach_session_dk";1576157715781579let sessionKeyMutation = Promise.resolve();15801581function mutateSessionKey(mutation: () => Promise<void>): Promise<void> {1582 const task = sessionKeyMutation.then(mutation, mutation);1583 sessionKeyMutation = task.catch(() => undefined);1584 return task;1585}158615871588158915901591let sessionEscrowAvailable = true;15921593159415951596159715981599export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1600 setSessionAllowed(sessionEscrow);1601 if (sessionEscrow) return;1602 sessionEscrowAvailable = false;1603 await clearSessionKey();1604}16051606async function saveSessionKey(1607 key: Uint8Array,1608 mode: "real" | "decoy",1609 expectedEpoch = vaultEpoch,1610): Promise<void> {1611 const session = chrome.storage.session;1612 if (!session || !sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1613 const stored = { keyHex: hex(key), mode };1614 await mutateSessionKey(async () => {1615 if (!sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1616 await session.set({ [SESSION_KEY_STORAGE]: stored });1617 });1618}16191620async function clearSessionKey(): Promise<void> {1621 const session = chrome.storage.session;1622 if (!session) return;1623 await mutateSessionKey(() => session.remove(SESSION_KEY_STORAGE));1624}16251626162716281629163016311632let vaultEpoch = 0;1633163416351636let restoreInFlight: Promise<boolean> | null = null;163716381639164016411642164316441645export async function restoreFromSession(): Promise<boolean> {1646 if (derivedKey !== null) return true;1647 if (restoreInFlight) return restoreInFlight;1648 const task = restoreFromSessionInner();1649 restoreInFlight = task;1650 try {1651 return await task;1652 } finally {1653 restoreInFlight = null;1654 }1655}16561657async function restoreFromSessionInner(): Promise<boolean> {1658 const epoch = vaultEpoch;1659 const stale = () => epoch !== vaultEpoch;1660 if (derivedKey !== null) return true;1661 if (!sessionEscrowAvailable) return false;1662 if (!chrome.storage.session) return false;1663 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1664 if (stale()) return false;1665 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1666 if (!entry?.keyHex) return false;16671668 const key = unhex(entry.keyHex);1669 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1670 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1671 const slot = await loadSlot(slotKey);1672 if (stale()) { zeroKey(key); return false; }1673 if (!slot) { await clearSessionKey(); return false; }16741675 const result = await tryDecryptSlotResult(slot, key, aadTag);1676 if (stale()) { zeroKey(key); return false; }1677 if (result.status !== "ok") { await clearSessionKey(); return false; }16781679 1680 1681 const payload = result.payload;1682 await loadSettings();1683 if (stale()) { zeroKey(key); return false; }16841685 1686 1687 let freshRoot: Uint8Array | null = null;1688 if (entry.mode === "real") {1689 freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1690 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1691 if (!freshRoot) {1692 await lock();1693 return false;1694 }1695 }16961697 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1698 if (freshRoot) {1699 1700 await reconcilePairSecrets(freshRoot);1701 if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1702 } else {1703 1704 await dropSessionCache();1705 if (stale()) { zeroKey(key); return false; }1706 }1707 derivedKey = key;1708 vaultMode = entry.mode;1709 if (entry.mode === "decoy") decoySyncTeardownHook?.();1710 entries = payload.entries;1711 tombstones = payload.tombstones;1712 duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1713 if (freshRoot) rootKey = freshRoot;1714 buildDomainIndexSync(entries);1715 touchAutoLock();17161717 await lockedSyncUnlockHook?.().catch((error) => {1718 console.warn("[Peach sync] locked inbox drain failed:", error);1719 });1720 if (stale()) return false;17211722 return true;1723}17241725export async function lock(): Promise<void> {1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 vaultEpoch++;1736 if (derivedKey) {1737 zeroKey(derivedKey);1738 derivedKey = null;1739 }1740 if (rootKey) {1741 rootKey.fill(0);1742 rootKey = null;1743 }17441745 entries = [];1746 tombstones = new Map();1747 domainIndex = new Map();1748 vaultMode = "real";1749 duressConfigured = false;1750 clearAutoLock();1751 await clearSessionKey();1752 await clearRecoveryConfirmPending();1753}17541755export function isUnlocked(): boolean {1756 return derivedKey !== null;1757}17581759export function isDecoyMode(): boolean {1760 return derivedKey !== null && vaultMode === "decoy";1761}17621763176417651766export async function unlockWithKey(1767 masterKeyHex: string,1768): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1769 const epoch = vaultEpoch;1770 const stale = () => epoch !== vaultEpoch;1771 let masterKeyBytes: Uint8Array | null = null;1772 try {1773 masterKeyBytes = unhex(masterKeyHex);1774 const stored = await loadSlot(SLOT_A);1775 if (!stored) return { ok: false, error: "invalid_key" };1776 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1777 if (!payload || stale()) {1778 zeroKey(masterKeyBytes);1779 return { ok: false, error: "invalid_key" };1780 }17811782 derivedKey = masterKeyBytes;1783 vaultMode = "real";1784 entries = payload.entries;1785 tombstones = payload.tombstones;1786 duressConfigured = payload.duressConfigured;1787 buildDomainIndexSync(entries);1788 await loadSettings();1789 touchAutoLock();17901791 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1792 if (!rootKey) {1793 await lock();1794 return { ok: false, error: "invalid_key" };1795 }1796 await reconcilePairSecrets(rootKey);1797 if (stale()) {1798 await lock();1799 return { ok: false, error: "invalid_key" };1800 }1801 await saveSessionKey(masterKeyBytes, "real", epoch);1802 if (stale()) return { ok: false, error: "invalid_key" };1803 return { ok: true, entries };1804 } catch {1805 if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);1806 return { ok: false, error: "invalid_key" };1807 }1808}180918101811181218131814181518161817export async function exportDuressSlotForAdoption(): Promise<string | null> {1818 if (!(await isDuressEnabled())) return null;1819 const slotB = await loadSlot(SLOT_B);1820 if (!slotB) return null;1821 return JSON.stringify(slotB);1822}18231824182518261827182818291830export async function installAdoptedDuressSlot(json: string): Promise<void> {1831 let parsed: StoredVault;1832 try {1833 const raw = JSON.parse(json) as unknown;1834 if (!hasValidVaultSchema(raw)) {1835 throw new Error("invalid_duress_slot_shape");1836 }1837 parsed = raw;1838 } catch (err) {1839 if (err instanceof SyntaxError) {1840 throw new Error("invalid_duress_slot_shape");1841 }1842 throw err;1843 }1844 await writeSlot(SLOT_B, parsed);1845}18461847184818491850185118521853185418551856185718581859186018611862186318641865export async function unlockFromCodex(params: {1866 slotA: string; 1867 slotB: string | null; 1868 timestamp: number;1869 masterPassphrase: string;1870}): Promise<1871 | { ok: true; entryCount: number }1872 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1873> {1874 let parsedSlotA: StoredVault;1875 let parsedSlotB: StoredVault | null = null;1876 try {1877 const rawA = JSON.parse(params.slotA) as unknown;1878 if (!hasValidVaultSchema(rawA)) {1879 return { ok: false, reason: "invalid_slot_format" };1880 }1881 parsedSlotA = rawA;1882 if (params.slotB !== null) {1883 const rawB = JSON.parse(params.slotB) as unknown;1884 if (!hasValidVaultSchema(rawB)) {1885 return { ok: false, reason: "invalid_slot_format" };1886 }1887 parsedSlotB = rawB;1888 }1889 } catch {1890 return { ok: false, reason: "invalid_slot_format" };1891 }18921893 1894 1895 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1896 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1897 if (!trialPayload) {1898 zeroKey(trialKey);1899 return { ok: false, reason: "wrong_passphrase" };1900 }1901 1902 1903 zeroKey(trialKey);19041905 1906 1907 await takeVaultSnapshot("restore");19081909 await writeSlot(SLOT_A, parsedSlotA);1910 if (parsedSlotB) {1911 await writeSlot(SLOT_B, parsedSlotB);1912 } else {1913 1914 1915 1916 await writeSlot(1917 SLOT_B,1918 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1919 );1920 }1921 await chrome.storage.local.set({1922 peach_last_modified_at: String(params.timestamp),1923 });19241925 1926 1927 const ok = await unlock(params.masterPassphrase);1928 if (!ok) {1929 1930 1931 1932 return { ok: false, reason: "wrong_passphrase" };1933 }1934 return { ok: true, entryCount: getEntries().length };1935}19361937193819391940export function getDerivedKeyHex(): string {1941 if (!derivedKey) throw new Error("vault_not_unlocked");1942 return hex(derivedKey);1943}19441945194619471948194919501951195219531954195519561957195819591960export function getRootKeyHex(): string | null {1961 return rootKey ? hex(rootKey) : null;1962}19631964export function getEntries(): VaultEntry[] {1965 if (!derivedKey) throw new Error("vault_locked");1966 return entries.filter((entry) => !entry.deletedAt);1967}19681969const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;19701971export function getIdentities(): SavedIdentity[] {1972 if (!derivedKey) throw new Error("vault_locked");1973 const active = entries.filter((e) => !e.deletedAt && e.type === "login");19741975 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1976 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();19771978 for (const entry of active) {1979 const domain = extractDomain(entry.url ?? "") ?? "";1980 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;19811982 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1983 const existing = map.get(v);1984 if (!existing || modifiedAt > existing.lastUsed) {1985 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1986 } else {1987 map.set(v, { ...existing, count: existing.count + 1 });1988 }1989 }19901991 const uVal = entry.username?.trim();1992 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);19931994 1995 1996 const eVal = entry.email?.trim();1997 if (eVal) tally(emailMap, eVal);1998 }19992000 const toIdentity = (type: "email" | "username") =>2001 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({2002 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,2003 });20042005 return [2006 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),2007 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),2008 ];2009}20102011export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {2012 if (!derivedKey) throw new Error("vault_locked");20132014 const now = Date.now();2015 const added: VaultEntry[] = [];2016 for (const draft of drafts) {2017 try {2018 const sanitized = sanitizeEntryDraft(draft);2019 const nextEntry: VaultEntry = {2020 ...sanitized,2021 id: crypto.randomUUID(),2022 createdAt: now,2023 modifiedAt: monotonicEntryTimestamp(),2024 };2025 entries.push(nextEntry);2026 added.push(nextEntry);2027 } catch {2028 2029 }2030 }20312032 if (added.length > 0) {2033 await persistEntries();2034 }20352036 return added;2037}203820392040204120422043204420452046export async function restoreEntries(2047 incoming: VaultEntry[],2048 mode: "replace" | "merge"2049): Promise<{ added: number; updated: number; skipped: number }> {2050 if (!derivedKey) throw new Error("vault_locked");20512052 2053 2054 2055 if (mode === "replace" && incoming.length === 0) {2056 throw new Error("refuse to replace with empty entry set");2057 }20582059 let added = 0;2060 let updated = 0;2061 let skipped = 0;20622063 if (mode === "replace") {2064 entries = [];2065 for (const entry of incoming) {2066 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2067 if (!normalized) {2068 skipped += 1;2069 continue;2070 }2071 entries.push(normalized);2072 if (normalized.deletedAt) {2073 tombstones.set(2074 normalized.id,2075 Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2076 );2077 } else {2078 tombstones.delete(normalized.id);2079 }2080 added += 1;2081 }2082 } else {2083 const byId = new Map<string, VaultEntry>();2084 for (const e of entries) byId.set(e.id, e);20852086 for (const entry of incoming) {2087 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2088 if (!normalized) {2089 skipped += 1;2090 continue;2091 }2092 const existing = byId.get(normalized.id);2093 if (!existing) {2094 entries.push(normalized);2095 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2096 else tombstones.delete(normalized.id);2097 added += 1;2098 } else if (normalized.modifiedAt > existing.modifiedAt) {2099 const idx = entries.findIndex((e) => e.id === existing.id);2100 if (idx !== -1) {2101 entries[idx] = normalized;2102 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2103 else tombstones.delete(normalized.id);2104 updated += 1;2105 }2106 } else {2107 skipped += 1;2108 }2109 }2110 }21112112 await persistEntries("import");2113 return { added, updated, skipped };2114}211521162117211821192120212121222123212421252126212721282129213021312132const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;21332134function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2135 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2136 return { ...entry, id: crypto.randomUUID() };2137}213821392140214121422143214421452146function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2147 if (!entry || typeof entry !== "object") return null;2148 const title = typeof entry.title === "string" ? entry.title.trim() : "";2149 if (!title) return null;2150 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2151 const now = Date.now();2152 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2153 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;21542155 2156 2157 const normalized = normalizeEntry({2158 ...entry,2159 id,2160 title,2161 type: entry.type ?? "login",2162 createdAt,2163 modifiedAt,2164 });21652166 if (normalized.passkey) {2167 normalized.passkey = sanitizePasskey(normalized.passkey);2168 }2169 if (normalized.canary) {2170 normalized.canary = sanitizeCanary(normalized.canary);2171 }2172 2173 normalized.tags = sanitizeTags(normalized.tags);21742175 2176 2177 if (2178 normalized.type === "login" &&2179 !normalized.passkey &&2180 typeof normalized.password !== "string"2181 ) {2182 return null;2183 }2184 if (entry.passkey && !normalized.passkey) {2185 2186 2187 return null;2188 }21892190 return normalized;2191}2192219321942195219621972198function sanitizePasskey(pk: unknown): PasskeyData | undefined {2199 if (!pk || typeof pk !== "object") return undefined;2200 const r = pk as Record<string, unknown>;2201 if (2202 typeof r.rpId !== "string" || !r.rpId ||2203 typeof r.credentialId !== "string" || !r.credentialId ||2204 typeof r.publicKeyCose !== "string" ||2205 typeof r.userHandle !== "string" ||2206 typeof r.userName !== "string"2207 ) {2208 return undefined;2209 }2210 return {2211 rpId: r.rpId,2212 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2213 credentialId: r.credentialId,2214 publicKeyCose: r.publicKeyCose,2215 privateKeyJwk:2216 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2217 ? (r.privateKeyJwk as JsonWebKey)2218 : undefined,2219 userHandle: r.userHandle,2220 userName: r.userName,2221 userDisplayName:2222 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2223 algorithm: r.algorithm === -8 ? -8 : -7,2224 signCount: typeof r.signCount === "number" ? r.signCount : 0,2225 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2226 backupEligible: r.backupEligible === true,2227 backupState: r.backupState === true,2228 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2229 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2230 transports: Array.isArray(r.transports)2231 ? r.transports.filter((t): t is string => typeof t === "string")2232 : undefined,2233 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2234 };2235}22362237223822392240224122422243224422452246function sanitizeCanary(c: unknown): Canary | undefined {2247 if (!c || typeof c !== "object") return undefined;2248 const r = c as Record<string, unknown>;2249 if (typeof r.webhookUrl !== "string") return undefined;2250 let url: URL;2251 try {2252 url = new URL(r.webhookUrl);2253 } catch {2254 return undefined;2255 }2256 if (url.protocol !== "https:") return undefined;2257 return {2258 webhookUrl: url.toString(),2259 label: typeof r.label === "string" ? r.label : undefined,2260 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2261 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2262 };2263}22642265226622672268226922702271function sanitizeTags(tags: unknown): string[] | undefined {2272 if (!Array.isArray(tags)) return undefined;2273 const out: string[] = [];2274 for (const t of tags) {2275 if (typeof t !== "string") continue;2276 const trimmed = t.trim();2277 if (!trimmed) continue;2278 out.push(trimmed.slice(0, 256));2279 if (out.length >= 255) break;2280 }2281 return out.length > 0 ? out : undefined;2282}228322842285228622872288export async function clearVaultEntries(2289 confirmation: "REPLACE_ALL"2290): Promise<void> {2291 if (confirmation !== "REPLACE_ALL") {2292 throw new Error("clearVaultEntries requires explicit confirmation");2293 }2294 if (!derivedKey) throw new Error("vault_locked");2295 for (const entry of entries) {2296 if (!entry.deletedAt) {2297 const stamp = monotonicEntryTimestamp(Math.max(2298 entry.modifiedAt,2299 tombstones.get(entry.id) ?? 0,2300 ));2301 entry.deletedAt = stamp;2302 entry.modifiedAt = stamp;2303 tombstones.set(entry.id, stamp);2304 }2305 }2306 await persistEntries();2307}23082309export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2310 if (!derivedKey) throw new Error("vault_locked");23112312 const sanitized = sanitizeEntryDraft(entry);2313 const now = Date.now();2314 const stamp = monotonicEntryTimestamp();2315 const nextEntry: VaultEntry = {2316 ...sanitized,2317 id: crypto.randomUUID(),2318 createdAt: now,2319 modifiedAt: stamp,2320 2321 2322 passwordChangedAt: sanitized.password ? stamp : undefined,2323 };23242325 2326 2327 2328 const rollback = entries;2329 entries = [...entries, nextEntry];2330 try {2331 await persistEntries();2332 } catch (err) {2333 entries = rollback;2334 throw err;2335 }23362337 2338 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2339 checkBreach(nextEntry.password).then(async (count) => {2340 if (count > 0) {2341 const idx = entries.findIndex((e) => e.id === nextEntry.id);2342 if (idx !== -1) {2343 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2344 await persistEntries();2345 }2346 }2347 });2348 }23492350 fireSyncTrigger("vault_change");23512352 return nextEntry;2353}23542355235623572358235923602361export async function consolidatePasskeyEntries(): Promise<void> {2362 if (!derivedKey) return;2363 const { merged } = mergePasskeyOnlyIntoLogins(entries);2364 if (merged) {2365 await persistEntries();2366 }2367}23682369export async function updateEntry(2370 id: string,2371 patch: Partial<VaultEntry>2372): Promise<void> {2373 if (!derivedKey) throw new Error("vault_locked");23742375 const idx = entries.findIndex((entry) => entry.id === id);2376 if (idx === -1 || entries[idx].deletedAt) {2377 throw new Error("entry_not_found");2378 }23792380 const current = entries[idx];2381 const sanitizedPatch = sanitizeEntryPatch(patch);2382 2383 2384 2385 const passwordChanged =2386 "password" in patch &&2387 typeof sanitizedPatch.password === "string" &&2388 sanitizedPatch.password !== current.password;2389 const stamp = monotonicEntryTimestamp(current.modifiedAt);2390 2391 2392 2393 2394 2395 2396 2397 const priorPassword = current.password;2398 const shouldCapture =2399 passwordChanged &&2400 typeof priorPassword === "string" &&2401 priorPassword.length > 0 &&2402 current.passwordHistory?.[0]?.password !== priorPassword;2403 const nextHistory = shouldCapture2404 ? [2405 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2406 ...(current.passwordHistory ?? []),2407 ].slice(0, PASSWORD_HISTORY_MAX)2408 : current.passwordHistory;2409 entries[idx] = {2410 ...current,2411 ...sanitizedPatch,2412 modifiedAt: stamp,2413 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2414 };2415241624172418 2419 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2420 entries[idx] = current;2421 throw new Error("card number is required for card entries");2422 }24232424 2425 2426 2427 2428 try {2429 await persistEntries();2430 } catch (err) {2431 entries[idx] = current;2432 throw err;2433 }24342435 2436 2437 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2438 entries[idx] = { ...entries[idx], breach: undefined };2439 await persistEntries();2440 const password = entries[idx].password;2441 if (password) {2442 checkBreach(password).then(async (count) => {2443 const i = entries.findIndex((e) => e.id === id);2444 if (i !== -1 && count > 0) {2445 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2446 await persistEntries();2447 }2448 });2449 }2450 }24512452 fireSyncTrigger("vault_change");2453}24542455export async function deleteEntry(id: string): Promise<void> {2456 if (!derivedKey) throw new Error("vault_locked");24572458 const entry = entries.find((item) => item.id === id);2459 if (!entry || entry.deletedAt) {2460 throw new Error("entry_not_found");2461 }24622463 2464 2465 2466 const previousModifiedAt = entry.modifiedAt;2467 const previousTombstone = tombstones.get(id);2468 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2469 entry.deletedAt = stamp;2470 entry.modifiedAt = stamp;2471 tombstones.set(id, stamp);2472 2473 2474 try {2475 await persistEntries();2476 } catch (err) {2477 delete entry.deletedAt;2478 entry.modifiedAt = previousModifiedAt;2479 if (previousTombstone === undefined) tombstones.delete(id);2480 else tombstones.set(id, previousTombstone);2481 throw err;2482 }2483 fireSyncTrigger("vault_change");2484}24852486export function getDeletedEntries(): VaultEntry[] {2487 if (!derivedKey) return [];2488 return entries.filter((e) => !!e.deletedAt);2489}249024912492export function getTombstoneManifest(): Record<string, number> {2493 if (!derivedKey) return {};2494 return Object.fromEntries(tombstones);2495}2496249724982499250025012502export async function getVaultVersion(): Promise<number> {2503 try {2504 const raw = await chrome.storage.local.get("peach_last_modified_at");2505 const v = (raw as Record<string, unknown>).peach_last_modified_at;2506 if (typeof v === "number") return v;2507 if (typeof v === "string" && v !== "") {2508 const n = Number.parseInt(v, 10);2509 return Number.isFinite(n) ? n : 0;2510 }2511 return 0;2512 } catch {2513 return 0;2514 }2515}2516251725182519252025212522252325242525252625272528export async function applyMergedRemoteEntries(2529 remoteEntries: VaultEntry[],2530 remoteTombstones: Record<string, number>,2531): Promise<{ received: number; applied: number; conflicts: number }> {2532 2533 2534 2535 2536 2537 2538 if (!derivedKey || vaultMode === "decoy") throw new Error("vault_locked");25392540 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2541 let applied = 0;2542 let conflicts = 0;25432544 const byId = new Map<string, VaultEntry>();2545 for (const e of entries) byId.set(e.id, e);25462547 const mergeNow = Date.now();2548 const remoteTombstoneTimes = new Map<string, number>();2549 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2550 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2551 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2552 }2553 }25542555 for (const remote of remoteEntries) {2556 const sanitized = sanitizeRestoredEntry(remote);2557 if (!sanitized) continue;2558 2559 2560 2561 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2562 if (typeof sanitized.deletedAt === "number") {2563 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2564 }2565 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2566 const localTombstoneTs = tombstones.get(sanitized.id);2567 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2568 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2569 continue;2570 }2571 const local = byId.get(sanitized.id);2572 if (!local) {2573 entries.push(sanitized);2574 byId.set(sanitized.id, sanitized);2575 tombstones.delete(sanitized.id);2576 applied += 1;2577 } else if (sanitized.modifiedAt > local.modifiedAt) {2578 const idx = entries.findIndex((e) => e.id === local.id);2579 if (idx !== -1) {2580 entries[idx] = sanitized;2581 byId.set(sanitized.id, sanitized);2582 tombstones.delete(sanitized.id);2583 applied += 1;2584 if (!local.deletedAt) conflicts += 1;2585 }2586 }2587 }25882589 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2590 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2591 2592 const deletedAt = remoteTombstoneTimes.get(id)!;2593 const knownTombstone = tombstones.get(id);2594 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2595 const local = byId.get(id);2596 if (!local) {2597 tombstones.set(id, deletedAt);2598 applied += 1;2599 continue;2600 }2601 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2602 if (deletedAt < local.modifiedAt) continue;2603 const idx = entries.findIndex((e) => e.id === id);2604 if (idx !== -1) {2605 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2606 tombstones.set(id, deletedAt);2607 applied += 1;2608 }2609 }26102611 if (applied > 0 && vaultMode === "real") {2612 await persistEntries("sync_received");2613 buildDomainIndexSync(entries);2614 }2615 return { received, applied, conflicts };2616}26172618export async function restoreEntry(id: string): Promise<void> {2619 if (!derivedKey) throw new Error("vault_locked");2620 const entry = entries.find((e) => e.id === id && e.deletedAt);2621 if (!entry) throw new Error("deleted entry not found");2622 const previousDeletedAt = entry.deletedAt;2623 const previousModifiedAt = entry.modifiedAt;2624 const previousTombstone = tombstones.get(id);2625 delete entry.deletedAt;2626 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2627 entry.modifiedAt,2628 previousTombstone ?? 0,2629 ));2630 tombstones.delete(id);2631 try {2632 await persistEntries();2633 } catch (err) {2634 entry.deletedAt = previousDeletedAt;2635 entry.modifiedAt = previousModifiedAt;2636 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2637 throw err;2638 }2639 buildDomainIndexSync(entries);2640}26412642export async function purgeEntry(id: string): Promise<void> {2643 if (!derivedKey) throw new Error("vault_locked");2644 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2645 if (idx === -1) throw new Error("deleted entry not found");2646 const entry = entries[idx]!;2647 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2648 entries.splice(idx, 1);2649 await persistEntries();2650}265126522653export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2654 if (!derivedKey || retentionMs === 0) return 0;2655 const cutoff = Date.now() - retentionMs;2656 const before = entries.length;2657 for (const entry of entries) {2658 if (entry.deletedAt && entry.deletedAt < cutoff) {2659 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2660 }2661 }2662 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2663 const purged = before - entries.length;2664 if (purged > 0) await persistEntries();2665 return purged;2666}26672668export function queryDomain(domain: string): VaultEntry[] {2669 return domainIndex.get(domain) ?? [];2670}26712672export function getEntryById(id: string): VaultEntry | undefined {2673 return entries.find((entry) => entry.id === id && !entry.deletedAt);2674}26752676async function persistSlotA(2677 reason: VaultSnapshot["reason"] = "local_save",2678): Promise<StoredVault> {2679 if (!derivedKey) throw new Error("vault_locked");2680 const epoch = vaultEpoch;2681 const throwIfStale = () => {2682 if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2683 };26842685 const slotA = await loadSlot(SLOT_A);2686 throwIfStale();2687 if (!slotA) throw new Error("missing slot A");26882689 2690 2691 await takeVaultSnapshot(reason);2692 throwIfStale();26932694 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2695 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");2696 throwIfStale();26972698 2699 const nextSlotA: StoredVault = {2700 ...slotA,2701 nonce,2702 ciphertext,2703 tag,2704 2705 };27062707 2708 2709 2710 2711 2712 2713 let slotACommitted = false;2714 try {2715 throwIfStale();2716 await writeSlot(SLOT_A, nextSlotA);2717 slotACommitted = true;2718 throwIfStale();27192720 2721 2722 2723 if (!duressConfigured) {2724 throwIfStale();2725 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2726 throwIfStale();2727 }2728 2729 2730 2731 2732 2733 if (reason !== "sync_received") {2734 throwIfStale();2735 await stampLocalVaultMutation();2736 throwIfStale();2737 }2738 } catch (err) {2739 2740 2741 if (slotACommitted) await writeSlot(SLOT_A, slotA).catch(() => {});2742 if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2743 throw err;2744 }27452746 return nextSlotA;2747}27482749async function persistSlotB(): Promise<void> {2750 if (!derivedKey) throw new Error("vault_locked");27512752 const slotB = await loadSlot(SLOT_B);2753 if (!slotB) throw new Error("missing slot B");27542755 2756 2757 2758 2759 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2760 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");27612762 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2763 2764 2765 2766 try {2767 await stampLocalVaultMutation();2768 } catch (err) {2769 await writeSlot(SLOT_B, slotB).catch(() => {});2770 throw err;2771 }2772}27732774async function persistEntries(2775 reason: VaultSnapshot["reason"] = "local_save",2776): Promise<void> {2777 if (!derivedKey) throw new Error("vault_locked");27782779 2780 2781 const previousTombstones = tombstones;2782 tombstones = new Map(tombstones);2783 reconcileTombstones();2784 try {2785 if (vaultMode === "decoy") {2786 await persistSlotB();2787 } else {2788 await persistSlotA(reason);2789 }2790 } catch (err) {2791 tombstones = previousTombstones;2792 throw err;2793 }2794 buildDomainIndexSync(entries);2795 await refreshLockedSyncState();2796}2797279827992800280128022803function allowHttpUrl(url: string | undefined): string {2804 const trimmed = url?.trim() ?? "";2805 if (!trimmed) return "";2806 try {2807 const parsed = new URL(trimmed);2808 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2809 } catch {2810 2811 }2812 return trimmed;2813}28142815function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2816 if (!Array.isArray(values)) return undefined;2817 const seen = new Set<string>();2818 const cleaned: string[] = [];2819 for (const value of values) {2820 const trimmed = typeof value === "string" ? value.trim() : "";2821 if (!trimmed) continue;2822 if (!allowHttpUrl(trimmed)) continue;2823 const key = trimmed.toLowerCase();2824 if (seen.has(key)) continue;2825 seen.add(key);2826 cleaned.push(trimmed);2827 }2828 return cleaned.length > 0 ? cleaned : undefined;2829}28302831function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2832 const title = entry.title.trim();2833 const type: VaultEntry["type"] = entry.type ?? "login";28342835 if (!title) throw new Error("title is required");28362837 if (entry.passkey) {2838 const p = entry.passkey;2839 if (2840 !p.rpId ||2841 !p.credentialId ||2842 !p.publicKeyCose ||2843 !p.userHandle ||2844 !p.userName ||2845 (p.algorithm !== -7 && p.algorithm !== -8)2846 ) {2847 throw new Error("invalid passkey credential");2848 }2849 }2850 if (type === "card") {2851 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2852 throw new Error("cardholder name and card number are required");2853 }2854 }28552856 return {2857 type,2858 title,2859 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2860 email: type === "login" ? entry.email?.trim() || undefined : undefined,2861 password: type === "login" ? entry.password : undefined,2862 url: type === "login" ? allowHttpUrl(entry.url) : undefined,2863 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2864 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2865 2866 2867 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2868 totpDigits: type === "login" ? entry.totpDigits : undefined,2869 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2870 notes: entry.notes?.trim() || undefined,2871 breach: type === "login" ? entry.breach : undefined,2872 2873 2874 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2875 tags: sanitizeTags(entry.tags),2876 passkey: entry.passkey,2877 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2878 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2879 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2880 expYear: type === "card" ? entry.expYear?.trim() : undefined,2881 cvc: type === "card" ? entry.cvc?.trim() : undefined,2882 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2883 network: type === "card" ? entry.network : undefined,2884 last4: type === "card" ? entry.last4?.trim() : undefined,2885 };2886}28872888function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2889 const nextPatch: Partial<VaultEntry> = {};28902891 if ("type" in patch) {2892 nextPatch.type = patch.type;2893 }28942895 if ("title" in patch) {2896 const title = patch.title?.trim() ?? "";2897 if (!title) throw new Error("title is required");2898 nextPatch.title = title;2899 }29002901 if ("url" in patch) {2902 nextPatch.url = allowHttpUrl(patch.url);2903 }29042905 if ("additionalUrls" in patch) {2906 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2907 const seen = new Set<string>();2908 const cleaned: string[] = [];2909 for (const value of incoming) {2910 const trimmed = typeof value === "string" ? value.trim() : "";2911 if (!trimmed || !allowHttpUrl(trimmed)) continue;2912 const key = trimmed.toLowerCase();2913 if (seen.has(key)) continue;2914 seen.add(key);2915 cleaned.push(trimmed);2916 }2917 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2918 }29192920 if ("username" in patch) {2921 nextPatch.username = patch.username?.trim() ?? "";2922 }29232924 if ("email" in patch) {2925 nextPatch.email = patch.email?.trim() || undefined;2926 }29272928 if ("password" in patch) {2929 const password = patch.password ?? "";2930 2931 nextPatch.password = password || undefined;2932 }29332934 if ("totp" in patch) {2935 nextPatch.totp = patch.totp?.trim() || undefined;2936 }29372938 if ("totpAlgorithm" in patch) {2939 nextPatch.totpAlgorithm = patch.totpAlgorithm;2940 }29412942 if ("totpDigits" in patch) {2943 nextPatch.totpDigits = patch.totpDigits;2944 }29452946 if ("totpPeriod" in patch) {2947 nextPatch.totpPeriod = patch.totpPeriod;2948 }29492950 if ("notes" in patch) {2951 nextPatch.notes = patch.notes?.trim() || undefined;2952 }29532954 if ("breach" in patch) {2955 nextPatch.breach = patch.breach;2956 }29572958 if ("canary" in patch) {2959 2960 2961 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2962 }29632964 if ("passkey" in patch) {2965 2966 2967 if (patch.passkey === undefined || patch.passkey === null) {2968 nextPatch.passkey = undefined;2969 } else {2970 const sanitized = sanitizePasskey(patch.passkey);2971 if (!sanitized) throw new Error("invalid passkey credential");2972 nextPatch.passkey = sanitized;2973 }2974 }29752976 if ("tags" in patch) {2977 2978 nextPatch.tags = sanitizeTags(patch.tags);2979 }29802981 if ("cardholderName" in patch) {2982 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2983 }2984 if ("cardNumber" in patch) {2985 const n = patch.cardNumber ?? "";2986 nextPatch.cardNumber = n || undefined;2987 }2988 if ("expMonth" in patch) {2989 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2990 }2991 if ("expYear" in patch) {2992 nextPatch.expYear = patch.expYear?.trim() || undefined;2993 }2994 if ("cvc" in patch) {2995 nextPatch.cvc = patch.cvc?.trim() || undefined;2996 }2997 if ("cardNickname" in patch) {2998 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2999 }3000 if ("network" in patch) {3001 nextPatch.network = patch.network;3002 }3003 if ("last4" in patch) {3004 nextPatch.last4 = patch.last4?.trim() || undefined;3005 }30063007 return nextPatch;3008}30093010function normalizeEntry(entry: VaultEntry): VaultEntry {3011 const base: VaultEntry = {3012 id: entry.id,3013 type: entry.type,3014 title: entry.title,3015 username: entry.username,3016 email: entry.email,3017 password: entry.password,3018 url: entry.url,3019 additionalUrls:3020 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)3021 ? entry.additionalUrls3022 : undefined,3023 totp: entry.totp,3024 3025 totpAlgorithm: entry.totpAlgorithm,3026 totpDigits: entry.totpDigits,3027 totpPeriod: entry.totpPeriod,3028 notes: entry.notes,3029 breach: entry.breach,3030 3031 3032 3033 passwordChangedAt:3034 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,3035 3036 3037 3038 passwordHistory:3039 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)3040 ? entry.passwordHistory3041 : undefined,3042 canary: entry.canary,3043 passkey: entry.passkey,3044 createdAt: entry.createdAt,3045 modifiedAt: entry.modifiedAt,3046 deletedAt: entry.deletedAt,3047 tags: entry.tags,3048 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,3049 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,3050 expMonth: "expMonth" in entry ? entry.expMonth : undefined,3051 expYear: "expYear" in entry ? entry.expYear : undefined,3052 cvc: "cvc" in entry ? entry.cvc : undefined,3053 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,3054 network: "network" in entry ? entry.network : undefined,3055 last4: "last4" in entry ? entry.last4 : undefined,3056 };30573058 return base;3059}3060306130623063306430653066function entryDupeKey(e: VaultEntry): string {3067 if (e.type === "note") return `note|${e.title.toLowerCase()}`;3068 3069 3070 3071 3072 if (e.passkey) {3073 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3074 }3075 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3076}307730783079export function countDuplicates(): number {3080 const best = new Map<string, VaultEntry>();3081 let dupeCount = 0;3082 for (const e of entries.filter((e) => !e.deletedAt)) {3083 const key = entryDupeKey(e);3084 const existing = best.get(key);3085 if (!existing) {3086 best.set(key, e);3087 } else {3088 dupeCount++;3089 3090 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3091 best.set(key, e);3092 }3093 }3094 }3095 return dupeCount;3096}309730983099310031013102export async function deduplicateEntries(): Promise<number> {3103 if (!derivedKey) throw new Error("vault_locked");3104 3105 3106 if (vaultMode === "decoy") {3107 throw new Error("deduplicate disabled in decoy mode");3108 }31093110 const best = new Map<string, VaultEntry>();3111 for (const e of entries.filter((en) => !en.deletedAt)) {3112 const key = entryDupeKey(e);3113 const existing = best.get(key);3114 if (!existing) {3115 best.set(key, e);3116 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3117 best.set(key, e);3118 }3119 }31203121 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3122 let removed = 0;3123 entries = entries.map((e) => {3124 if (!e.deletedAt && !keepIds.has(e.id)) {3125 removed++;3126 const deletedAt = monotonicEntryTimestamp(Math.max(3127 e.modifiedAt,3128 tombstones.get(e.id) ?? 0,3129 ));3130 tombstones.set(e.id, deletedAt);3131 return { ...e, deletedAt };3132 }3133 return e;3134 });31353136 if (removed > 0) await persistEntries();3137 return removed;3138}313931403141314231433144314531463147const HISTORY_KEY = "peach_vault_history";3148const HISTORY_MAX = 5;314931503151315231533154function bucketEntryCount(n: number): number {3155 if (n <= 0) return 0;3156 if (n <= 10) return 10;3157 if (n <= 25) return 25;3158 if (n <= 50) return 50;3159 if (n <= 100) return 100;3160 if (n <= 250) return 250;3161 if (n <= 500) return 500;3162 return Math.ceil(n / 500) * 500;3163}31643165316631673168316931703171export async function takeVaultSnapshot(3172 reason: VaultSnapshot["reason"],3173): Promise<void> {3174 try {3175 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3176 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3177 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];31783179 3180 3181 3182 if (!slotA) return;31833184 const vault_a = JSON.stringify(slotA);3185 const vault_b = JSON.stringify(slotB ?? slotA);31863187 3188 3189 3190 3191 3192 3193 let entryCount: number | null = null;3194 if (derivedKey && vaultMode === "real") {3195 try {3196 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3197 } catch {3198 entryCount = null;3199 }3200 }32013202 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3203 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3204 ? existingRaw3205 : { snapshots: [] };32063207 3208 3209 3210 const latest = history.snapshots[0];3211 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3212 return;3213 }32143215 const snapshot: VaultSnapshot = {3216 id: crypto.randomUUID(),3217 savedAt: Date.now(),3218 reason,3219 vault_a,3220 vault_b,3221 entryCount,3222 };32233224 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3225 await chrome.storage.local.set({ [HISTORY_KEY]: history });3226 } catch (err) {3227 3228 3229 3230 3231 console.warn('[Peach] takeVaultSnapshot failed:', err);3232 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3233 try {3234 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3235 } catch {3236 3237 }3238 }3239 }3240}324132423243export async function clearVaultHistory(): Promise<void> {3244 try {3245 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3246 } catch {3247 3248 }3249}325032513252export async function getVaultHistory(): Promise<VaultSnapshot[]> {3253 const raw = await chrome.storage.local.get(HISTORY_KEY);3254 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3255 if (!history || !Array.isArray(history.snapshots)) return [];3256 return history.snapshots;3257}32583259326032613262326332643265export async function restoreVaultSnapshot(3266 snapshotId: string,3267): Promise<{ ok: true } | { ok: false; error: string }> {3268 const history = await getVaultHistory();3269 const snapshot = history.find((s) => s.id === snapshotId);3270 if (!snapshot) return { ok: false, error: "snapshot_not_found" };32713272 3273 3274 3275 await takeVaultSnapshot("restore");32763277 let restoredA: StoredVault;3278 let restoredB: StoredVault;3279 try {3280 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3281 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3282 } catch {3283 return { ok: false, error: "snapshot_corrupt" };3284 }32853286 await writeSlot(SLOT_A, restoredA);3287 await writeSlot(SLOT_B, restoredB);3288 await stampLocalVaultMutation();32893290 3291 3292 3293 await lock();32943295 return { ok: true };3296}329732983299330033013302330333043305330633073308330933103311export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3312 if (!derivedKey || vaultMode !== "real") {3313 return { ok: false, error: "vault_locked" };3314 }3315 const slotA = await loadSlot(SLOT_A);3316 if (!slotA) return { ok: false, error: "missing_slot_a" };33173318 3319 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3320 await generateAndWrapRootKey();33213322 3323 const newRootKey = unhex(rawRootKey);3324 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);33253326 3327 3328 3329 3330 await takeVaultSnapshot("local_save");3331 const nextSlotA: StoredVault = {3332 ...slotA,3333 recovery_salt: recoverySalt,3334 recovery_wrapped_root_key: wrappedRootKey,3335 recovery_nonce: wrappedNonce,3336 recovery_tag: wrappedTag,3337 ...pwRoot,3338 };3339 await writeSlot(SLOT_A, nextSlotA);33403341 3342 if (rootKey) zeroKey(rootKey);3343 rootKey = newRootKey;33443345 3346 3347 3348 await clearPairedDevices();3349 await clearPairSecrets();33503351 3352 await setRecoveryConfirmPending(); 33533354 await stampLocalVaultMutation();3355 return { ok: true };3356}33573358335933603361336233633364export async function changePassword(3365 currentPassword: string,3366 newPassword: string3367): Promise<boolean> {3368 const epoch = vaultEpoch;3369 const stale = () => epoch !== vaultEpoch;3370 if (!derivedKey || vaultMode !== "real") return false;33713372 3373 const valid = await verifyMainPassword(currentPassword);3374 if (!valid) return false;33753376 const slotA = await loadSlot(SLOT_A);3377 if (!slotA) return false;3378 if (!rootKey) return false;33793380 3381 const newSalt = generateSalt();3382 const newKey = await deriveKey(newPassword, newSalt);3383 if (stale()) {3384 zeroKey(newKey);3385 return false;3386 }33873388 3389 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3390 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");33913392 3393 3394 3395 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);33963397 const finalSlotA: StoredVault = {3398 ...slotA,3399 salt: hex(newSalt),3400 nonce,3401 ciphertext,3402 tag,3403 ...pwRoot,3404 };34053406 3407 3408 3409 await takeVaultSnapshot("local_save");34103411 3412 3413 3414 3415 await writeSlot(SLOT_A, finalSlotA);3416 if (stale()) {3417 zeroKey(newKey);3418 return false;3419 }34203421 3422 3423 3424 zeroKey(derivedKey);3425 derivedKey = newKey;34263427 3428 3429 3430 3431 if (stale()) {3432 await lock();3433 return false;3434 }3435 await saveSessionKey(newKey, "real", epoch);3436 if (stale()) return false;34373438 3439 3440 3441 if (!duressConfigured) {3442 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3443 }3444 await clearPinUnlockStorage();3445 await stampLocalVaultMutation();34463447 return true;3448}