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;408409export function setLockedSyncHooks(hooks: {410 refresh: () => Promise<void>;411 afterUnlock: () => Promise<void>;412}): void {413 lockedSyncRefreshHook = hooks.refresh;414 lockedSyncUnlockHook = hooks.afterUnlock;415}416417async function refreshLockedSyncState(): Promise<void> {418 if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;419 await lockedSyncRefreshHook().catch((error) => {420 console.warn("[Peach sync] locked spool refresh failed:", error);421 });422}423424425426427428export async function checkBreach(password: string): Promise<number> {429 430 431 const encoder = new TextEncoder();432 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));433 const hashHex = Array.from(new Uint8Array(hashBuffer))434 .map((b) => b.toString(16).padStart(2, "0"))435 .join("")436 .toUpperCase();437438 const prefix = hashHex.slice(0, 5);439 const suffix = hashHex.slice(5);440441 try {442 443 444 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {445 headers: { "Add-Padding": "true" },446 });447 if (!res.ok) return 0;448 const text = await res.text();449 const match = text.split("\r\n").find((l) => l.startsWith(suffix));450 return match ? parseInt(match.split(":")[1], 10) : 0;451 } catch {452 return 0; 453 }454}455456export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {457 458 const key = await cardDeclineKey();459 if (!key) return;460 const fingerprint = await cardFingerprint(last4, expMonth, expYear);461 await loadSettings();462 settings = {463 ...settings,464 declinedCardFingerprints: [465 466 467 ...(settings.declinedCardFingerprints ?? []).filter(468 (stored) => stored.length === CARD_DECLINE_DIGEST_HEX,469 ),470 await metadataDigest(fingerprint, key),471 ].slice(-CARD_DECLINE_MAX),472 };473 await persistSettings();474}475476export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {477 await loadSettings();478 const key = await cardDeclineKey();479 if (!key) return false;480 const fingerprint = await cardFingerprint(last4, expMonth, expYear);481 const candidate = await metadataDigest(fingerprint, key);482 return (settings.declinedCardFingerprints ?? []).some(483 (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate,484 );485}486487488489490491492493494495496497498async function cardDeclineKey(): Promise<string | null> {499 const root = getRootKeyHex();500 if (!root) return null;501 const digest = new Uint8Array(502 await crypto.subtle.digest(503 "SHA-256",504 new TextEncoder().encode(`peach-card-decline-v1:${root}`),505 ),506 );507 return hex(digest);508}509510async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {511 const encoder = new TextEncoder();512 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));513 return Array.from(new Uint8Array(buf).slice(0, 8))514 .map((b) => b.toString(16).padStart(2, "0"))515 .join("");516}517518function hex(buf: Uint8Array): string {519 return Array.from(buf)520 .map((b) => b.toString(16).padStart(2, "0"))521 .join("");522}523524function unhex(s: string): Uint8Array {525 const out = new Uint8Array(s.length / 2);526 for (let i = 0; i < s.length; i += 2) {527 out[i / 2] = parseInt(s.slice(i, i + 2), 16);528 }529 return out;530}531532533534535536537538539540541542543544545546547548549550551async function generateAndWrapRootKey(): Promise<{552 rawRootKey: string; 553 recoverySalt: string;554 wrappedRootKey: string;555 wrappedNonce: string;556 wrappedTag: string;557}> {558 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));559560 561 const words = await rootKeyToWords(rootKeyBytes);562 const wordMaterial = await wordsToRootKeyMaterial(words);563 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));564 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);565566 567 568 const nonce = crypto.getRandomValues(new Uint8Array(12));569 const encoded = new TextEncoder().encode(hex(rootKeyBytes));570 const raw = await crypto.subtle.encrypt(571 {572 name: "AES-GCM",573 iv: nonce,574 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,575 tagLength: 128,576 },577 recoveryKey,578 encoded579 );580 581 const rawU8 = new Uint8Array(raw);582 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));583 const tag = hex(rawU8.slice(rawU8.length - 16));584585 return {586 rawRootKey: hex(rootKeyBytes),587 recoverySalt: hex(recoverySalt),588 wrappedRootKey: ciphertext,589 wrappedNonce: hex(nonce),590 wrappedTag: tag,591 };592}593594async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{595 recoverySalt: string;596 wrappedRootKey: string;597 wrappedNonce: string;598 wrappedTag: string;599}> {600 if (rootKeyBytes.length !== 32) {601 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");602 }603604 const words = await rootKeyToWords(rootKeyBytes);605 const wordMaterial = await wordsToRootKeyMaterial(words);606 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));607 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);608 const nonce = crypto.getRandomValues(new Uint8Array(12));609 const encoded = new TextEncoder().encode(hex(rootKeyBytes));610 const raw = await crypto.subtle.encrypt(611 {612 name: "AES-GCM",613 iv: nonce,614 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,615 tagLength: 128,616 },617 recoveryKey,618 encoded,619 );620 const rawU8 = new Uint8Array(raw);621 return {622 recoverySalt: hex(recoverySalt),623 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),624 wrappedNonce: hex(nonce),625 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),626 };627}628629630631632633634export async function unwrapRootKeyForRecovery(635 wrappedRootKey: string,636 wrappedNonce: string,637 wrappedTag: string,638 recoverySaltHex: string,639 words: string[]640): Promise<void> {641 const wordMaterial = await wordsToRootKeyMaterial(words);642 const recoverySalt = unhex(recoverySaltHex);643 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);644645 646 647 648 649 650 const nonceRaw = unhex(wrappedNonce);651 const ciphertextRaw = unhex(wrappedRootKey);652 const tagRaw = unhex(wrappedTag);653 654 655 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;656 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);657 combined.set(ciphertextRaw, 0);658 combined.set(tagRaw, ciphertextRaw.length);659 const raw = await crypto.subtle.decrypt(660 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },661 recoveryKey,662 combined663 );664 const decryptedHex = new TextDecoder().decode(raw);665 rootKey = unhex(decryptedHex);666}667668669670671672673674675676677export async function rewrapRootKeyForPassword(678 newDerivedKey: Uint8Array,679 _newSalt: Uint8Array680): Promise<void> {681 if (!rootKey) throw new Error("root key not set");682 const slotA = await loadSlot(SLOT_A);683 if (!slotA) throw new Error("missing slot A");684 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);685 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });686}687688689690691692693async function reencryptVaultForNewKey(694 newKey: Uint8Array,695 slotA: StoredVault696): Promise<void> {697 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);698 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");699 await writeSlot(SLOT_A, {700 ...slotA,701 nonce,702 ciphertext,703 tag,704 });705}706707708709710711712export async function changePasswordWithNewKey(713 newSalt: Uint8Array,714 newKey: Uint8Array715): Promise<void> {716 const epoch = vaultEpoch;717 const throwIfStale = () => {718 if (epoch === vaultEpoch) return;719 zeroKey(newKey);720 throw new Error("vault_locked");721 };722 const slotA = await loadSlot(SLOT_A);723 throwIfStale();724 if (!slotA) throw new Error("missing slot A");725 726 727 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });728 throwIfStale();729 if (derivedKey) zeroKey(derivedKey);730 derivedKey = newKey;731 732 733 734 await saveSessionKey(newKey, "real", epoch);735 throwIfStale();736 737 738 739 740 if (rootKey) {741 const slotAfter = await loadSlot(SLOT_A);742 throwIfStale();743 if (slotAfter) {744 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);745 throwIfStale();746 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });747 throwIfStale();748 }749 }750 if (!duressConfigured) {751 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));752 throwIfStale();753 }754 await clearPinUnlockStorage();755 throwIfStale();756 await stampLocalVaultMutation();757 throwIfStale();758}759760761762763764765export { deriveKey, generateSalt };766767export async function loadSlotA(): Promise<StoredVault | null> {768 return loadSlot(SLOT_A);769}770771772773774775776777778779780async function deriveRootKeyFromDerivedKey(781 derivedKeyBytes: Uint8Array,782 slotA: StoredVault,783): Promise<Uint8Array | null> {784 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {785 return null;786 }787 try {788 const rootHex = await decrypt(789 derivedKeyBytes,790 slotA.pw_root_nonce,791 slotA.pw_root_ciphertext,792 slotA.pw_root_tag,793 "pw_root_v1",794 );795 return unhex(rootHex);796 } catch {797 return null;798 }799}800801802803async function wrapRootKeyForDerivedKey(804 derivedKeyBytes: Uint8Array,805 rootKeyBytes: Uint8Array,806): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {807 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");808 return {809 pw_root_ciphertext: ciphertext,810 pw_root_nonce: nonce,811 pw_root_tag: tag,812 };813}814815816export async function getRecoveryWords(): Promise<string[]> {817 if (!rootKey) {818 throw new Error("recovery not set up");819 }820 return rootKeyToWords(rootKey);821}822823async function loadSlot(key: string): Promise<StoredVault | null> {824 const result = await chrome.storage.local.get(key);825 return (result as Record<string, StoredVault | undefined>)[key] ?? null;826}827828async function writeSlot(key: string, vault: StoredVault): Promise<void> {829 try {830 await chrome.storage.local.set({ [key]: vault });831 } catch (err) {832 throw mapStorageError(err);833 }834}835836837838839840841842843844845export async function rollbackAdoptedVault(): Promise<void> {846 await lock();847 try {848 await chrome.storage.local.remove([849 SLOT_A,850 SLOT_B,851 "peach_last_modified_at",852 ]);853 } catch (err) {854 throw mapStorageError(err);855 }856}857858async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {859 const raw = await chrome.storage.local.get("peach_last_modified_at");860 const previous = Number(861 (raw as Record<string, unknown>).peach_last_modified_at,862 );863 const next = Math.max(864 Date.now(),865 Number.isFinite(previous) ? previous + 1 : 0,866 );867 try {868 await chrome.storage.local.set({869 [SLOT_A]: slotA,870 [SLOT_B]: slotB,871 peach_last_modified_at: String(next),872 });873 } catch (err) {874 throw mapStorageError(err);875 }876}877878879880881882883884885let lastEntryTimestamp = 0;886function monotonicEntryTimestamp(currentModifiedAt?: number): number {887 const base = Math.max(888 Date.now(),889 (currentModifiedAt ?? 0) + 1,890 lastEntryTimestamp + 1,891 );892 lastEntryTimestamp = base;893 return base;894}895896async function stampLocalVaultMutation(): Promise<void> {897 const raw = await chrome.storage.local.get("peach_last_modified_at");898 const previous = Number(899 (raw as Record<string, unknown>).peach_last_modified_at,900 );901 const next = Math.max(902 Date.now(),903 Number.isFinite(previous) ? previous + 1 : 0,904 );905 await chrome.storage.local.set({906 peach_last_modified_at: String(next),907 });908}909910911912913914915916917918919920function padPlaintext(json: string, minBytes = 0): string {921 const encoded = new TextEncoder().encode(json);922 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);923 924 const target = Math.max(bucket, minBytes);925 const padLen = target - encoded.length;926 return json + " ".repeat(padLen);927}928929930931932933934935936937938939const CSPRNG_CHUNK = 65_536;940941function fillRandom(buf: Uint8Array): Uint8Array {942 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {943 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);944 const chunk = new Uint8Array(end - offset);945 crypto.getRandomValues(chunk);946 buf.set(chunk, offset);947 }948 return buf;949}950951function generateNoiseBlob(ciphertextBytes: number): StoredVault {952 const salt = crypto.getRandomValues(new Uint8Array(16));953 const nonce = crypto.getRandomValues(new Uint8Array(12));954 const cipher = fillRandom(new Uint8Array(ciphertextBytes));955 const tag = crypto.getRandomValues(new Uint8Array(16));956 return {957 version: 1,958 salt: hex(salt),959 nonce: hex(nonce),960 ciphertext: hex(cipher),961 tag: hex(tag),962 };963}964965966967968969type VaultPayload = {970 entries: VaultEntry[];971 tombstones?: Record<string, number>;972 duressConfigured?: boolean;973};974975type DecryptedPayload = {976 entries: VaultEntry[];977 tombstones: Map<string, number>;978 duressConfigured: boolean;979};980981function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {982 const envelope: VaultPayload = {983 entries,984 tombstones: Object.fromEntries(tombstones),985 duressConfigured: duress,986 };987 return padPlaintext(JSON.stringify(envelope), minBytes);988}989990function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {991 const result = new Map<string, number>();992 if (raw && typeof raw === "object" && !Array.isArray(raw)) {993 for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {994 if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {995 result.set(id, timestamp);996 }997 }998 }999 1000 1001 for (const entry of allEntries) {1002 if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {1003 continue;1004 }1005 if ((result.get(entry.id) ?? 0) < entry.deletedAt) {1006 result.set(entry.id, entry.deletedAt);1007 }1008 }1009 return result;1010}10111012function reconcileTombstones(): void {1013 const byId = new Map(entries.map((entry) => [entry.id, entry]));1014 for (const [id] of tombstones) {1015 if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);1016 }1017 for (const entry of entries) {1018 if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {1019 if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {1020 tombstones.set(entry.id, entry.deletedAt);1021 }1022 }1023 }1024}10251026function decodePayload(plain: string): DecryptedPayload {1027 const parsed = JSON.parse(plain) as VaultPayload;1028 if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {1029 throw new Error("invalid vault payload");1030 }1031 const normalized = parsed.entries.map(normalizeEntry);1032 return {1033 entries: normalized,1034 tombstones: decodeTombstones(parsed.tombstones, normalized),1035 duressConfigured: parsed.duressConfigured === true,1036 };1037}1038103910401041104210431044104510461047104810491050105110521053105410551056105710581059function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1060 let merged = false;1061 const now = Date.now();10621063 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1064 const byRpId = new Map<string, VaultEntry[]>();1065 for (const e of candidates) {1066 const rpId = e.passkey!.rpId;1067 const list = byRpId.get(rpId);1068 if (list) list.push(e);1069 else byRpId.set(rpId, [e]);1070 }10711072 for (const [rpId, group] of byRpId) {1073 if (group.length === 0) continue;1074 group.sort((a, b) => {1075 const aT =1076 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1077 const bT =1078 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1079 return bT - aT;1080 });1081 const winner = group[0]!;10821083 1084 1085 1086 const loginTarget = entries.find((e) => {1087 if (e.deletedAt || e.passkey || e.type !== "login") return false;1088 if (e.url && extractDomain(e.url) === rpId) return true;1089 for (const extra of e.additionalUrls ?? []) {1090 if (extractDomain(extra) === rpId) return true;1091 }1092 return false;1093 });10941095 if (loginTarget) {1096 loginTarget.passkey = winner.passkey;1097 1098 1099 1100 if (!loginTarget.totp && winner.totp) {1101 loginTarget.totp = winner.totp;1102 loginTarget.totpAlgorithm = winner.totpAlgorithm;1103 loginTarget.totpDigits = winner.totpDigits;1104 loginTarget.totpPeriod = winner.totpPeriod;1105 }1106 loginTarget.modifiedAt = now;1107 for (const dup of group) {1108 if (!dup.deletedAt) {1109 dup.deletedAt = now;1110 dup.modifiedAt = now;1111 }1112 }1113 merged = true;1114 } else if (group.length > 1) {1115 for (let i = 1; i < group.length; i += 1) {1116 const dup = group[i]!;1117 if (!dup.deletedAt) {1118 dup.deletedAt = now;1119 dup.modifiedAt = now;1120 }1121 }1122 merged = true;1123 }1124 }11251126 return { merged };1127}11281129113011311132113311341135function hasValidVaultSchema(v: unknown): v is StoredVault {1136 if (typeof v !== "object" || v === null) return false;1137 const r = v as Record<string, unknown>;1138 return (1139 r.version === 1 &&1140 typeof r.salt === "string" &&1141 typeof r.nonce === "string" &&1142 typeof r.ciphertext === "string" &&1143 typeof r.tag === "string"1144 );1145}11461147export type TryDecryptResult =1148 | { status: "ok"; payload: DecryptedPayload }1149 | { status: "wrong_key" }1150 | { status: "corrupted"; reason: string };1151115211531154115511561157export async function tryDecryptSlot(1158 vault: StoredVault,1159 key: Uint8Array,1160 aadTag?: string,1161): Promise<DecryptedPayload | null> {1162 const res = await tryDecryptSlotResult(vault, key, aadTag);1163 return res.status === "ok" ? res.payload : null;1164}11651166export async function tryDecryptSlotResult(1167 vault: StoredVault,1168 key: Uint8Array,1169 aadTag?: string,1170): Promise<TryDecryptResult> {1171 if (!hasValidVaultSchema(vault)) {1172 return { status: "corrupted", reason: "invalid_slot_schema" };1173 }1174 let plain: string;1175 try {1176 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1177 } catch {1178 return { status: "wrong_key" };1179 }1180 try {1181 const parsed = JSON.parse(plain) as unknown;1182 if (typeof parsed !== "object" || parsed === null) {1183 return { status: "corrupted", reason: "invalid_payload_shape" };1184 }1185 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1186 return { status: "corrupted", reason: "invalid_payload_shape" };1187 }1188 return { status: "ok", payload: decodePayload(plain) };1189 } catch {1190 return { status: "corrupted", reason: "invalid_payload_json" };1191 }1192}119311941195119611971198function buildDomainIndexSync(all: VaultEntry[]): void {1199 domainIndex = new Map();12001201 for (const entry of all) {1202 if (entry.deletedAt) continue;1203 if (entry.type === "note" || entry.type === "card") continue;12041205 1206 1207 1208 1209 const domains = new Set<string>();1210 const primary = extractDomain(entry.url ?? "");1211 if (primary) domains.add(primary);1212 for (const extra of entry.additionalUrls ?? []) {1213 const d = extractDomain(extra);1214 if (d) domains.add(d);1215 }1216 if (domains.size === 0) continue;12171218 for (const domain of domains) {1219 const bucket = domainIndex.get(domain) ?? [];1220 bucket.push(entry);1221 domainIndex.set(domain, bucket);1222 }1223 }1224}12251226export async function createVault(password: string): Promise<void> {1227 const salt = generateSalt();1228 derivedKey = await deriveKey(password, salt);1229 entries = [];1230 tombstones = new Map();1231 vaultMode = "real";1232 duressConfigured = false;1233 buildDomainIndexSync(entries);12341235 1236 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1237 await generateAndWrapRootKey();1238 rootKey = unhex(rawRootKey);12391240 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1241 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");12421243 1244 1245 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);12461247 const slotA: StoredVault = {1248 version: 1,1249 salt: hex(salt),1250 nonce,1251 ciphertext,1252 tag,1253 recovery_salt: recoverySalt,1254 recovery_wrapped_root_key: wrappedRootKey,1255 recovery_nonce: wrappedNonce,1256 recovery_tag: wrappedTag,1257 ...pwRoot,1258 };1259 await writeSlot(SLOT_A, slotA);12601261 1262 1263 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1264 await stampLocalVaultMutation();12651266 touchAutoLock();1267}1268126912701271127212731274export async function createVaultFromRootKey(1275 password: string,1276 rootKeyHex: string,1277): Promise<{ vaultId: string }> {1278 if (typeof password !== "string" || password.length === 0) {1279 throw new Error("ADOPT_INVALID_PASSWORD");1280 }1281 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1282 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1283 }12841285 const rootKeyBytes = unhex(rootKeyHex);1286 if (rootKeyBytes.length !== 32) {1287 rootKeyBytes.fill(0);1288 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1289 }12901291 let nextDerivedKey: Uint8Array | null = null;1292 let sessionRootKey: Uint8Array | null = null;1293 try {1294 if ((await loadSlot(SLOT_A)) !== null) {1295 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1296 }12971298 const nextEntries: VaultEntry[] = [];1299 const nextDuressConfigured = false;1300 const vaultId = await computeVaultId(rootKeyBytes);1301 const salt = generateSalt();1302 nextDerivedKey = await deriveKey(password, salt);1303 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);13041305 sessionRootKey = new Uint8Array(rootKeyBytes);1306 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1307 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1308 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1309 const slotA: StoredVault = {1310 version: 1,1311 salt: hex(salt),1312 nonce,1313 ciphertext,1314 tag,1315 recovery_salt: recovery.recoverySalt,1316 recovery_wrapped_root_key: recovery.wrappedRootKey,1317 recovery_nonce: recovery.wrappedNonce,1318 recovery_tag: recovery.wrappedTag,1319 ...pwRoot,1320 };1321 const slotB = generateNoiseBlob(ciphertext.length / 2);1322 await commitCreatedVaultSlots(slotA, slotB);13231324 const previousDerivedKey = derivedKey;1325 const previousRootKey = rootKey;1326 derivedKey = nextDerivedKey;1327 rootKey = sessionRootKey;1328 nextDerivedKey = null;1329 sessionRootKey = null;1330 entries = nextEntries;1331 tombstones = new Map();1332 vaultMode = "real";1333 duressConfigured = nextDuressConfigured;1334 buildDomainIndexSync(entries);1335 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1336 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1337 touchAutoLock();13381339 return { vaultId };1340 } finally {1341 rootKeyBytes.fill(0);1342 if (nextDerivedKey) zeroKey(nextDerivedKey);1343 if (sessionRootKey) sessionRootKey.fill(0);1344 }1345}13461347export async function hasVault(): Promise<boolean> {1348 return (await loadSlot(SLOT_A)) !== null;1349}1350135113521353135413551356export async function isDuressEnabled(): Promise<boolean> {1357 return vaultMode === "real" && duressConfigured;1358}13591360export async function setupDuress(duressPassword: string): Promise<void> {1361 if (!derivedKey || vaultMode !== "real") {1362 throw new Error("vault must be unlocked in real mode");1363 }1364 1365 1366 1367 const slotA = await loadSlot(SLOT_A);1368 if (!slotA) throw new Error("missing slot A");13691370 const decoySalt = generateSalt();1371 const decoyKey = await deriveKey(duressPassword, decoySalt);1372 try {1373 1374 1375 1376 1377 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1378 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1379 await writeSlot(SLOT_B, {1380 version: 1,1381 salt: hex(decoySalt),1382 nonce,1383 ciphertext,1384 tag,1385 });1386 } finally {1387 zeroKey(decoyKey);1388 }13891390 1391 1392 duressConfigured = true;1393 await persistSlotA();1394}13951396export async function disableDuress(): Promise<void> {1397 if (!derivedKey || vaultMode !== "real") {1398 throw new Error("vault must be unlocked in real mode");1399 }1400 1401 1402 1403 const slotA = await loadSlot(SLOT_A);1404 if (slotA) {1405 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1406 }14071408 duressConfigured = false;1409 await persistSlotA();1410}14111412141314141415141614171418export async function verifyMainPassword(password: string): Promise<boolean> {1419 const slotA = await loadSlot(SLOT_A);1420 if (!slotA) return false;1421 const key = await deriveKey(password, unhex(slotA.salt));1422 try {1423 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1424 } finally {1425 zeroKey(key);1426 }1427}14281429export async function unlock(password: string): Promise<boolean> {1430 return attemptUnlockWithDerive(password, deriveKey);1431}14321433async function attemptUnlockWithDerive(1434 password: string,1435 derive: typeof deriveKey,1436): Promise<boolean> {1437 const epoch = vaultEpoch;1438 const stale = () => epoch !== vaultEpoch;1439 1440 1441 1442 1443 1444 1445 const [slotA, slotB] = await Promise.all([1446 loadSlot(SLOT_A),1447 loadSlot(SLOT_B),1448 ]);14491450 1451 const [keyA, keyB] = await Promise.all([1452 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1453 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1454 ]);14551456 1457 1458 1459 const [resultA, resultB] = await Promise.all([1460 keyA && slotA1461 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1462 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1463 keyB && slotB1464 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1465 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1466 ]);1467 const payloadA = resultA.status === "ok" ? resultA.payload : null;1468 const payloadB = resultB.status === "ok" ? resultB.payload : null;14691470 if (stale()) {1471 if (keyA) zeroKey(keyA);1472 if (keyB) zeroKey(keyB);1473 return false;1474 }14751476 if (payloadA && keyA) {1477 if (stale()) {1478 zeroKey(keyA);1479 if (keyB) zeroKey(keyB);1480 return false;1481 }1482 derivedKey = keyA;1483 vaultMode = "real";1484 entries = payloadA.entries;1485 tombstones = payloadA.tombstones;1486 duressConfigured = payloadA.duressConfigured;1487 buildDomainIndexSync(entries);1488 await loadSettings();1489 if (stale()) {1490 await lock();1491 return false;1492 }1493 touchAutoLock();1494 if (keyB) zeroKey(keyB);14951496 1497 1498 rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1499 if (!rootKey) {1500 await lock();1501 return false;1502 }15031504 1505 1506 await reconcilePairSecrets(rootKey);1507 if (stale()) {1508 await lock();1509 return false;1510 }1511 await lockedSyncUnlockHook?.().catch((error) => {1512 console.warn("[Peach sync] locked inbox drain failed:", error);1513 });1514 if (stale()) {1515 await lock();1516 return false;1517 }1518 await resetPinFailedAttempts();1519 if (stale()) {1520 await lock();1521 return false;1522 }1523 await saveSessionKey(keyA, "real", epoch);1524 if (stale()) return false;1525 return true;1526 }15271528 if (payloadB && keyB) {1529 if (stale()) {1530 zeroKey(keyB);1531 if (keyA) zeroKey(keyA);1532 return false;1533 }1534 derivedKey = keyB;1535 vaultMode = "decoy";1536 vaultMode = "decoy";1537 entries = payloadB.entries;1538 tombstones = payloadB.tombstones;1539 duressConfigured = false;1540 buildDomainIndexSync(entries);1541 await loadSettings();1542 touchAutoLock();1543 if (keyA) zeroKey(keyA);1544 1545 await dropSessionCache();1546 if (stale()) {1547 await lock();1548 return false;1549 }1550 await saveSessionKey(keyB, "decoy", epoch);1551 if (stale()) return false;1552 return true;1553 }15541555 if (keyA) zeroKey(keyA);1556 if (keyB) zeroKey(keyB);1557 1558 1559 if (resultA.status === "corrupted") {1560 lastUnlockFailureReason = resultA.reason;1561 } else if (resultB.status === "corrupted") {1562 lastUnlockFailureReason = resultB.reason;1563 }1564 return false;1565}15661567156815691570157115721573const SESSION_KEY_STORAGE = "peach_session_dk";1574157515761577let sessionKeyMutation = Promise.resolve();15781579function mutateSessionKey(mutation: () => Promise<void>): Promise<void> {1580 const task = sessionKeyMutation.then(mutation, mutation);1581 sessionKeyMutation = task.catch(() => undefined);1582 return task;1583}158415851586158715881589let sessionEscrowAvailable = true;15901591159215931594159515961597export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1598 setSessionAllowed(sessionEscrow);1599 if (sessionEscrow) return;1600 sessionEscrowAvailable = false;1601 await clearSessionKey();1602}16031604async function saveSessionKey(1605 key: Uint8Array,1606 mode: "real" | "decoy",1607 expectedEpoch = vaultEpoch,1608): Promise<void> {1609 const session = chrome.storage.session;1610 if (!session || !sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1611 const stored = { keyHex: hex(key), mode };1612 await mutateSessionKey(async () => {1613 if (!sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1614 await session.set({ [SESSION_KEY_STORAGE]: stored });1615 });1616}16171618async function clearSessionKey(): Promise<void> {1619 const session = chrome.storage.session;1620 if (!session) return;1621 await mutateSessionKey(() => session.remove(SESSION_KEY_STORAGE));1622}16231624162516261627162816291630let vaultEpoch = 0;1631163216331634let restoreInFlight: Promise<boolean> | null = null;163516361637163816391640164116421643export async function restoreFromSession(): Promise<boolean> {1644 if (derivedKey !== null) return true;1645 if (restoreInFlight) return restoreInFlight;1646 const task = restoreFromSessionInner();1647 restoreInFlight = task;1648 try {1649 return await task;1650 } finally {1651 restoreInFlight = null;1652 }1653}16541655async function restoreFromSessionInner(): Promise<boolean> {1656 const epoch = vaultEpoch;1657 const stale = () => epoch !== vaultEpoch;1658 if (derivedKey !== null) return true;1659 if (!sessionEscrowAvailable) return false;1660 if (!chrome.storage.session) return false;1661 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1662 if (stale()) return false;1663 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1664 if (!entry?.keyHex) return false;16651666 const key = unhex(entry.keyHex);1667 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1668 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1669 const slot = await loadSlot(slotKey);1670 if (stale()) { zeroKey(key); return false; }1671 if (!slot) { await clearSessionKey(); return false; }16721673 const result = await tryDecryptSlotResult(slot, key, aadTag);1674 if (stale()) { zeroKey(key); return false; }1675 if (result.status !== "ok") { await clearSessionKey(); return false; }16761677 1678 1679 const payload = result.payload;1680 await loadSettings();1681 if (stale()) { zeroKey(key); return false; }16821683 1684 1685 let freshRoot: Uint8Array | null = null;1686 if (entry.mode === "real") {1687 freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1688 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1689 if (!freshRoot) {1690 await lock();1691 return false;1692 }1693 }16941695 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1696 if (freshRoot) {1697 1698 await reconcilePairSecrets(freshRoot);1699 if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1700 } else {1701 1702 await dropSessionCache();1703 if (stale()) { zeroKey(key); return false; }1704 }1705 derivedKey = key;1706 vaultMode = entry.mode;1707 entries = payload.entries;1708 tombstones = payload.tombstones;1709 duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1710 if (freshRoot) rootKey = freshRoot;1711 buildDomainIndexSync(entries);1712 touchAutoLock();17131714 await lockedSyncUnlockHook?.().catch((error) => {1715 console.warn("[Peach sync] locked inbox drain failed:", error);1716 });1717 if (stale()) return false;17181719 return true;1720}17211722export async function lock(): Promise<void> {1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 vaultEpoch++;1733 if (derivedKey) {1734 zeroKey(derivedKey);1735 derivedKey = null;1736 }1737 if (rootKey) {1738 rootKey.fill(0);1739 rootKey = null;1740 }17411742 entries = [];1743 tombstones = new Map();1744 domainIndex = new Map();1745 vaultMode = "real";1746 duressConfigured = false;1747 clearAutoLock();1748 await clearSessionKey();1749 await clearRecoveryConfirmPending();1750}17511752export function isUnlocked(): boolean {1753 return derivedKey !== null;1754}17551756175717581759export async function unlockWithKey(1760 masterKeyHex: string,1761): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1762 const epoch = vaultEpoch;1763 const stale = () => epoch !== vaultEpoch;1764 let masterKeyBytes: Uint8Array | null = null;1765 try {1766 masterKeyBytes = unhex(masterKeyHex);1767 const stored = await loadSlot(SLOT_A);1768 if (!stored) return { ok: false, error: "invalid_key" };1769 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1770 if (!payload || stale()) {1771 zeroKey(masterKeyBytes);1772 return { ok: false, error: "invalid_key" };1773 }17741775 derivedKey = masterKeyBytes;1776 vaultMode = "real";1777 entries = payload.entries;1778 tombstones = payload.tombstones;1779 duressConfigured = payload.duressConfigured;1780 buildDomainIndexSync(entries);1781 await loadSettings();1782 touchAutoLock();17831784 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1785 if (!rootKey) {1786 await lock();1787 return { ok: false, error: "invalid_key" };1788 }1789 await reconcilePairSecrets(rootKey);1790 if (stale()) {1791 await lock();1792 return { ok: false, error: "invalid_key" };1793 }1794 await saveSessionKey(masterKeyBytes, "real", epoch);1795 if (stale()) return { ok: false, error: "invalid_key" };1796 return { ok: true, entries };1797 } catch {1798 if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);1799 return { ok: false, error: "invalid_key" };1800 }1801}180218031804180518061807180818091810export async function exportDuressSlotForAdoption(): Promise<string | null> {1811 if (!(await isDuressEnabled())) return null;1812 const slotB = await loadSlot(SLOT_B);1813 if (!slotB) return null;1814 return JSON.stringify(slotB);1815}18161817181818191820182118221823export async function installAdoptedDuressSlot(json: string): Promise<void> {1824 let parsed: StoredVault;1825 try {1826 const raw = JSON.parse(json) as unknown;1827 if (!hasValidVaultSchema(raw)) {1828 throw new Error("invalid_duress_slot_shape");1829 }1830 parsed = raw;1831 } catch (err) {1832 if (err instanceof SyntaxError) {1833 throw new Error("invalid_duress_slot_shape");1834 }1835 throw err;1836 }1837 await writeSlot(SLOT_B, parsed);1838}18391840184118421843184418451846184718481849185018511852185318541855185618571858export async function unlockFromCodex(params: {1859 slotA: string; 1860 slotB: string | null; 1861 timestamp: number;1862 masterPassphrase: string;1863}): Promise<1864 | { ok: true; entryCount: number }1865 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1866> {1867 let parsedSlotA: StoredVault;1868 let parsedSlotB: StoredVault | null = null;1869 try {1870 const rawA = JSON.parse(params.slotA) as unknown;1871 if (!hasValidVaultSchema(rawA)) {1872 return { ok: false, reason: "invalid_slot_format" };1873 }1874 parsedSlotA = rawA;1875 if (params.slotB !== null) {1876 const rawB = JSON.parse(params.slotB) as unknown;1877 if (!hasValidVaultSchema(rawB)) {1878 return { ok: false, reason: "invalid_slot_format" };1879 }1880 parsedSlotB = rawB;1881 }1882 } catch {1883 return { ok: false, reason: "invalid_slot_format" };1884 }18851886 1887 1888 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1889 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1890 if (!trialPayload) {1891 zeroKey(trialKey);1892 return { ok: false, reason: "wrong_passphrase" };1893 }1894 1895 1896 zeroKey(trialKey);18971898 1899 1900 await takeVaultSnapshot("restore");19011902 await writeSlot(SLOT_A, parsedSlotA);1903 if (parsedSlotB) {1904 await writeSlot(SLOT_B, parsedSlotB);1905 } else {1906 1907 1908 1909 await writeSlot(1910 SLOT_B,1911 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1912 );1913 }1914 await chrome.storage.local.set({1915 peach_last_modified_at: String(params.timestamp),1916 });19171918 1919 1920 const ok = await unlock(params.masterPassphrase);1921 if (!ok) {1922 1923 1924 1925 return { ok: false, reason: "wrong_passphrase" };1926 }1927 return { ok: true, entryCount: getEntries().length };1928}19291930193119321933export function getDerivedKeyHex(): string {1934 if (!derivedKey) throw new Error("vault_not_unlocked");1935 return hex(derivedKey);1936}19371938193919401941194219431944194519461947194819491950195119521953export function getRootKeyHex(): string | null {1954 return rootKey ? hex(rootKey) : null;1955}19561957export function getEntries(): VaultEntry[] {1958 if (!derivedKey) throw new Error("vault_locked");1959 return entries.filter((entry) => !entry.deletedAt);1960}19611962const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;19631964export function getIdentities(): SavedIdentity[] {1965 if (!derivedKey) throw new Error("vault_locked");1966 const active = entries.filter((e) => !e.deletedAt && e.type === "login");19671968 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1969 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();19701971 for (const entry of active) {1972 const domain = extractDomain(entry.url ?? "") ?? "";1973 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;19741975 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1976 const existing = map.get(v);1977 if (!existing || modifiedAt > existing.lastUsed) {1978 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1979 } else {1980 map.set(v, { ...existing, count: existing.count + 1 });1981 }1982 }19831984 const uVal = entry.username?.trim();1985 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);19861987 1988 1989 const eVal = entry.email?.trim();1990 if (eVal) tally(emailMap, eVal);1991 }19921993 const toIdentity = (type: "email" | "username") =>1994 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1995 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1996 });19971998 return [1999 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),2000 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),2001 ];2002}20032004export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {2005 if (!derivedKey) throw new Error("vault_locked");20062007 const now = Date.now();2008 const added: VaultEntry[] = [];2009 for (const draft of drafts) {2010 try {2011 const sanitized = sanitizeEntryDraft(draft);2012 const nextEntry: VaultEntry = {2013 ...sanitized,2014 id: crypto.randomUUID(),2015 createdAt: now,2016 modifiedAt: monotonicEntryTimestamp(),2017 };2018 entries.push(nextEntry);2019 added.push(nextEntry);2020 } catch {2021 2022 }2023 }20242025 if (added.length > 0) {2026 await persistEntries();2027 }20282029 return added;2030}203120322033203420352036203720382039export async function restoreEntries(2040 incoming: VaultEntry[],2041 mode: "replace" | "merge"2042): Promise<{ added: number; updated: number; skipped: number }> {2043 if (!derivedKey) throw new Error("vault_locked");20442045 2046 2047 2048 if (mode === "replace" && incoming.length === 0) {2049 throw new Error("refuse to replace with empty entry set");2050 }20512052 let added = 0;2053 let updated = 0;2054 let skipped = 0;20552056 if (mode === "replace") {2057 entries = [];2058 for (const entry of incoming) {2059 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2060 if (!normalized) {2061 skipped += 1;2062 continue;2063 }2064 entries.push(normalized);2065 if (normalized.deletedAt) {2066 tombstones.set(2067 normalized.id,2068 Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2069 );2070 } else {2071 tombstones.delete(normalized.id);2072 }2073 added += 1;2074 }2075 } else {2076 const byId = new Map<string, VaultEntry>();2077 for (const e of entries) byId.set(e.id, e);20782079 for (const entry of incoming) {2080 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2081 if (!normalized) {2082 skipped += 1;2083 continue;2084 }2085 const existing = byId.get(normalized.id);2086 if (!existing) {2087 entries.push(normalized);2088 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2089 else tombstones.delete(normalized.id);2090 added += 1;2091 } else if (normalized.modifiedAt > existing.modifiedAt) {2092 const idx = entries.findIndex((e) => e.id === existing.id);2093 if (idx !== -1) {2094 entries[idx] = normalized;2095 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2096 else tombstones.delete(normalized.id);2097 updated += 1;2098 }2099 } else {2100 skipped += 1;2101 }2102 }2103 }21042105 await persistEntries("import");2106 return { added, updated, skipped };2107}210821092110211121122113211421152116211721182119212021212122212321242125const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;21262127function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2128 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2129 return { ...entry, id: crypto.randomUUID() };2130}213121322133213421352136213721382139function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2140 if (!entry || typeof entry !== "object") return null;2141 const title = typeof entry.title === "string" ? entry.title.trim() : "";2142 if (!title) return null;2143 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2144 const now = Date.now();2145 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2146 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;21472148 2149 2150 const normalized = normalizeEntry({2151 ...entry,2152 id,2153 title,2154 type: entry.type ?? "login",2155 createdAt,2156 modifiedAt,2157 });21582159 if (normalized.passkey) {2160 normalized.passkey = sanitizePasskey(normalized.passkey);2161 }2162 if (normalized.canary) {2163 normalized.canary = sanitizeCanary(normalized.canary);2164 }2165 2166 normalized.tags = sanitizeTags(normalized.tags);21672168 2169 2170 if (2171 normalized.type === "login" &&2172 !normalized.passkey &&2173 typeof normalized.password !== "string"2174 ) {2175 return null;2176 }2177 if (entry.passkey && !normalized.passkey) {2178 2179 2180 return null;2181 }21822183 return normalized;2184}2185218621872188218921902191function sanitizePasskey(pk: unknown): PasskeyData | undefined {2192 if (!pk || typeof pk !== "object") return undefined;2193 const r = pk as Record<string, unknown>;2194 if (2195 typeof r.rpId !== "string" || !r.rpId ||2196 typeof r.credentialId !== "string" || !r.credentialId ||2197 typeof r.publicKeyCose !== "string" ||2198 typeof r.userHandle !== "string" ||2199 typeof r.userName !== "string"2200 ) {2201 return undefined;2202 }2203 return {2204 rpId: r.rpId,2205 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2206 credentialId: r.credentialId,2207 publicKeyCose: r.publicKeyCose,2208 privateKeyJwk:2209 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2210 ? (r.privateKeyJwk as JsonWebKey)2211 : undefined,2212 userHandle: r.userHandle,2213 userName: r.userName,2214 userDisplayName:2215 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2216 algorithm: r.algorithm === -8 ? -8 : -7,2217 signCount: typeof r.signCount === "number" ? r.signCount : 0,2218 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2219 backupEligible: r.backupEligible === true,2220 backupState: r.backupState === true,2221 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2222 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2223 transports: Array.isArray(r.transports)2224 ? r.transports.filter((t): t is string => typeof t === "string")2225 : undefined,2226 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2227 };2228}22292230223122322233223422352236223722382239function sanitizeCanary(c: unknown): Canary | undefined {2240 if (!c || typeof c !== "object") return undefined;2241 const r = c as Record<string, unknown>;2242 if (typeof r.webhookUrl !== "string") return undefined;2243 let url: URL;2244 try {2245 url = new URL(r.webhookUrl);2246 } catch {2247 return undefined;2248 }2249 if (url.protocol !== "https:") return undefined;2250 return {2251 webhookUrl: url.toString(),2252 label: typeof r.label === "string" ? r.label : undefined,2253 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2254 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2255 };2256}22572258225922602261226222632264function sanitizeTags(tags: unknown): string[] | undefined {2265 if (!Array.isArray(tags)) return undefined;2266 const out: string[] = [];2267 for (const t of tags) {2268 if (typeof t !== "string") continue;2269 const trimmed = t.trim();2270 if (!trimmed) continue;2271 out.push(trimmed.slice(0, 256));2272 if (out.length >= 255) break;2273 }2274 return out.length > 0 ? out : undefined;2275}227622772278227922802281export async function clearVaultEntries(2282 confirmation: "REPLACE_ALL"2283): Promise<void> {2284 if (confirmation !== "REPLACE_ALL") {2285 throw new Error("clearVaultEntries requires explicit confirmation");2286 }2287 if (!derivedKey) throw new Error("vault_locked");2288 for (const entry of entries) {2289 if (!entry.deletedAt) {2290 const stamp = monotonicEntryTimestamp(Math.max(2291 entry.modifiedAt,2292 tombstones.get(entry.id) ?? 0,2293 ));2294 entry.deletedAt = stamp;2295 entry.modifiedAt = stamp;2296 tombstones.set(entry.id, stamp);2297 }2298 }2299 await persistEntries();2300}23012302export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2303 if (!derivedKey) throw new Error("vault_locked");23042305 const sanitized = sanitizeEntryDraft(entry);2306 const now = Date.now();2307 const stamp = monotonicEntryTimestamp();2308 const nextEntry: VaultEntry = {2309 ...sanitized,2310 id: crypto.randomUUID(),2311 createdAt: now,2312 modifiedAt: stamp,2313 2314 2315 passwordChangedAt: sanitized.password ? stamp : undefined,2316 };23172318 2319 2320 2321 const rollback = entries;2322 entries = [...entries, nextEntry];2323 try {2324 await persistEntries();2325 } catch (err) {2326 entries = rollback;2327 throw err;2328 }23292330 2331 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2332 checkBreach(nextEntry.password).then(async (count) => {2333 if (count > 0) {2334 const idx = entries.findIndex((e) => e.id === nextEntry.id);2335 if (idx !== -1) {2336 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2337 await persistEntries();2338 }2339 }2340 });2341 }23422343 fireSyncTrigger("vault_change");23442345 return nextEntry;2346}23472348234923502351235223532354export async function consolidatePasskeyEntries(): Promise<void> {2355 if (!derivedKey) return;2356 const { merged } = mergePasskeyOnlyIntoLogins(entries);2357 if (merged) {2358 await persistEntries();2359 }2360}23612362export async function updateEntry(2363 id: string,2364 patch: Partial<VaultEntry>2365): Promise<void> {2366 if (!derivedKey) throw new Error("vault_locked");23672368 const idx = entries.findIndex((entry) => entry.id === id);2369 if (idx === -1 || entries[idx].deletedAt) {2370 throw new Error("entry_not_found");2371 }23722373 const current = entries[idx];2374 const sanitizedPatch = sanitizeEntryPatch(patch);2375 2376 2377 2378 const passwordChanged =2379 "password" in patch &&2380 typeof sanitizedPatch.password === "string" &&2381 sanitizedPatch.password !== current.password;2382 const stamp = monotonicEntryTimestamp(current.modifiedAt);2383 2384 2385 2386 2387 2388 2389 2390 const priorPassword = current.password;2391 const shouldCapture =2392 passwordChanged &&2393 typeof priorPassword === "string" &&2394 priorPassword.length > 0 &&2395 current.passwordHistory?.[0]?.password !== priorPassword;2396 const nextHistory = shouldCapture2397 ? [2398 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2399 ...(current.passwordHistory ?? []),2400 ].slice(0, PASSWORD_HISTORY_MAX)2401 : current.passwordHistory;2402 entries[idx] = {2403 ...current,2404 ...sanitizedPatch,2405 modifiedAt: stamp,2406 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2407 };2408240924102411 2412 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2413 entries[idx] = current;2414 throw new Error("card number is required for card entries");2415 }24162417 2418 2419 2420 2421 try {2422 await persistEntries();2423 } catch (err) {2424 entries[idx] = current;2425 throw err;2426 }24272428 2429 2430 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2431 entries[idx] = { ...entries[idx], breach: undefined };2432 await persistEntries();2433 const password = entries[idx].password;2434 if (password) {2435 checkBreach(password).then(async (count) => {2436 const i = entries.findIndex((e) => e.id === id);2437 if (i !== -1 && count > 0) {2438 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2439 await persistEntries();2440 }2441 });2442 }2443 }24442445 fireSyncTrigger("vault_change");2446}24472448export async function deleteEntry(id: string): Promise<void> {2449 if (!derivedKey) throw new Error("vault_locked");24502451 const entry = entries.find((item) => item.id === id);2452 if (!entry || entry.deletedAt) {2453 throw new Error("entry_not_found");2454 }24552456 2457 2458 2459 const previousModifiedAt = entry.modifiedAt;2460 const previousTombstone = tombstones.get(id);2461 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2462 entry.deletedAt = stamp;2463 entry.modifiedAt = stamp;2464 tombstones.set(id, stamp);2465 2466 2467 try {2468 await persistEntries();2469 } catch (err) {2470 delete entry.deletedAt;2471 entry.modifiedAt = previousModifiedAt;2472 if (previousTombstone === undefined) tombstones.delete(id);2473 else tombstones.set(id, previousTombstone);2474 throw err;2475 }2476 fireSyncTrigger("vault_change");2477}24782479export function getDeletedEntries(): VaultEntry[] {2480 if (!derivedKey) return [];2481 return entries.filter((e) => !!e.deletedAt);2482}248324842485export function getTombstoneManifest(): Record<string, number> {2486 if (!derivedKey) return {};2487 return Object.fromEntries(tombstones);2488}2489249024912492249324942495export async function getVaultVersion(): Promise<number> {2496 try {2497 const raw = await chrome.storage.local.get("peach_last_modified_at");2498 const v = (raw as Record<string, unknown>).peach_last_modified_at;2499 if (typeof v === "number") return v;2500 if (typeof v === "string" && v !== "") {2501 const n = Number.parseInt(v, 10);2502 return Number.isFinite(n) ? n : 0;2503 }2504 return 0;2505 } catch {2506 return 0;2507 }2508}2509251025112512251325142515251625172518251925202521export async function applyMergedRemoteEntries(2522 remoteEntries: VaultEntry[],2523 remoteTombstones: Record<string, number>,2524 _context: { deviceName?: string } = {},2525): Promise<{ received: number; applied: number; conflicts: number }> {2526 2527 2528 2529 2530 2531 2532 if (!derivedKey) throw new Error("vault_locked");25332534 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2535 let applied = 0;2536 let conflicts = 0;25372538 const byId = new Map<string, VaultEntry>();2539 for (const e of entries) byId.set(e.id, e);25402541 const mergeNow = Date.now();2542 const remoteTombstoneTimes = new Map<string, number>();2543 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2544 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2545 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2546 }2547 }25482549 for (const remote of remoteEntries) {2550 const sanitized = sanitizeRestoredEntry(remote);2551 if (!sanitized) continue;2552 2553 2554 2555 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2556 if (typeof sanitized.deletedAt === "number") {2557 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2558 }2559 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2560 const localTombstoneTs = tombstones.get(sanitized.id);2561 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2562 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2563 continue;2564 }2565 const local = byId.get(sanitized.id);2566 if (!local) {2567 entries.push(sanitized);2568 byId.set(sanitized.id, sanitized);2569 tombstones.delete(sanitized.id);2570 applied += 1;2571 } else if (sanitized.modifiedAt > local.modifiedAt) {2572 const idx = entries.findIndex((e) => e.id === local.id);2573 if (idx !== -1) {2574 entries[idx] = sanitized;2575 byId.set(sanitized.id, sanitized);2576 tombstones.delete(sanitized.id);2577 applied += 1;2578 if (!local.deletedAt) conflicts += 1;2579 }2580 }2581 }25822583 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2584 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2585 2586 const deletedAt = remoteTombstoneTimes.get(id)!;2587 const knownTombstone = tombstones.get(id);2588 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2589 const local = byId.get(id);2590 if (!local) {2591 tombstones.set(id, deletedAt);2592 applied += 1;2593 continue;2594 }2595 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2596 if (deletedAt < local.modifiedAt) continue;2597 const idx = entries.findIndex((e) => e.id === id);2598 if (idx !== -1) {2599 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2600 tombstones.set(id, deletedAt);2601 applied += 1;2602 }2603 }26042605 if (applied > 0) {2606 await persistEntries("sync_received");2607 buildDomainIndexSync(entries);2608 }2609 return { received, applied, conflicts };2610}26112612export async function restoreEntry(id: string): Promise<void> {2613 if (!derivedKey) throw new Error("vault_locked");2614 const entry = entries.find((e) => e.id === id && e.deletedAt);2615 if (!entry) throw new Error("deleted entry not found");2616 const previousDeletedAt = entry.deletedAt;2617 const previousModifiedAt = entry.modifiedAt;2618 const previousTombstone = tombstones.get(id);2619 delete entry.deletedAt;2620 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2621 entry.modifiedAt,2622 previousTombstone ?? 0,2623 ));2624 tombstones.delete(id);2625 try {2626 await persistEntries();2627 } catch (err) {2628 entry.deletedAt = previousDeletedAt;2629 entry.modifiedAt = previousModifiedAt;2630 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2631 throw err;2632 }2633 buildDomainIndexSync(entries);2634}26352636export async function purgeEntry(id: string): Promise<void> {2637 if (!derivedKey) throw new Error("vault_locked");2638 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2639 if (idx === -1) throw new Error("deleted entry not found");2640 const entry = entries[idx]!;2641 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2642 entries.splice(idx, 1);2643 await persistEntries();2644}264526462647export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2648 if (!derivedKey || retentionMs === 0) return 0;2649 const cutoff = Date.now() - retentionMs;2650 const before = entries.length;2651 for (const entry of entries) {2652 if (entry.deletedAt && entry.deletedAt < cutoff) {2653 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2654 }2655 }2656 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2657 const purged = before - entries.length;2658 if (purged > 0) await persistEntries();2659 return purged;2660}26612662export function queryDomain(domain: string): VaultEntry[] {2663 return domainIndex.get(domain) ?? [];2664}26652666export function getEntryById(id: string): VaultEntry | undefined {2667 return entries.find((entry) => entry.id === id && !entry.deletedAt);2668}26692670async function persistSlotA(2671 reason: VaultSnapshot["reason"] = "local_save",2672): Promise<StoredVault> {2673 if (!derivedKey) throw new Error("vault_locked");26742675 const slotA = await loadSlot(SLOT_A);2676 if (!slotA) throw new Error("missing slot A");26772678 2679 2680 await takeVaultSnapshot(reason);26812682 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2683 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");26842685 2686 const nextSlotA: StoredVault = {2687 ...slotA,2688 nonce,2689 ciphertext,2690 tag,2691 2692 };2693 await writeSlot(SLOT_A, nextSlotA);26942695 2696 2697 2698 2699 2700 2701 try {2702 2703 2704 2705 if (!duressConfigured) {2706 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2707 }2708 2709 2710 2711 2712 2713 if (reason !== "sync_received") {2714 await stampLocalVaultMutation();2715 }2716 } catch (err) {2717 2718 2719 await writeSlot(SLOT_A, slotA).catch(() => {});2720 throw err;2721 }27222723 return nextSlotA;2724}27252726async function persistSlotB(): Promise<void> {2727 if (!derivedKey) throw new Error("vault_locked");27282729 const slotB = await loadSlot(SLOT_B);2730 if (!slotB) throw new Error("missing slot B");27312732 2733 2734 2735 2736 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2737 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");27382739 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2740 2741 2742 2743 try {2744 await stampLocalVaultMutation();2745 } catch (err) {2746 await writeSlot(SLOT_B, slotB).catch(() => {});2747 throw err;2748 }2749}27502751async function persistEntries(2752 reason: VaultSnapshot["reason"] = "local_save",2753): Promise<void> {2754 if (!derivedKey) throw new Error("vault_locked");27552756 2757 2758 const previousTombstones = tombstones;2759 tombstones = new Map(tombstones);2760 reconcileTombstones();2761 try {2762 if (vaultMode === "decoy") {2763 await persistSlotB();2764 } else {2765 await persistSlotA(reason);2766 }2767 } catch (err) {2768 tombstones = previousTombstones;2769 throw err;2770 }2771 buildDomainIndexSync(entries);2772 await refreshLockedSyncState();2773}2774277527762777277827792780function allowHttpUrl(url: string | undefined): string {2781 const trimmed = url?.trim() ?? "";2782 if (!trimmed) return "";2783 try {2784 const parsed = new URL(trimmed);2785 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2786 } catch {2787 2788 }2789 return trimmed;2790}27912792function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2793 if (!Array.isArray(values)) return undefined;2794 const seen = new Set<string>();2795 const cleaned: string[] = [];2796 for (const value of values) {2797 const trimmed = typeof value === "string" ? value.trim() : "";2798 if (!trimmed) continue;2799 if (!allowHttpUrl(trimmed)) continue;2800 const key = trimmed.toLowerCase();2801 if (seen.has(key)) continue;2802 seen.add(key);2803 cleaned.push(trimmed);2804 }2805 return cleaned.length > 0 ? cleaned : undefined;2806}28072808function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2809 const title = entry.title.trim();2810 const type: VaultEntry["type"] = entry.type ?? "login";28112812 if (!title) throw new Error("title is required");28132814 if (entry.passkey) {2815 const p = entry.passkey;2816 if (2817 !p.rpId ||2818 !p.credentialId ||2819 !p.publicKeyCose ||2820 !p.userHandle ||2821 !p.userName ||2822 (p.algorithm !== -7 && p.algorithm !== -8)2823 ) {2824 throw new Error("invalid passkey credential");2825 }2826 }2827 if (type === "card") {2828 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2829 throw new Error("cardholder name and card number are required");2830 }2831 }28322833 return {2834 type,2835 title,2836 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2837 email: type === "login" ? entry.email?.trim() || undefined : undefined,2838 password: type === "login" ? entry.password : undefined,2839 url: type === "login" ? allowHttpUrl(entry.url) : undefined,2840 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2841 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2842 2843 2844 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2845 totpDigits: type === "login" ? entry.totpDigits : undefined,2846 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2847 notes: entry.notes?.trim() || undefined,2848 breach: type === "login" ? entry.breach : undefined,2849 2850 2851 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2852 tags: sanitizeTags(entry.tags),2853 passkey: entry.passkey,2854 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2855 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2856 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2857 expYear: type === "card" ? entry.expYear?.trim() : undefined,2858 cvc: type === "card" ? entry.cvc?.trim() : undefined,2859 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2860 network: type === "card" ? entry.network : undefined,2861 last4: type === "card" ? entry.last4?.trim() : undefined,2862 };2863}28642865function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2866 const nextPatch: Partial<VaultEntry> = {};28672868 if ("type" in patch) {2869 nextPatch.type = patch.type;2870 }28712872 if ("title" in patch) {2873 const title = patch.title?.trim() ?? "";2874 if (!title) throw new Error("title is required");2875 nextPatch.title = title;2876 }28772878 if ("url" in patch) {2879 nextPatch.url = allowHttpUrl(patch.url);2880 }28812882 if ("additionalUrls" in patch) {2883 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2884 const seen = new Set<string>();2885 const cleaned: string[] = [];2886 for (const value of incoming) {2887 const trimmed = typeof value === "string" ? value.trim() : "";2888 if (!trimmed || !allowHttpUrl(trimmed)) continue;2889 const key = trimmed.toLowerCase();2890 if (seen.has(key)) continue;2891 seen.add(key);2892 cleaned.push(trimmed);2893 }2894 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2895 }28962897 if ("username" in patch) {2898 nextPatch.username = patch.username?.trim() ?? "";2899 }29002901 if ("email" in patch) {2902 nextPatch.email = patch.email?.trim() || undefined;2903 }29042905 if ("password" in patch) {2906 const password = patch.password ?? "";2907 2908 nextPatch.password = password || undefined;2909 }29102911 if ("totp" in patch) {2912 nextPatch.totp = patch.totp?.trim() || undefined;2913 }29142915 if ("notes" in patch) {2916 nextPatch.notes = patch.notes?.trim() || undefined;2917 }29182919 if ("breach" in patch) {2920 nextPatch.breach = patch.breach;2921 }29222923 if ("canary" in patch) {2924 2925 2926 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2927 }29282929 if ("passkey" in patch) {2930 2931 2932 if (patch.passkey === undefined || patch.passkey === null) {2933 nextPatch.passkey = undefined;2934 } else {2935 const sanitized = sanitizePasskey(patch.passkey);2936 if (!sanitized) throw new Error("invalid passkey credential");2937 nextPatch.passkey = sanitized;2938 }2939 }29402941 if ("tags" in patch) {2942 2943 nextPatch.tags = sanitizeTags(patch.tags);2944 }29452946 if ("cardholderName" in patch) {2947 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2948 }2949 if ("cardNumber" in patch) {2950 const n = patch.cardNumber ?? "";2951 nextPatch.cardNumber = n || undefined;2952 }2953 if ("expMonth" in patch) {2954 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2955 }2956 if ("expYear" in patch) {2957 nextPatch.expYear = patch.expYear?.trim() || undefined;2958 }2959 if ("cvc" in patch) {2960 nextPatch.cvc = patch.cvc?.trim() || undefined;2961 }2962 if ("cardNickname" in patch) {2963 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2964 }2965 if ("network" in patch) {2966 nextPatch.network = patch.network;2967 }2968 if ("last4" in patch) {2969 nextPatch.last4 = patch.last4?.trim() || undefined;2970 }29712972 return nextPatch;2973}29742975function normalizeEntry(entry: VaultEntry): VaultEntry {2976 const base: VaultEntry = {2977 id: entry.id,2978 type: entry.type,2979 title: entry.title,2980 username: entry.username,2981 email: entry.email,2982 password: entry.password,2983 url: entry.url,2984 additionalUrls:2985 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2986 ? entry.additionalUrls2987 : undefined,2988 totp: entry.totp,2989 2990 totpAlgorithm: entry.totpAlgorithm,2991 totpDigits: entry.totpDigits,2992 totpPeriod: entry.totpPeriod,2993 notes: entry.notes,2994 breach: entry.breach,2995 2996 2997 2998 passwordChangedAt:2999 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,3000 3001 3002 3003 passwordHistory:3004 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)3005 ? entry.passwordHistory3006 : undefined,3007 canary: entry.canary,3008 passkey: entry.passkey,3009 createdAt: entry.createdAt,3010 modifiedAt: entry.modifiedAt,3011 deletedAt: entry.deletedAt,3012 tags: entry.tags,3013 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,3014 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,3015 expMonth: "expMonth" in entry ? entry.expMonth : undefined,3016 expYear: "expYear" in entry ? entry.expYear : undefined,3017 cvc: "cvc" in entry ? entry.cvc : undefined,3018 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,3019 network: "network" in entry ? entry.network : undefined,3020 last4: "last4" in entry ? entry.last4 : undefined,3021 };30223023 return base;3024}3025302630273028302930303031function entryDupeKey(e: VaultEntry): string {3032 if (e.type === "note") return `note|${e.title.toLowerCase()}`;3033 3034 3035 3036 3037 if (e.passkey) {3038 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3039 }3040 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3041}304230433044export function countDuplicates(): number {3045 const best = new Map<string, VaultEntry>();3046 let dupeCount = 0;3047 for (const e of entries.filter((e) => !e.deletedAt)) {3048 const key = entryDupeKey(e);3049 const existing = best.get(key);3050 if (!existing) {3051 best.set(key, e);3052 } else {3053 dupeCount++;3054 3055 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3056 best.set(key, e);3057 }3058 }3059 }3060 return dupeCount;3061}306230633064306530663067export async function deduplicateEntries(): Promise<number> {3068 if (!derivedKey) throw new Error("vault_locked");3069 3070 3071 if (vaultMode === "decoy") {3072 throw new Error("deduplicate disabled in decoy mode");3073 }30743075 const best = new Map<string, VaultEntry>();3076 for (const e of entries.filter((en) => !en.deletedAt)) {3077 const key = entryDupeKey(e);3078 const existing = best.get(key);3079 if (!existing) {3080 best.set(key, e);3081 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3082 best.set(key, e);3083 }3084 }30853086 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3087 let removed = 0;3088 entries = entries.map((e) => {3089 if (!e.deletedAt && !keepIds.has(e.id)) {3090 removed++;3091 const deletedAt = monotonicEntryTimestamp(Math.max(3092 e.modifiedAt,3093 tombstones.get(e.id) ?? 0,3094 ));3095 tombstones.set(e.id, deletedAt);3096 return { ...e, deletedAt };3097 }3098 return e;3099 });31003101 if (removed > 0) await persistEntries();3102 return removed;3103}310431053106310731083109311031113112const HISTORY_KEY = "peach_vault_history";3113const HISTORY_MAX = 5;311431153116311731183119function bucketEntryCount(n: number): number {3120 if (n <= 0) return 0;3121 if (n <= 10) return 10;3122 if (n <= 25) return 25;3123 if (n <= 50) return 50;3124 if (n <= 100) return 100;3125 if (n <= 250) return 250;3126 if (n <= 500) return 500;3127 return Math.ceil(n / 500) * 500;3128}31293130313131323133313431353136export async function takeVaultSnapshot(3137 reason: VaultSnapshot["reason"],3138): Promise<void> {3139 try {3140 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3141 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3142 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];31433144 3145 3146 3147 if (!slotA) return;31483149 const vault_a = JSON.stringify(slotA);3150 const vault_b = JSON.stringify(slotB ?? slotA);31513152 3153 3154 3155 3156 3157 3158 let entryCount: number | null = null;3159 if (derivedKey && vaultMode === "real") {3160 try {3161 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3162 } catch {3163 entryCount = null;3164 }3165 }31663167 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3168 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3169 ? existingRaw3170 : { snapshots: [] };31713172 3173 3174 3175 const latest = history.snapshots[0];3176 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3177 return;3178 }31793180 const snapshot: VaultSnapshot = {3181 id: crypto.randomUUID(),3182 savedAt: Date.now(),3183 reason,3184 vault_a,3185 vault_b,3186 entryCount,3187 };31883189 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3190 await chrome.storage.local.set({ [HISTORY_KEY]: history });3191 } catch (err) {3192 3193 3194 3195 3196 console.warn('[Peach] takeVaultSnapshot failed:', err);3197 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3198 try {3199 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3200 } catch {3201 3202 }3203 }3204 }3205}320632073208export async function clearVaultHistory(): Promise<void> {3209 try {3210 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3211 } catch {3212 3213 }3214}321532163217export async function getVaultHistory(): Promise<VaultSnapshot[]> {3218 const raw = await chrome.storage.local.get(HISTORY_KEY);3219 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3220 if (!history || !Array.isArray(history.snapshots)) return [];3221 return history.snapshots;3222}32233224322532263227322832293230export async function restoreVaultSnapshot(3231 snapshotId: string,3232): Promise<{ ok: true } | { ok: false; error: string }> {3233 const history = await getVaultHistory();3234 const snapshot = history.find((s) => s.id === snapshotId);3235 if (!snapshot) return { ok: false, error: "snapshot_not_found" };32363237 3238 3239 3240 await takeVaultSnapshot("restore");32413242 let restoredA: StoredVault;3243 let restoredB: StoredVault;3244 try {3245 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3246 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3247 } catch {3248 return { ok: false, error: "snapshot_corrupt" };3249 }32503251 await writeSlot(SLOT_A, restoredA);3252 await writeSlot(SLOT_B, restoredB);3253 await stampLocalVaultMutation();32543255 3256 3257 3258 await lock();32593260 return { ok: true };3261}326232633264326532663267326832693270327132723273327432753276export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3277 if (!derivedKey || vaultMode !== "real") {3278 return { ok: false, error: "vault_locked" };3279 }3280 const slotA = await loadSlot(SLOT_A);3281 if (!slotA) return { ok: false, error: "missing_slot_a" };32823283 3284 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3285 await generateAndWrapRootKey();32863287 3288 const newRootKey = unhex(rawRootKey);3289 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);32903291 3292 3293 3294 3295 await takeVaultSnapshot("local_save");3296 const nextSlotA: StoredVault = {3297 ...slotA,3298 recovery_salt: recoverySalt,3299 recovery_wrapped_root_key: wrappedRootKey,3300 recovery_nonce: wrappedNonce,3301 recovery_tag: wrappedTag,3302 ...pwRoot,3303 };3304 await writeSlot(SLOT_A, nextSlotA);33053306 3307 if (rootKey) zeroKey(rootKey);3308 rootKey = newRootKey;33093310 3311 3312 3313 await clearPairedDevices();3314 await clearPairSecrets();33153316 3317 await setRecoveryConfirmPending(); 33183319 await stampLocalVaultMutation();3320 return { ok: true };3321}33223323332433253326332733283329export async function changePassword(3330 currentPassword: string,3331 newPassword: string3332): Promise<boolean> {3333 const epoch = vaultEpoch;3334 const stale = () => epoch !== vaultEpoch;3335 if (!derivedKey || vaultMode !== "real") return false;33363337 3338 const valid = await verifyMainPassword(currentPassword);3339 if (!valid) return false;33403341 const slotA = await loadSlot(SLOT_A);3342 if (!slotA) return false;3343 if (!rootKey) return false;33443345 3346 const newSalt = generateSalt();3347 const newKey = await deriveKey(newPassword, newSalt);3348 if (stale()) {3349 zeroKey(newKey);3350 return false;3351 }33523353 3354 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3355 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");33563357 3358 3359 3360 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);33613362 const finalSlotA: StoredVault = {3363 ...slotA,3364 salt: hex(newSalt),3365 nonce,3366 ciphertext,3367 tag,3368 ...pwRoot,3369 };33703371 3372 3373 3374 await takeVaultSnapshot("local_save");33753376 3377 3378 3379 3380 await writeSlot(SLOT_A, finalSlotA);3381 if (stale()) {3382 zeroKey(newKey);3383 return false;3384 }33853386 3387 3388 3389 zeroKey(derivedKey);3390 derivedKey = newKey;33913392 3393 3394 3395 3396 if (stale()) {3397 await lock();3398 return false;3399 }3400 await saveSessionKey(newKey, "real", epoch);3401 if (stale()) return false;34023403 3404 3405 3406 if (!duressConfigured) {3407 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3408 }3409 await clearPinUnlockStorage();3410 await stampLocalVaultMutation();34113412 return true;3413}