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";4243type EntryDraft = Omit<44 VaultEntry,45 "id" | "createdAt" | "modifiedAt" | "deletedAt"46>;474849505152const SLOT_A = "peach_vault_a";53const SLOT_B = "peach_vault_b";54555657const PAD_BLOCK = 1024;5859606162const PASSWORD_HISTORY_MAX = 50;6364656667686970717273const DURESS_SLOT_SIZE = 262_144;7475let derivedKey: Uint8Array | null = null;7677787980let rootKey: Uint8Array | null = null;81let entries: VaultEntry[] = [];82838485let tombstones = new Map<string, number>();86let domainIndex: Map<string, VaultEntry[]> = new Map();87let vaultMode: "real" | "decoy" = "real";88899091let duressConfigured = false;9293949596979899100101102let lastUnlockFailureReason: string | null = null;103104export function consumeLastUnlockFailureReason(): string | null {105 const r = lastUnlockFailureReason;106 lastUnlockFailureReason = null;107 return r;108}109110111112113114export type TextSize = "small" | "medium" | "large";115116export interface VaultSettings {117 accentColor?: AccentColorName;118 theme?: "dark" | "light";119 autoLockMinutes?: number;120 clipboardClearSeconds?: number;121 lockOnClose?: boolean;122 breachMonitoring?: boolean;123 passkeyProviderEnabled?: boolean;124 trashRetentionDays?: number;125 manualSummonDomains?: Record<string, ManualSummonRecord>;126 manualSummonSalt?: string;127 declinedCardFingerprints?: string[];128129 130 metadataVersion?: 1;131 132133134135136 textSize?: TextSize;137 138139140141142 reduceMotion?: boolean;143 144 pinEnabled?: boolean;145 146 pinPreferred?: boolean;147 148149150151152153154 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;155}156157interface StoredPinWrap {158 version: 1;159 salt: string;160 nonce: string;161 ciphertext: string;162 tag: string;163}164165const PIN_WRAP_STORAGE = "peach_pin_wrap";166const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";167const PIN_MIN_LENGTH = 4;168const PIN_MAX_LENGTH = 12;169const PIN_MAX_ATTEMPTS = 3;170171let settings: VaultSettings = {};172const CARD_DECLINE_MAX = 256;173174const CARD_DECLINE_DIGEST_HEX = 32;175176async function metadataDigest(value: string, key: string): Promise<string> {177 const bytes = new TextEncoder().encode(`${key}:${value}`);178 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));179 return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join("");180}181182let inMemoryPinWrap: StoredPinWrap | null = null;183let inMemoryPinFailedAttempts = 0;184let sessionPinStorageFailed = false;185186export function getSettings(): VaultSettings {187 return settings;188}189190export async function ensureSettingsLoaded(): Promise<VaultSettings> {191 await loadSettings();192 return settings;193}194195export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {196 await loadSettings();197 198 199 200 201 const safe = Object.fromEntries(202 Object.entries(patch).filter(203 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),204 ),205 ) as Partial<VaultSettings>;206 settings = { ...settings, ...safe };207 await persistSettings();208}209210function isValidPin(pin: string): boolean {211 return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);212}213214215export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {216 if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;217 if (!(await verifyMainPassword(currentPassword))) return false;218219 const salt = generateSalt();220 const pinKey = await deriveKey(pin, salt);221 try {222 const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");223 await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });224 await updateSettings({ pinEnabled: true });225 return true;226 } finally {227 zeroKey(pinKey);228 }229}230231async function clearPinUnlockStorage(): Promise<void> {232 inMemoryPinWrap = null;233 inMemoryPinFailedAttempts = 0;234 sessionPinStorageFailed = false;235 if (chrome.storage.session) {236 await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);237 }238 await updateSettings({ pinEnabled: false });239}240241242export async function disablePinUnlock(currentPassword: string): Promise<boolean> {243 if (!derivedKey || vaultMode !== "real") return false;244 if (!(await verifyMainPassword(currentPassword))) return false;245 await clearPinUnlockStorage();246 return true;247}248249250export async function unlockWithPin(pin: string): Promise<boolean> {251 if (!isValidPin(pin)) return false;252 await loadSettings();253 if (!settings.pinEnabled) return false;254255 const { stored, failedAttempts } = await loadPinState();256 if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;257 if (258 !stored ||259 stored.version !== 1 ||260 !stored.salt ||261 !stored.nonce ||262 !stored.ciphertext ||263 !stored.tag264 ) return false;265266 const pinKey = await deriveKey(pin, unhex(stored.salt));267 let unwrappedKey: Uint8Array | null = null;268 try {269 const masterKeyHex = await decrypt(270 pinKey,271 stored.nonce,272 stored.ciphertext,273 stored.tag,274 "pin_wrap_v1",275 );276 if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;277 unwrappedKey = unhex(masterKeyHex);278 const result = await unlockWithKey(masterKeyHex);279 if (!result.ok) return false;280 await saveSessionKey(unwrappedKey, "real");281 await resetPinFailedAttempts();282 return true;283 } catch {284 await recordPinFailure(failedAttempts + 1);285 return false;286 } finally {287 zeroKey(pinKey);288 if (unwrappedKey) zeroKey(unwrappedKey);289 }290}291292async function savePinWrap(stored: StoredPinWrap): Promise<void> {293 inMemoryPinWrap = stored;294 inMemoryPinFailedAttempts = 0;295 sessionPinStorageFailed = !chrome.storage.session;296 if (!chrome.storage.session) return;297 try {298 await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });299 sessionPinStorageFailed = false;300 } catch {301 302 sessionPinStorageFailed = true;303 }304}305306async function loadPinState(): Promise<{307 stored: StoredPinWrap | undefined;308 failedAttempts: number;309}> {310 if (!chrome.storage.session) {311 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };312 }313 try {314 const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);315 return {316 stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??317 (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),318 failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"319 ? result[PIN_ATTEMPTS_STORAGE]320 : 0,321 };322 } catch {323 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };324 }325}326327async function recordPinFailure(failedAttempts: number): Promise<void> {328 inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);329 if (!chrome.storage.session) return;330 try {331 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });332 } catch { }333}334335async function resetPinFailedAttempts(): Promise<void> {336 inMemoryPinFailedAttempts = 0;337 if (!chrome.storage.session) return;338 try {339 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });340 } catch { }341}342343export async function getPinUnlockStatus(): Promise<{344 configured: boolean;345 available: boolean;346 attemptsRemaining: number;347}> {348 await loadSettings();349 if (!settings.pinEnabled) {350 return { configured: false, available: false, attemptsRemaining: 0 };351 }352 const { stored, failedAttempts } = await loadPinState();353 const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;354 return {355 configured: true,356 available,357 attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,358 };359}360361async function persistSettings(): Promise<void> {362 try {363 await chrome.storage.local.set({364 peach_settings: {365 ...settings,366 manualSummonDomains: undefined,367 manualSummonSalt: undefined,368 cardFingerprintSalt: undefined,369 declinedCardFingerprints: settings.declinedCardFingerprints ?? [],370 metadataVersion: 1,371 },372 });373 } catch (err) {374 throw mapStorageError(err);375 }376}377378function mapStorageError(err: unknown): Error {379 const message = err instanceof Error ? err.message : String(err);380 if (/QUOTA_BYTES|quota/i.test(message)) {381 return new Error("STORAGE_QUOTA_EXCEEDED");382 }383 return err instanceof Error ? err : new Error(message);384}385386async function loadSettings(): Promise<void> {387 const result = await chrome.storage.local.get("peach_settings");388 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};389}390391392393function fireSyncTrigger(reason: "vault_change"): void {394 void Promise.resolve()395 .then(() => triggerSyncIfReachable({ reason }))396 .catch(() => {397 398 });399}400401let lockedSyncRefreshHook: (() => Promise<void>) | null = null;402let lockedSyncUnlockHook: (() => Promise<void>) | null = null;403404export function setLockedSyncHooks(hooks: {405 refresh: () => Promise<void>;406 afterUnlock: () => Promise<void>;407}): void {408 lockedSyncRefreshHook = hooks.refresh;409 lockedSyncUnlockHook = hooks.afterUnlock;410}411412async function refreshLockedSyncState(): Promise<void> {413 if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;414 await lockedSyncRefreshHook().catch((error) => {415 console.warn("[Peach sync] locked spool refresh failed:", error);416 });417}418419420421422423export async function checkBreach(password: string): Promise<number> {424 425 426 const encoder = new TextEncoder();427 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));428 const hashHex = Array.from(new Uint8Array(hashBuffer))429 .map((b) => b.toString(16).padStart(2, "0"))430 .join("")431 .toUpperCase();432433 const prefix = hashHex.slice(0, 5);434 const suffix = hashHex.slice(5);435436 try {437 438 439 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {440 headers: { "Add-Padding": "true" },441 });442 if (!res.ok) return 0;443 const text = await res.text();444 const match = text.split("\r\n").find((l) => l.startsWith(suffix));445 return match ? parseInt(match.split(":")[1], 10) : 0;446 } catch {447 return 0; 448 }449}450451export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {452 453 const key = await cardDeclineKey();454 if (!key) return;455 const fingerprint = await cardFingerprint(last4, expMonth, expYear);456 await loadSettings();457 settings = {458 ...settings,459 declinedCardFingerprints: [460 461 462 ...(settings.declinedCardFingerprints ?? []).filter(463 (stored) => stored.length === CARD_DECLINE_DIGEST_HEX,464 ),465 await metadataDigest(fingerprint, key),466 ].slice(-CARD_DECLINE_MAX),467 };468 await persistSettings();469}470471export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {472 await loadSettings();473 const key = await cardDeclineKey();474 if (!key) return false;475 const fingerprint = await cardFingerprint(last4, expMonth, expYear);476 const candidate = await metadataDigest(fingerprint, key);477 return (settings.declinedCardFingerprints ?? []).some(478 (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate,479 );480}481482483484485486487488489490491492493async function cardDeclineKey(): Promise<string | null> {494 const root = getRootKeyHex();495 if (!root) return null;496 const digest = new Uint8Array(497 await crypto.subtle.digest(498 "SHA-256",499 new TextEncoder().encode(`peach-card-decline-v1:${root}`),500 ),501 );502 return hex(digest);503}504505async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {506 const encoder = new TextEncoder();507 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));508 return Array.from(new Uint8Array(buf).slice(0, 8))509 .map((b) => b.toString(16).padStart(2, "0"))510 .join("");511}512513function hex(buf: Uint8Array): string {514 return Array.from(buf)515 .map((b) => b.toString(16).padStart(2, "0"))516 .join("");517}518519function unhex(s: string): Uint8Array {520 const out = new Uint8Array(s.length / 2);521 for (let i = 0; i < s.length; i += 2) {522 out[i / 2] = parseInt(s.slice(i, i + 2), 16);523 }524 return out;525}526527528529530531532533534535536537538539540541542543544545546async function generateAndWrapRootKey(): Promise<{547 rawRootKey: string; 548 recoverySalt: string;549 wrappedRootKey: string;550 wrappedNonce: string;551 wrappedTag: string;552}> {553 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));554555 556 const words = await rootKeyToWords(rootKeyBytes);557 const wordMaterial = await wordsToRootKeyMaterial(words);558 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));559 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);560561 562 563 const nonce = crypto.getRandomValues(new Uint8Array(12));564 const encoded = new TextEncoder().encode(hex(rootKeyBytes));565 const raw = await crypto.subtle.encrypt(566 {567 name: "AES-GCM",568 iv: nonce,569 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,570 tagLength: 128,571 },572 recoveryKey,573 encoded574 );575 576 const rawU8 = new Uint8Array(raw);577 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));578 const tag = hex(rawU8.slice(rawU8.length - 16));579580 return {581 rawRootKey: hex(rootKeyBytes),582 recoverySalt: hex(recoverySalt),583 wrappedRootKey: ciphertext,584 wrappedNonce: hex(nonce),585 wrappedTag: tag,586 };587}588589async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{590 recoverySalt: string;591 wrappedRootKey: string;592 wrappedNonce: string;593 wrappedTag: string;594}> {595 if (rootKeyBytes.length !== 32) {596 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");597 }598599 const words = await rootKeyToWords(rootKeyBytes);600 const wordMaterial = await wordsToRootKeyMaterial(words);601 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));602 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);603 const nonce = crypto.getRandomValues(new Uint8Array(12));604 const encoded = new TextEncoder().encode(hex(rootKeyBytes));605 const raw = await crypto.subtle.encrypt(606 {607 name: "AES-GCM",608 iv: nonce,609 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,610 tagLength: 128,611 },612 recoveryKey,613 encoded,614 );615 const rawU8 = new Uint8Array(raw);616 return {617 recoverySalt: hex(recoverySalt),618 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),619 wrappedNonce: hex(nonce),620 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),621 };622}623624625626627628629export async function unwrapRootKeyForRecovery(630 wrappedRootKey: string,631 wrappedNonce: string,632 wrappedTag: string,633 recoverySaltHex: string,634 words: string[]635): Promise<void> {636 const wordMaterial = await wordsToRootKeyMaterial(words);637 const recoverySalt = unhex(recoverySaltHex);638 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);639640 641 642 643 644 645 const nonceRaw = unhex(wrappedNonce);646 const ciphertextRaw = unhex(wrappedRootKey);647 const tagRaw = unhex(wrappedTag);648 649 650 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;651 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);652 combined.set(ciphertextRaw, 0);653 combined.set(tagRaw, ciphertextRaw.length);654 const raw = await crypto.subtle.decrypt(655 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },656 recoveryKey,657 combined658 );659 const decryptedHex = new TextDecoder().decode(raw);660 rootKey = unhex(decryptedHex);661}662663664665666667668669670671672export async function rewrapRootKeyForPassword(673 newDerivedKey: Uint8Array,674 _newSalt: Uint8Array675): Promise<void> {676 if (!rootKey) throw new Error("root key not set");677 const slotA = await loadSlot(SLOT_A);678 if (!slotA) throw new Error("missing slot A");679 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);680 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });681}682683684685686687688async function reencryptVaultForNewKey(689 newKey: Uint8Array,690 slotA: StoredVault691): Promise<void> {692 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);693 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");694 await writeSlot(SLOT_A, {695 ...slotA,696 nonce,697 ciphertext,698 tag,699 });700}701702703704705706707export async function changePasswordWithNewKey(708 newSalt: Uint8Array,709 newKey: Uint8Array710): Promise<void> {711 const slotA = await loadSlot(SLOT_A);712 if (!slotA) throw new Error("missing slot A");713 714 715 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });716 if (derivedKey) zeroKey(derivedKey);717 derivedKey = newKey;718 719 720 721 void saveSessionKey(newKey, "real");722 723 724 725 726 if (rootKey) {727 const slotAfter = await loadSlot(SLOT_A);728 if (slotAfter) {729 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);730 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });731 }732 }733 if (!duressConfigured) {734 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));735 }736 await clearPinUnlockStorage();737 await stampLocalVaultMutation();738}739740741742743744745export { deriveKey, generateSalt };746747export async function loadSlotA(): Promise<StoredVault | null> {748 return loadSlot(SLOT_A);749}750751752753754755756757758759760async function deriveRootKeyFromDerivedKey(761 derivedKeyBytes: Uint8Array,762 slotA: StoredVault,763): Promise<Uint8Array | null> {764 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {765 return null;766 }767 try {768 const rootHex = await decrypt(769 derivedKeyBytes,770 slotA.pw_root_nonce,771 slotA.pw_root_ciphertext,772 slotA.pw_root_tag,773 "pw_root_v1",774 );775 return unhex(rootHex);776 } catch {777 return null;778 }779}780781782783async function wrapRootKeyForDerivedKey(784 derivedKeyBytes: Uint8Array,785 rootKeyBytes: Uint8Array,786): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {787 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");788 return {789 pw_root_ciphertext: ciphertext,790 pw_root_nonce: nonce,791 pw_root_tag: tag,792 };793}794795796export async function getRecoveryWords(): Promise<string[]> {797 if (!rootKey) {798 throw new Error("recovery not set up");799 }800 return rootKeyToWords(rootKey);801}802803async function loadSlot(key: string): Promise<StoredVault | null> {804 const result = await chrome.storage.local.get(key);805 return (result as Record<string, StoredVault | undefined>)[key] ?? null;806}807808async function writeSlot(key: string, vault: StoredVault): Promise<void> {809 try {810 await chrome.storage.local.set({ [key]: vault });811 } catch (err) {812 throw mapStorageError(err);813 }814}815816817818819820821822823824825export async function rollbackAdoptedVault(): Promise<void> {826 await lock();827 try {828 await chrome.storage.local.remove([829 SLOT_A,830 SLOT_B,831 "peach_last_modified_at",832 ]);833 } catch (err) {834 throw mapStorageError(err);835 }836}837838async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {839 const raw = await chrome.storage.local.get("peach_last_modified_at");840 const previous = Number(841 (raw as Record<string, unknown>).peach_last_modified_at,842 );843 const next = Math.max(844 Date.now(),845 Number.isFinite(previous) ? previous + 1 : 0,846 );847 try {848 await chrome.storage.local.set({849 [SLOT_A]: slotA,850 [SLOT_B]: slotB,851 peach_last_modified_at: String(next),852 });853 } catch (err) {854 throw mapStorageError(err);855 }856}857858859860861862863864865let lastEntryTimestamp = 0;866function monotonicEntryTimestamp(currentModifiedAt?: number): number {867 const base = Math.max(868 Date.now(),869 (currentModifiedAt ?? 0) + 1,870 lastEntryTimestamp + 1,871 );872 lastEntryTimestamp = base;873 return base;874}875876async function stampLocalVaultMutation(): Promise<void> {877 const raw = await chrome.storage.local.get("peach_last_modified_at");878 const previous = Number(879 (raw as Record<string, unknown>).peach_last_modified_at,880 );881 const next = Math.max(882 Date.now(),883 Number.isFinite(previous) ? previous + 1 : 0,884 );885 await chrome.storage.local.set({886 peach_last_modified_at: String(next),887 });888}889890891892893894895896897898899900function padPlaintext(json: string, minBytes = 0): string {901 const encoded = new TextEncoder().encode(json);902 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);903 904 const target = Math.max(bucket, minBytes);905 const padLen = target - encoded.length;906 return json + " ".repeat(padLen);907}908909910911912913914915916917918919const CSPRNG_CHUNK = 65_536;920921function fillRandom(buf: Uint8Array): Uint8Array {922 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {923 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);924 const chunk = new Uint8Array(end - offset);925 crypto.getRandomValues(chunk);926 buf.set(chunk, offset);927 }928 return buf;929}930931function generateNoiseBlob(ciphertextBytes: number): StoredVault {932 const salt = crypto.getRandomValues(new Uint8Array(16));933 const nonce = crypto.getRandomValues(new Uint8Array(12));934 const cipher = fillRandom(new Uint8Array(ciphertextBytes));935 const tag = crypto.getRandomValues(new Uint8Array(16));936 return {937 version: 1,938 salt: hex(salt),939 nonce: hex(nonce),940 ciphertext: hex(cipher),941 tag: hex(tag),942 };943}944945946947948949type VaultPayload = {950 entries: VaultEntry[];951 tombstones?: Record<string, number>;952 duressConfigured?: boolean;953};954955type DecryptedPayload = {956 entries: VaultEntry[];957 tombstones: Map<string, number>;958 duressConfigured: boolean;959};960961function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {962 const envelope: VaultPayload = {963 entries,964 tombstones: Object.fromEntries(tombstones),965 duressConfigured: duress,966 };967 return padPlaintext(JSON.stringify(envelope), minBytes);968}969970function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {971 const result = new Map<string, number>();972 if (raw && typeof raw === "object" && !Array.isArray(raw)) {973 for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {974 if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {975 result.set(id, timestamp);976 }977 }978 }979 980 981 for (const entry of allEntries) {982 if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {983 continue;984 }985 if ((result.get(entry.id) ?? 0) < entry.deletedAt) {986 result.set(entry.id, entry.deletedAt);987 }988 }989 return result;990}991992function reconcileTombstones(): void {993 const byId = new Map(entries.map((entry) => [entry.id, entry]));994 for (const [id] of tombstones) {995 if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);996 }997 for (const entry of entries) {998 if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {999 if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {1000 tombstones.set(entry.id, entry.deletedAt);1001 }1002 }1003 }1004}10051006function decodePayload(plain: string): DecryptedPayload {1007 const parsed = JSON.parse(plain) as VaultPayload;1008 if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {1009 throw new Error("invalid vault payload");1010 }1011 const normalized = parsed.entries.map(normalizeEntry);1012 return {1013 entries: normalized,1014 tombstones: decodeTombstones(parsed.tombstones, normalized),1015 duressConfigured: parsed.duressConfigured === true,1016 };1017}1018101910201021102210231024102510261027102810291030103110321033103410351036103710381039function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1040 let merged = false;1041 const now = Date.now();10421043 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1044 const byRpId = new Map<string, VaultEntry[]>();1045 for (const e of candidates) {1046 const rpId = e.passkey!.rpId;1047 const list = byRpId.get(rpId);1048 if (list) list.push(e);1049 else byRpId.set(rpId, [e]);1050 }10511052 for (const [rpId, group] of byRpId) {1053 if (group.length === 0) continue;1054 group.sort((a, b) => {1055 const aT =1056 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1057 const bT =1058 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1059 return bT - aT;1060 });1061 const winner = group[0]!;10621063 1064 1065 1066 const loginTarget = entries.find((e) => {1067 if (e.deletedAt || e.passkey || e.type !== "login") return false;1068 if (e.url && extractDomain(e.url) === rpId) return true;1069 for (const extra of e.additionalUrls ?? []) {1070 if (extractDomain(extra) === rpId) return true;1071 }1072 return false;1073 });10741075 if (loginTarget) {1076 loginTarget.passkey = winner.passkey;1077 1078 1079 1080 if (!loginTarget.totp && winner.totp) {1081 loginTarget.totp = winner.totp;1082 loginTarget.totpAlgorithm = winner.totpAlgorithm;1083 loginTarget.totpDigits = winner.totpDigits;1084 loginTarget.totpPeriod = winner.totpPeriod;1085 }1086 loginTarget.modifiedAt = now;1087 for (const dup of group) {1088 if (!dup.deletedAt) {1089 dup.deletedAt = now;1090 dup.modifiedAt = now;1091 }1092 }1093 merged = true;1094 } else if (group.length > 1) {1095 for (let i = 1; i < group.length; i += 1) {1096 const dup = group[i]!;1097 if (!dup.deletedAt) {1098 dup.deletedAt = now;1099 dup.modifiedAt = now;1100 }1101 }1102 merged = true;1103 }1104 }11051106 return { merged };1107}11081109111011111112111311141115function hasValidVaultSchema(v: unknown): v is StoredVault {1116 if (typeof v !== "object" || v === null) return false;1117 const r = v as Record<string, unknown>;1118 return (1119 r.version === 1 &&1120 typeof r.salt === "string" &&1121 typeof r.nonce === "string" &&1122 typeof r.ciphertext === "string" &&1123 typeof r.tag === "string"1124 );1125}11261127export type TryDecryptResult =1128 | { status: "ok"; payload: DecryptedPayload }1129 | { status: "wrong_key" }1130 | { status: "corrupted"; reason: string };1131113211331134113511361137export async function tryDecryptSlot(1138 vault: StoredVault,1139 key: Uint8Array,1140 aadTag?: string,1141): Promise<DecryptedPayload | null> {1142 const res = await tryDecryptSlotResult(vault, key, aadTag);1143 return res.status === "ok" ? res.payload : null;1144}11451146export async function tryDecryptSlotResult(1147 vault: StoredVault,1148 key: Uint8Array,1149 aadTag?: string,1150): Promise<TryDecryptResult> {1151 if (!hasValidVaultSchema(vault)) {1152 return { status: "corrupted", reason: "invalid_slot_schema" };1153 }1154 let plain: string;1155 try {1156 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1157 } catch {1158 return { status: "wrong_key" };1159 }1160 try {1161 const parsed = JSON.parse(plain) as unknown;1162 if (typeof parsed !== "object" || parsed === null) {1163 return { status: "corrupted", reason: "invalid_payload_shape" };1164 }1165 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1166 return { status: "corrupted", reason: "invalid_payload_shape" };1167 }1168 return { status: "ok", payload: decodePayload(plain) };1169 } catch {1170 return { status: "corrupted", reason: "invalid_payload_json" };1171 }1172}117311741175117611771178function buildDomainIndexSync(all: VaultEntry[]): void {1179 domainIndex = new Map();11801181 for (const entry of all) {1182 if (entry.deletedAt) continue;1183 if (entry.type === "note" || entry.type === "card") continue;11841185 1186 1187 1188 1189 const domains = new Set<string>();1190 const primary = extractDomain(entry.url ?? "");1191 if (primary) domains.add(primary);1192 for (const extra of entry.additionalUrls ?? []) {1193 const d = extractDomain(extra);1194 if (d) domains.add(d);1195 }1196 if (domains.size === 0) continue;11971198 for (const domain of domains) {1199 const bucket = domainIndex.get(domain) ?? [];1200 bucket.push(entry);1201 domainIndex.set(domain, bucket);1202 }1203 }1204}12051206export async function createVault(password: string): Promise<void> {1207 const salt = generateSalt();1208 derivedKey = await deriveKey(password, salt);1209 entries = [];1210 tombstones = new Map();1211 vaultMode = "real";1212 duressConfigured = false;1213 buildDomainIndexSync(entries);12141215 1216 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1217 await generateAndWrapRootKey();1218 rootKey = unhex(rawRootKey);12191220 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1221 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");12221223 1224 1225 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);12261227 const slotA: StoredVault = {1228 version: 1,1229 salt: hex(salt),1230 nonce,1231 ciphertext,1232 tag,1233 recovery_salt: recoverySalt,1234 recovery_wrapped_root_key: wrappedRootKey,1235 recovery_nonce: wrappedNonce,1236 recovery_tag: wrappedTag,1237 ...pwRoot,1238 };1239 await writeSlot(SLOT_A, slotA);12401241 1242 1243 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1244 await stampLocalVaultMutation();12451246 touchAutoLock();1247}1248124912501251125212531254export async function createVaultFromRootKey(1255 password: string,1256 rootKeyHex: string,1257): Promise<{ vaultId: string }> {1258 if (typeof password !== "string" || password.length === 0) {1259 throw new Error("ADOPT_INVALID_PASSWORD");1260 }1261 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1262 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1263 }12641265 const rootKeyBytes = unhex(rootKeyHex);1266 if (rootKeyBytes.length !== 32) {1267 rootKeyBytes.fill(0);1268 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1269 }12701271 let nextDerivedKey: Uint8Array | null = null;1272 let sessionRootKey: Uint8Array | null = null;1273 try {1274 if ((await loadSlot(SLOT_A)) !== null) {1275 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1276 }12771278 const nextEntries: VaultEntry[] = [];1279 const nextDuressConfigured = false;1280 const vaultId = await computeVaultId(rootKeyBytes);1281 const salt = generateSalt();1282 nextDerivedKey = await deriveKey(password, salt);1283 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);12841285 sessionRootKey = new Uint8Array(rootKeyBytes);1286 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1287 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1288 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1289 const slotA: StoredVault = {1290 version: 1,1291 salt: hex(salt),1292 nonce,1293 ciphertext,1294 tag,1295 recovery_salt: recovery.recoverySalt,1296 recovery_wrapped_root_key: recovery.wrappedRootKey,1297 recovery_nonce: recovery.wrappedNonce,1298 recovery_tag: recovery.wrappedTag,1299 ...pwRoot,1300 };1301 const slotB = generateNoiseBlob(ciphertext.length / 2);1302 await commitCreatedVaultSlots(slotA, slotB);13031304 const previousDerivedKey = derivedKey;1305 const previousRootKey = rootKey;1306 derivedKey = nextDerivedKey;1307 rootKey = sessionRootKey;1308 nextDerivedKey = null;1309 sessionRootKey = null;1310 entries = nextEntries;1311 tombstones = new Map();1312 vaultMode = "real";1313 duressConfigured = nextDuressConfigured;1314 buildDomainIndexSync(entries);1315 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1316 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1317 touchAutoLock();13181319 return { vaultId };1320 } finally {1321 rootKeyBytes.fill(0);1322 if (nextDerivedKey) zeroKey(nextDerivedKey);1323 if (sessionRootKey) sessionRootKey.fill(0);1324 }1325}13261327export async function hasVault(): Promise<boolean> {1328 return (await loadSlot(SLOT_A)) !== null;1329}1330133113321333133413351336export async function isDuressEnabled(): Promise<boolean> {1337 return vaultMode === "real" && duressConfigured;1338}13391340export async function setupDuress(duressPassword: string): Promise<void> {1341 if (!derivedKey || vaultMode !== "real") {1342 throw new Error("vault must be unlocked in real mode");1343 }1344 1345 1346 1347 const slotA = await loadSlot(SLOT_A);1348 if (!slotA) throw new Error("missing slot A");13491350 const decoySalt = generateSalt();1351 const decoyKey = await deriveKey(duressPassword, decoySalt);1352 try {1353 1354 1355 1356 1357 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1358 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1359 await writeSlot(SLOT_B, {1360 version: 1,1361 salt: hex(decoySalt),1362 nonce,1363 ciphertext,1364 tag,1365 });1366 } finally {1367 zeroKey(decoyKey);1368 }13691370 1371 1372 duressConfigured = true;1373 await persistSlotA();1374}13751376export async function disableDuress(): Promise<void> {1377 if (!derivedKey || vaultMode !== "real") {1378 throw new Error("vault must be unlocked in real mode");1379 }1380 1381 1382 1383 const slotA = await loadSlot(SLOT_A);1384 if (slotA) {1385 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1386 }13871388 duressConfigured = false;1389 await persistSlotA();1390}13911392139313941395139613971398export async function verifyMainPassword(password: string): Promise<boolean> {1399 const slotA = await loadSlot(SLOT_A);1400 if (!slotA) return false;1401 const key = await deriveKey(password, unhex(slotA.salt));1402 try {1403 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1404 } finally {1405 zeroKey(key);1406 }1407}14081409export async function unlock(password: string): Promise<boolean> {1410 return attemptUnlockWithDerive(password, deriveKey);1411}14121413async function attemptUnlockWithDerive(1414 password: string,1415 derive: typeof deriveKey,1416): Promise<boolean> {1417 1418 1419 1420 1421 1422 1423 const [slotA, slotB] = await Promise.all([1424 loadSlot(SLOT_A),1425 loadSlot(SLOT_B),1426 ]);14271428 1429 const [keyA, keyB] = await Promise.all([1430 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1431 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1432 ]);14331434 1435 1436 1437 const [resultA, resultB] = await Promise.all([1438 keyA && slotA1439 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1440 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1441 keyB && slotB1442 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1443 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1444 ]);1445 const payloadA = resultA.status === "ok" ? resultA.payload : null;1446 const payloadB = resultB.status === "ok" ? resultB.payload : null;14471448 if (payloadA && keyA) {1449 derivedKey = keyA;1450 void saveSessionKey(keyA, "real");1451 vaultMode = "real";1452 entries = payloadA.entries;1453 tombstones = payloadA.tombstones;1454 duressConfigured = payloadA.duressConfigured;1455 buildDomainIndexSync(entries);1456 await loadSettings();1457 touchAutoLock();1458 if (keyB) zeroKey(keyB);14591460 1461 1462 rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1463 if (!rootKey) {1464 await lock();1465 return false;1466 }14671468 await lockedSyncUnlockHook?.().catch((error) => {1469 console.warn("[Peach sync] locked inbox drain failed:", error);1470 });1471 await resetPinFailedAttempts();1472 return true;1473 }14741475 if (payloadB && keyB) {1476 derivedKey = keyB;1477 void saveSessionKey(keyB, "decoy");1478 vaultMode = "decoy";1479 entries = payloadB.entries;1480 tombstones = payloadB.tombstones;1481 duressConfigured = false;1482 buildDomainIndexSync(entries);1483 await loadSettings();1484 touchAutoLock();1485 if (keyA) zeroKey(keyA);1486 return true;1487 }14881489 if (keyA) zeroKey(keyA);1490 if (keyB) zeroKey(keyB);1491 1492 1493 if (resultA.status === "corrupted") {1494 lastUnlockFailureReason = resultA.reason;1495 } else if (resultB.status === "corrupted") {1496 lastUnlockFailureReason = resultB.reason;1497 }1498 return false;1499}15001501150215031504150515061507const SESSION_KEY_STORAGE = "peach_session_dk";150815091510151115121513let sessionEscrowAvailable = true;15141515151615171518151915201521export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1522 if (sessionEscrow) return;1523 sessionEscrowAvailable = false;1524 await clearSessionKey();1525}15261527async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1528 if (!chrome.storage.session || !sessionEscrowAvailable) return;1529 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1530}15311532async function clearSessionKey(): Promise<void> {1533 if (!chrome.storage.session) return;1534 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1535}15361537153815391540154115421543let vaultEpoch = 0;1544154515461547let restoreInFlight: Promise<boolean> | null = null;154815491550155115521553155415551556export async function restoreFromSession(): Promise<boolean> {1557 if (derivedKey !== null) return true;1558 if (restoreInFlight) return restoreInFlight;1559 const task = restoreFromSessionInner();1560 restoreInFlight = task;1561 try {1562 return await task;1563 } finally {1564 restoreInFlight = null;1565 }1566}15671568async function restoreFromSessionInner(): Promise<boolean> {1569 const epoch = vaultEpoch;1570 const stale = () => epoch !== vaultEpoch;1571 if (derivedKey !== null) return true;1572 if (!sessionEscrowAvailable) return false;1573 if (!chrome.storage.session) return false;1574 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1575 if (stale()) return false;1576 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1577 if (!entry?.keyHex) return false;15781579 const key = unhex(entry.keyHex);1580 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1581 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1582 const slot = await loadSlot(slotKey);1583 if (stale()) { zeroKey(key); return false; }1584 if (!slot) { await clearSessionKey(); return false; }15851586 const result = await tryDecryptSlotResult(slot, key, aadTag);1587 if (stale()) { zeroKey(key); return false; }1588 if (result.status !== "ok") { await clearSessionKey(); return false; }15891590 1591 1592 const payload = result.payload;1593 await loadSettings();1594 if (stale()) { zeroKey(key); return false; }15951596 1597 1598 let freshRoot: Uint8Array | null = null;1599 if (entry.mode === "real") {1600 freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1601 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1602 if (!freshRoot) {1603 await lock();1604 return false;1605 }1606 }16071608 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1609 derivedKey = key;1610 vaultMode = entry.mode;1611 entries = payload.entries;1612 tombstones = payload.tombstones;1613 duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1614 if (freshRoot) rootKey = freshRoot;1615 buildDomainIndexSync(entries);1616 touchAutoLock();16171618 await lockedSyncUnlockHook?.().catch((error) => {1619 console.warn("[Peach sync] locked inbox drain failed:", error);1620 });1621 if (stale()) return false;16221623 return true;1624}16251626export async function lock(): Promise<void> {1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 vaultEpoch++;1637 if (derivedKey) {1638 zeroKey(derivedKey);1639 derivedKey = null;1640 }1641 if (rootKey) {1642 rootKey.fill(0);1643 rootKey = null;1644 }16451646 entries = [];1647 tombstones = new Map();1648 domainIndex = new Map();1649 vaultMode = "real";1650 duressConfigured = false;1651 clearAutoLock();1652 await clearSessionKey();1653 await clearRecoveryConfirmPending();1654}16551656export function isUnlocked(): boolean {1657 return derivedKey !== null;1658}16591660166116621663export async function unlockWithKey(1664 masterKeyHex: string,1665): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1666 try {1667 const masterKeyBytes = unhex(masterKeyHex);1668 const stored = await loadSlot(SLOT_A);1669 if (!stored) return { ok: false, error: "invalid_key" };1670 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1671 if (!payload) return { ok: false, error: "invalid_key" };16721673 derivedKey = masterKeyBytes;1674 vaultMode = "real";1675 entries = payload.entries;1676 tombstones = payload.tombstones;1677 duressConfigured = payload.duressConfigured;1678 buildDomainIndexSync(entries);1679 await loadSettings();1680 touchAutoLock();16811682 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1683 if (!rootKey) {1684 await lock();1685 return { ok: false, error: "invalid_key" };1686 }1687 return { ok: true, entries };1688 } catch {1689 return { ok: false, error: "invalid_key" };1690 }1691}169216931694169516961697169816991700export async function exportDuressSlotForAdoption(): Promise<string | null> {1701 if (!(await isDuressEnabled())) return null;1702 const slotB = await loadSlot(SLOT_B);1703 if (!slotB) return null;1704 return JSON.stringify(slotB);1705}17061707170817091710171117121713export async function installAdoptedDuressSlot(json: string): Promise<void> {1714 let parsed: StoredVault;1715 try {1716 const raw = JSON.parse(json) as unknown;1717 if (!hasValidVaultSchema(raw)) {1718 throw new Error("invalid_duress_slot_shape");1719 }1720 parsed = raw;1721 } catch (err) {1722 if (err instanceof SyntaxError) {1723 throw new Error("invalid_duress_slot_shape");1724 }1725 throw err;1726 }1727 await writeSlot(SLOT_B, parsed);1728}17291730173117321733173417351736173717381739174017411742174317441745174617471748export async function unlockFromCodex(params: {1749 slotA: string; 1750 slotB: string | null; 1751 timestamp: number;1752 masterPassphrase: string;1753}): Promise<1754 | { ok: true; entryCount: number }1755 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1756> {1757 let parsedSlotA: StoredVault;1758 let parsedSlotB: StoredVault | null = null;1759 try {1760 const rawA = JSON.parse(params.slotA) as unknown;1761 if (!hasValidVaultSchema(rawA)) {1762 return { ok: false, reason: "invalid_slot_format" };1763 }1764 parsedSlotA = rawA;1765 if (params.slotB !== null) {1766 const rawB = JSON.parse(params.slotB) as unknown;1767 if (!hasValidVaultSchema(rawB)) {1768 return { ok: false, reason: "invalid_slot_format" };1769 }1770 parsedSlotB = rawB;1771 }1772 } catch {1773 return { ok: false, reason: "invalid_slot_format" };1774 }17751776 1777 1778 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1779 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1780 if (!trialPayload) {1781 zeroKey(trialKey);1782 return { ok: false, reason: "wrong_passphrase" };1783 }1784 1785 1786 zeroKey(trialKey);17871788 1789 1790 await takeVaultSnapshot("restore");17911792 await writeSlot(SLOT_A, parsedSlotA);1793 if (parsedSlotB) {1794 await writeSlot(SLOT_B, parsedSlotB);1795 } else {1796 1797 1798 1799 await writeSlot(1800 SLOT_B,1801 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1802 );1803 }1804 await chrome.storage.local.set({1805 peach_last_modified_at: String(params.timestamp),1806 });18071808 1809 1810 const ok = await unlock(params.masterPassphrase);1811 if (!ok) {1812 1813 1814 1815 return { ok: false, reason: "wrong_passphrase" };1816 }1817 return { ok: true, entryCount: getEntries().length };1818}18191820182118221823export function getDerivedKeyHex(): string {1824 if (!derivedKey) throw new Error("vault_not_unlocked");1825 return hex(derivedKey);1826}18271828182918301831183218331834183518361837183818391840184118421843export function getRootKeyHex(): string | null {1844 return rootKey ? hex(rootKey) : null;1845}18461847export function getEntries(): VaultEntry[] {1848 if (!derivedKey) throw new Error("vault_locked");1849 return entries.filter((entry) => !entry.deletedAt);1850}18511852const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;18531854export function getIdentities(): SavedIdentity[] {1855 if (!derivedKey) throw new Error("vault_locked");1856 const active = entries.filter((e) => !e.deletedAt && e.type === "login");18571858 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1859 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();18601861 for (const entry of active) {1862 const domain = extractDomain(entry.url ?? "") ?? "";1863 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;18641865 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1866 const existing = map.get(v);1867 if (!existing || modifiedAt > existing.lastUsed) {1868 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1869 } else {1870 map.set(v, { ...existing, count: existing.count + 1 });1871 }1872 }18731874 const uVal = entry.username?.trim();1875 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);18761877 1878 1879 const eVal = entry.email?.trim();1880 if (eVal) tally(emailMap, eVal);1881 }18821883 const toIdentity = (type: "email" | "username") =>1884 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1885 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1886 });18871888 return [1889 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1890 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1891 ];1892}18931894export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1895 if (!derivedKey) throw new Error("vault_locked");18961897 const now = Date.now();1898 const added: VaultEntry[] = [];1899 for (const draft of drafts) {1900 try {1901 const sanitized = sanitizeEntryDraft(draft);1902 const nextEntry: VaultEntry = {1903 ...sanitized,1904 id: crypto.randomUUID(),1905 createdAt: now,1906 modifiedAt: monotonicEntryTimestamp(),1907 };1908 entries.push(nextEntry);1909 added.push(nextEntry);1910 } catch {1911 1912 }1913 }19141915 if (added.length > 0) {1916 await persistEntries();1917 }19181919 return added;1920}192119221923192419251926192719281929export async function restoreEntries(1930 incoming: VaultEntry[],1931 mode: "replace" | "merge"1932): Promise<{ added: number; updated: number; skipped: number }> {1933 if (!derivedKey) throw new Error("vault_locked");19341935 1936 1937 1938 if (mode === "replace" && incoming.length === 0) {1939 throw new Error("refuse to replace with empty entry set");1940 }19411942 let added = 0;1943 let updated = 0;1944 let skipped = 0;19451946 if (mode === "replace") {1947 entries = [];1948 for (const entry of incoming) {1949 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1950 if (!normalized) {1951 skipped += 1;1952 continue;1953 }1954 entries.push(normalized);1955 if (normalized.deletedAt) {1956 tombstones.set(1957 normalized.id,1958 Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),1959 );1960 } else {1961 tombstones.delete(normalized.id);1962 }1963 added += 1;1964 }1965 } else {1966 const byId = new Map<string, VaultEntry>();1967 for (const e of entries) byId.set(e.id, e);19681969 for (const entry of incoming) {1970 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1971 if (!normalized) {1972 skipped += 1;1973 continue;1974 }1975 const existing = byId.get(normalized.id);1976 if (!existing) {1977 entries.push(normalized);1978 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1979 else tombstones.delete(normalized.id);1980 added += 1;1981 } else if (normalized.modifiedAt > existing.modifiedAt) {1982 const idx = entries.findIndex((e) => e.id === existing.id);1983 if (idx !== -1) {1984 entries[idx] = normalized;1985 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1986 else tombstones.delete(normalized.id);1987 updated += 1;1988 }1989 } else {1990 skipped += 1;1991 }1992 }1993 }19941995 await persistEntries("import");1996 return { added, updated, skipped };1997}199819992000200120022003200420052006200720082009201020112012201320142015const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;20162017function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2018 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2019 return { ...entry, id: crypto.randomUUID() };2020}202120222023202420252026202720282029function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2030 if (!entry || typeof entry !== "object") return null;2031 const title = typeof entry.title === "string" ? entry.title.trim() : "";2032 if (!title) return null;2033 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2034 const now = Date.now();2035 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2036 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;20372038 2039 2040 const normalized = normalizeEntry({2041 ...entry,2042 id,2043 title,2044 type: entry.type ?? "login",2045 createdAt,2046 modifiedAt,2047 });20482049 if (normalized.passkey) {2050 normalized.passkey = sanitizePasskey(normalized.passkey);2051 }2052 if (normalized.canary) {2053 normalized.canary = sanitizeCanary(normalized.canary);2054 }2055 2056 normalized.tags = sanitizeTags(normalized.tags);20572058 2059 2060 if (2061 normalized.type === "login" &&2062 !normalized.passkey &&2063 typeof normalized.password !== "string"2064 ) {2065 return null;2066 }2067 if (entry.passkey && !normalized.passkey) {2068 2069 2070 return null;2071 }20722073 return normalized;2074}2075207620772078207920802081function sanitizePasskey(pk: unknown): PasskeyData | undefined {2082 if (!pk || typeof pk !== "object") return undefined;2083 const r = pk as Record<string, unknown>;2084 if (2085 typeof r.rpId !== "string" || !r.rpId ||2086 typeof r.credentialId !== "string" || !r.credentialId ||2087 typeof r.publicKeyCose !== "string" ||2088 typeof r.userHandle !== "string" ||2089 typeof r.userName !== "string"2090 ) {2091 return undefined;2092 }2093 return {2094 rpId: r.rpId,2095 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2096 credentialId: r.credentialId,2097 publicKeyCose: r.publicKeyCose,2098 privateKeyJwk:2099 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2100 ? (r.privateKeyJwk as JsonWebKey)2101 : undefined,2102 userHandle: r.userHandle,2103 userName: r.userName,2104 userDisplayName:2105 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2106 algorithm: r.algorithm === -8 ? -8 : -7,2107 signCount: typeof r.signCount === "number" ? r.signCount : 0,2108 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2109 backupEligible: r.backupEligible === true,2110 backupState: r.backupState === true,2111 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2112 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2113 transports: Array.isArray(r.transports)2114 ? r.transports.filter((t): t is string => typeof t === "string")2115 : undefined,2116 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2117 };2118}21192120212121222123212421252126212721282129function sanitizeCanary(c: unknown): Canary | undefined {2130 if (!c || typeof c !== "object") return undefined;2131 const r = c as Record<string, unknown>;2132 if (typeof r.webhookUrl !== "string") return undefined;2133 let url: URL;2134 try {2135 url = new URL(r.webhookUrl);2136 } catch {2137 return undefined;2138 }2139 if (url.protocol !== "https:") return undefined;2140 return {2141 webhookUrl: url.toString(),2142 label: typeof r.label === "string" ? r.label : undefined,2143 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2144 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2145 };2146}21472148214921502151215221532154function sanitizeTags(tags: unknown): string[] | undefined {2155 if (!Array.isArray(tags)) return undefined;2156 const out: string[] = [];2157 for (const t of tags) {2158 if (typeof t !== "string") continue;2159 const trimmed = t.trim();2160 if (!trimmed) continue;2161 out.push(trimmed.slice(0, 256));2162 if (out.length >= 255) break;2163 }2164 return out.length > 0 ? out : undefined;2165}216621672168216921702171export async function clearVaultEntries(2172 confirmation: "REPLACE_ALL"2173): Promise<void> {2174 if (confirmation !== "REPLACE_ALL") {2175 throw new Error("clearVaultEntries requires explicit confirmation");2176 }2177 if (!derivedKey) throw new Error("vault_locked");2178 for (const entry of entries) {2179 if (!entry.deletedAt) {2180 const stamp = monotonicEntryTimestamp(Math.max(2181 entry.modifiedAt,2182 tombstones.get(entry.id) ?? 0,2183 ));2184 entry.deletedAt = stamp;2185 entry.modifiedAt = stamp;2186 tombstones.set(entry.id, stamp);2187 }2188 }2189 await persistEntries();2190}21912192export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2193 if (!derivedKey) throw new Error("vault_locked");21942195 const sanitized = sanitizeEntryDraft(entry);2196 const now = Date.now();2197 const stamp = monotonicEntryTimestamp();2198 const nextEntry: VaultEntry = {2199 ...sanitized,2200 id: crypto.randomUUID(),2201 createdAt: now,2202 modifiedAt: stamp,2203 2204 2205 passwordChangedAt: sanitized.password ? stamp : undefined,2206 };22072208 2209 2210 2211 const rollback = entries;2212 entries = [...entries, nextEntry];2213 try {2214 await persistEntries();2215 } catch (err) {2216 entries = rollback;2217 throw err;2218 }22192220 2221 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2222 checkBreach(nextEntry.password).then(async (count) => {2223 if (count > 0) {2224 const idx = entries.findIndex((e) => e.id === nextEntry.id);2225 if (idx !== -1) {2226 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2227 await persistEntries();2228 }2229 }2230 });2231 }22322233 fireSyncTrigger("vault_change");22342235 return nextEntry;2236}22372238223922402241224222432244export async function consolidatePasskeyEntries(): Promise<void> {2245 if (!derivedKey) return;2246 const { merged } = mergePasskeyOnlyIntoLogins(entries);2247 if (merged) {2248 await persistEntries();2249 }2250}22512252export async function updateEntry(2253 id: string,2254 patch: Partial<VaultEntry>2255): Promise<void> {2256 if (!derivedKey) throw new Error("vault_locked");22572258 const idx = entries.findIndex((entry) => entry.id === id);2259 if (idx === -1 || entries[idx].deletedAt) {2260 throw new Error("entry_not_found");2261 }22622263 const current = entries[idx];2264 const sanitizedPatch = sanitizeEntryPatch(patch);2265 2266 2267 2268 const passwordChanged =2269 "password" in patch &&2270 typeof sanitizedPatch.password === "string" &&2271 sanitizedPatch.password !== current.password;2272 const stamp = monotonicEntryTimestamp(current.modifiedAt);2273 2274 2275 2276 2277 2278 2279 2280 const priorPassword = current.password;2281 const shouldCapture =2282 passwordChanged &&2283 typeof priorPassword === "string" &&2284 priorPassword.length > 0 &&2285 current.passwordHistory?.[0]?.password !== priorPassword;2286 const nextHistory = shouldCapture2287 ? [2288 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2289 ...(current.passwordHistory ?? []),2290 ].slice(0, PASSWORD_HISTORY_MAX)2291 : current.passwordHistory;2292 entries[idx] = {2293 ...current,2294 ...sanitizedPatch,2295 modifiedAt: stamp,2296 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2297 };2298229923002301 2302 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2303 entries[idx] = current;2304 throw new Error("card number is required for card entries");2305 }23062307 2308 2309 2310 2311 try {2312 await persistEntries();2313 } catch (err) {2314 entries[idx] = current;2315 throw err;2316 }23172318 2319 2320 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2321 entries[idx] = { ...entries[idx], breach: undefined };2322 await persistEntries();2323 const password = entries[idx].password;2324 if (password) {2325 checkBreach(password).then(async (count) => {2326 const i = entries.findIndex((e) => e.id === id);2327 if (i !== -1 && count > 0) {2328 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2329 await persistEntries();2330 }2331 });2332 }2333 }23342335 fireSyncTrigger("vault_change");2336}23372338export async function deleteEntry(id: string): Promise<void> {2339 if (!derivedKey) throw new Error("vault_locked");23402341 const entry = entries.find((item) => item.id === id);2342 if (!entry || entry.deletedAt) {2343 throw new Error("entry_not_found");2344 }23452346 2347 2348 2349 const previousModifiedAt = entry.modifiedAt;2350 const previousTombstone = tombstones.get(id);2351 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2352 entry.deletedAt = stamp;2353 entry.modifiedAt = stamp;2354 tombstones.set(id, stamp);2355 2356 2357 try {2358 await persistEntries();2359 } catch (err) {2360 delete entry.deletedAt;2361 entry.modifiedAt = previousModifiedAt;2362 if (previousTombstone === undefined) tombstones.delete(id);2363 else tombstones.set(id, previousTombstone);2364 throw err;2365 }2366 fireSyncTrigger("vault_change");2367}23682369export function getDeletedEntries(): VaultEntry[] {2370 if (!derivedKey) return [];2371 return entries.filter((e) => !!e.deletedAt);2372}237323742375export function getTombstoneManifest(): Record<string, number> {2376 if (!derivedKey) return {};2377 return Object.fromEntries(tombstones);2378}2379238023812382238323842385export async function getVaultVersion(): Promise<number> {2386 try {2387 const raw = await chrome.storage.local.get("peach_last_modified_at");2388 const v = (raw as Record<string, unknown>).peach_last_modified_at;2389 if (typeof v === "number") return v;2390 if (typeof v === "string" && v !== "") {2391 const n = Number.parseInt(v, 10);2392 return Number.isFinite(n) ? n : 0;2393 }2394 return 0;2395 } catch {2396 return 0;2397 }2398}2399240024012402240324042405240624072408240924102411export async function applyMergedRemoteEntries(2412 remoteEntries: VaultEntry[],2413 remoteTombstones: Record<string, number>,2414 _context: { deviceName?: string } = {},2415): Promise<{ received: number; applied: number; conflicts: number }> {2416 2417 2418 2419 2420 2421 2422 if (!derivedKey) throw new Error("vault_locked");24232424 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2425 let applied = 0;2426 let conflicts = 0;24272428 const byId = new Map<string, VaultEntry>();2429 for (const e of entries) byId.set(e.id, e);24302431 const mergeNow = Date.now();2432 const remoteTombstoneTimes = new Map<string, number>();2433 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2434 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2435 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2436 }2437 }24382439 for (const remote of remoteEntries) {2440 const sanitized = sanitizeRestoredEntry(remote);2441 if (!sanitized) continue;2442 2443 2444 2445 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2446 if (typeof sanitized.deletedAt === "number") {2447 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2448 }2449 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2450 const localTombstoneTs = tombstones.get(sanitized.id);2451 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2452 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2453 continue;2454 }2455 const local = byId.get(sanitized.id);2456 if (!local) {2457 entries.push(sanitized);2458 byId.set(sanitized.id, sanitized);2459 tombstones.delete(sanitized.id);2460 applied += 1;2461 } else if (sanitized.modifiedAt > local.modifiedAt) {2462 const idx = entries.findIndex((e) => e.id === local.id);2463 if (idx !== -1) {2464 entries[idx] = sanitized;2465 byId.set(sanitized.id, sanitized);2466 tombstones.delete(sanitized.id);2467 applied += 1;2468 if (!local.deletedAt) conflicts += 1;2469 }2470 }2471 }24722473 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2474 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2475 2476 const deletedAt = remoteTombstoneTimes.get(id)!;2477 const knownTombstone = tombstones.get(id);2478 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2479 const local = byId.get(id);2480 if (!local) {2481 tombstones.set(id, deletedAt);2482 applied += 1;2483 continue;2484 }2485 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2486 if (deletedAt < local.modifiedAt) continue;2487 const idx = entries.findIndex((e) => e.id === id);2488 if (idx !== -1) {2489 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2490 tombstones.set(id, deletedAt);2491 applied += 1;2492 }2493 }24942495 if (applied > 0) {2496 await persistEntries("sync_received");2497 buildDomainIndexSync(entries);2498 }2499 return { received, applied, conflicts };2500}25012502export async function restoreEntry(id: string): Promise<void> {2503 if (!derivedKey) throw new Error("vault_locked");2504 const entry = entries.find((e) => e.id === id && e.deletedAt);2505 if (!entry) throw new Error("deleted entry not found");2506 const previousDeletedAt = entry.deletedAt;2507 const previousModifiedAt = entry.modifiedAt;2508 const previousTombstone = tombstones.get(id);2509 delete entry.deletedAt;2510 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2511 entry.modifiedAt,2512 previousTombstone ?? 0,2513 ));2514 tombstones.delete(id);2515 try {2516 await persistEntries();2517 } catch (err) {2518 entry.deletedAt = previousDeletedAt;2519 entry.modifiedAt = previousModifiedAt;2520 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2521 throw err;2522 }2523 buildDomainIndexSync(entries);2524}25252526export async function purgeEntry(id: string): Promise<void> {2527 if (!derivedKey) throw new Error("vault_locked");2528 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2529 if (idx === -1) throw new Error("deleted entry not found");2530 const entry = entries[idx]!;2531 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2532 entries.splice(idx, 1);2533 await persistEntries();2534}253525362537export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2538 if (!derivedKey || retentionMs === 0) return 0;2539 const cutoff = Date.now() - retentionMs;2540 const before = entries.length;2541 for (const entry of entries) {2542 if (entry.deletedAt && entry.deletedAt < cutoff) {2543 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2544 }2545 }2546 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2547 const purged = before - entries.length;2548 if (purged > 0) await persistEntries();2549 return purged;2550}25512552export function queryDomain(domain: string): VaultEntry[] {2553 return domainIndex.get(domain) ?? [];2554}25552556export function getEntryById(id: string): VaultEntry | undefined {2557 return entries.find((entry) => entry.id === id && !entry.deletedAt);2558}25592560async function persistSlotA(2561 reason: VaultSnapshot["reason"] = "local_save",2562): Promise<StoredVault> {2563 if (!derivedKey) throw new Error("vault_locked");25642565 const slotA = await loadSlot(SLOT_A);2566 if (!slotA) throw new Error("missing slot A");25672568 2569 2570 await takeVaultSnapshot(reason);25712572 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2573 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");25742575 2576 const nextSlotA: StoredVault = {2577 ...slotA,2578 nonce,2579 ciphertext,2580 tag,2581 2582 };2583 await writeSlot(SLOT_A, nextSlotA);25842585 2586 2587 2588 2589 2590 2591 try {2592 2593 2594 2595 if (!duressConfigured) {2596 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2597 }2598 2599 2600 2601 2602 2603 if (reason !== "sync_received") {2604 await stampLocalVaultMutation();2605 }2606 } catch (err) {2607 2608 2609 await writeSlot(SLOT_A, slotA).catch(() => {});2610 throw err;2611 }26122613 return nextSlotA;2614}26152616async function persistSlotB(): Promise<void> {2617 if (!derivedKey) throw new Error("vault_locked");26182619 const slotB = await loadSlot(SLOT_B);2620 if (!slotB) throw new Error("missing slot B");26212622 2623 2624 2625 2626 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2627 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");26282629 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2630 2631 2632 2633 try {2634 await stampLocalVaultMutation();2635 } catch (err) {2636 await writeSlot(SLOT_B, slotB).catch(() => {});2637 throw err;2638 }2639}26402641async function persistEntries(2642 reason: VaultSnapshot["reason"] = "local_save",2643): Promise<void> {2644 if (!derivedKey) throw new Error("vault_locked");26452646 2647 2648 const previousTombstones = tombstones;2649 tombstones = new Map(tombstones);2650 reconcileTombstones();2651 try {2652 if (vaultMode === "decoy") {2653 await persistSlotB();2654 } else {2655 await persistSlotA(reason);2656 }2657 } catch (err) {2658 tombstones = previousTombstones;2659 throw err;2660 }2661 buildDomainIndexSync(entries);2662 await refreshLockedSyncState();2663}2664266526662667266826692670function allowHttpUrl(url: string | undefined): string {2671 const trimmed = url?.trim() ?? "";2672 if (!trimmed) return "";2673 try {2674 const parsed = new URL(trimmed);2675 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2676 } catch {2677 2678 }2679 return trimmed;2680}26812682function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2683 if (!Array.isArray(values)) return undefined;2684 const seen = new Set<string>();2685 const cleaned: string[] = [];2686 for (const value of values) {2687 const trimmed = typeof value === "string" ? value.trim() : "";2688 if (!trimmed) continue;2689 if (!allowHttpUrl(trimmed)) continue;2690 const key = trimmed.toLowerCase();2691 if (seen.has(key)) continue;2692 seen.add(key);2693 cleaned.push(trimmed);2694 }2695 return cleaned.length > 0 ? cleaned : undefined;2696}26972698function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2699 const title = entry.title.trim();2700 const type: VaultEntry["type"] = entry.type ?? "login";27012702 if (!title) throw new Error("title is required");27032704 if (entry.passkey) {2705 const p = entry.passkey;2706 if (2707 !p.rpId ||2708 !p.credentialId ||2709 !p.publicKeyCose ||2710 !p.userHandle ||2711 !p.userName ||2712 (p.algorithm !== -7 && p.algorithm !== -8)2713 ) {2714 throw new Error("invalid passkey credential");2715 }2716 }2717 if (type === "card") {2718 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2719 throw new Error("cardholder name and card number are required");2720 }2721 }27222723 return {2724 type,2725 title,2726 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2727 email: type === "login" ? entry.email?.trim() || undefined : undefined,2728 password: type === "login" ? entry.password : undefined,2729 url: type === "login" ? allowHttpUrl(entry.url) : undefined,2730 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2731 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2732 2733 2734 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2735 totpDigits: type === "login" ? entry.totpDigits : undefined,2736 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2737 notes: entry.notes?.trim() || undefined,2738 breach: type === "login" ? entry.breach : undefined,2739 2740 2741 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2742 tags: sanitizeTags(entry.tags),2743 passkey: entry.passkey,2744 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2745 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2746 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2747 expYear: type === "card" ? entry.expYear?.trim() : undefined,2748 cvc: type === "card" ? entry.cvc?.trim() : undefined,2749 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2750 network: type === "card" ? entry.network : undefined,2751 last4: type === "card" ? entry.last4?.trim() : undefined,2752 };2753}27542755function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2756 const nextPatch: Partial<VaultEntry> = {};27572758 if ("type" in patch) {2759 nextPatch.type = patch.type;2760 }27612762 if ("title" in patch) {2763 const title = patch.title?.trim() ?? "";2764 if (!title) throw new Error("title is required");2765 nextPatch.title = title;2766 }27672768 if ("url" in patch) {2769 nextPatch.url = allowHttpUrl(patch.url);2770 }27712772 if ("additionalUrls" in patch) {2773 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2774 const seen = new Set<string>();2775 const cleaned: string[] = [];2776 for (const value of incoming) {2777 const trimmed = typeof value === "string" ? value.trim() : "";2778 if (!trimmed || !allowHttpUrl(trimmed)) continue;2779 const key = trimmed.toLowerCase();2780 if (seen.has(key)) continue;2781 seen.add(key);2782 cleaned.push(trimmed);2783 }2784 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2785 }27862787 if ("username" in patch) {2788 nextPatch.username = patch.username?.trim() ?? "";2789 }27902791 if ("email" in patch) {2792 nextPatch.email = patch.email?.trim() || undefined;2793 }27942795 if ("password" in patch) {2796 const password = patch.password ?? "";2797 2798 nextPatch.password = password || undefined;2799 }28002801 if ("totp" in patch) {2802 nextPatch.totp = patch.totp?.trim() || undefined;2803 }28042805 if ("notes" in patch) {2806 nextPatch.notes = patch.notes?.trim() || undefined;2807 }28082809 if ("breach" in patch) {2810 nextPatch.breach = patch.breach;2811 }28122813 if ("canary" in patch) {2814 2815 2816 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2817 }28182819 if ("passkey" in patch) {2820 2821 2822 if (patch.passkey === undefined || patch.passkey === null) {2823 nextPatch.passkey = undefined;2824 } else {2825 const sanitized = sanitizePasskey(patch.passkey);2826 if (!sanitized) throw new Error("invalid passkey credential");2827 nextPatch.passkey = sanitized;2828 }2829 }28302831 if ("tags" in patch) {2832 2833 nextPatch.tags = sanitizeTags(patch.tags);2834 }28352836 if ("cardholderName" in patch) {2837 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2838 }2839 if ("cardNumber" in patch) {2840 const n = patch.cardNumber ?? "";2841 nextPatch.cardNumber = n || undefined;2842 }2843 if ("expMonth" in patch) {2844 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2845 }2846 if ("expYear" in patch) {2847 nextPatch.expYear = patch.expYear?.trim() || undefined;2848 }2849 if ("cvc" in patch) {2850 nextPatch.cvc = patch.cvc?.trim() || undefined;2851 }2852 if ("cardNickname" in patch) {2853 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2854 }2855 if ("network" in patch) {2856 nextPatch.network = patch.network;2857 }2858 if ("last4" in patch) {2859 nextPatch.last4 = patch.last4?.trim() || undefined;2860 }28612862 return nextPatch;2863}28642865function normalizeEntry(entry: VaultEntry): VaultEntry {2866 const base: VaultEntry = {2867 id: entry.id,2868 type: entry.type,2869 title: entry.title,2870 username: entry.username,2871 email: entry.email,2872 password: entry.password,2873 url: entry.url,2874 additionalUrls:2875 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2876 ? entry.additionalUrls2877 : undefined,2878 totp: entry.totp,2879 2880 totpAlgorithm: entry.totpAlgorithm,2881 totpDigits: entry.totpDigits,2882 totpPeriod: entry.totpPeriod,2883 notes: entry.notes,2884 breach: entry.breach,2885 2886 2887 2888 passwordChangedAt:2889 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2890 2891 2892 2893 passwordHistory:2894 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2895 ? entry.passwordHistory2896 : undefined,2897 canary: entry.canary,2898 passkey: entry.passkey,2899 createdAt: entry.createdAt,2900 modifiedAt: entry.modifiedAt,2901 deletedAt: entry.deletedAt,2902 tags: entry.tags,2903 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2904 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2905 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2906 expYear: "expYear" in entry ? entry.expYear : undefined,2907 cvc: "cvc" in entry ? entry.cvc : undefined,2908 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2909 network: "network" in entry ? entry.network : undefined,2910 last4: "last4" in entry ? entry.last4 : undefined,2911 };29122913 return base;2914}2915291629172918291929202921function entryDupeKey(e: VaultEntry): string {2922 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2923 2924 2925 2926 2927 if (e.passkey) {2928 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2929 }2930 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2931}293229332934export function countDuplicates(): number {2935 const best = new Map<string, VaultEntry>();2936 let dupeCount = 0;2937 for (const e of entries.filter((e) => !e.deletedAt)) {2938 const key = entryDupeKey(e);2939 const existing = best.get(key);2940 if (!existing) {2941 best.set(key, e);2942 } else {2943 dupeCount++;2944 2945 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2946 best.set(key, e);2947 }2948 }2949 }2950 return dupeCount;2951}295229532954295529562957export async function deduplicateEntries(): Promise<number> {2958 if (!derivedKey) throw new Error("vault_locked");2959 2960 2961 if (vaultMode === "decoy") {2962 throw new Error("deduplicate disabled in decoy mode");2963 }29642965 const best = new Map<string, VaultEntry>();2966 for (const e of entries.filter((en) => !en.deletedAt)) {2967 const key = entryDupeKey(e);2968 const existing = best.get(key);2969 if (!existing) {2970 best.set(key, e);2971 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2972 best.set(key, e);2973 }2974 }29752976 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2977 let removed = 0;2978 entries = entries.map((e) => {2979 if (!e.deletedAt && !keepIds.has(e.id)) {2980 removed++;2981 const deletedAt = monotonicEntryTimestamp(Math.max(2982 e.modifiedAt,2983 tombstones.get(e.id) ?? 0,2984 ));2985 tombstones.set(e.id, deletedAt);2986 return { ...e, deletedAt };2987 }2988 return e;2989 });29902991 if (removed > 0) await persistEntries();2992 return removed;2993}299429952996299729982999300030013002const HISTORY_KEY = "peach_vault_history";3003const HISTORY_MAX = 5;300430053006300730083009function bucketEntryCount(n: number): number {3010 if (n <= 0) return 0;3011 if (n <= 10) return 10;3012 if (n <= 25) return 25;3013 if (n <= 50) return 50;3014 if (n <= 100) return 100;3015 if (n <= 250) return 250;3016 if (n <= 500) return 500;3017 return Math.ceil(n / 500) * 500;3018}30193020302130223023302430253026export async function takeVaultSnapshot(3027 reason: VaultSnapshot["reason"],3028): Promise<void> {3029 try {3030 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3031 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3032 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];30333034 3035 3036 3037 if (!slotA) return;30383039 const vault_a = JSON.stringify(slotA);3040 const vault_b = JSON.stringify(slotB ?? slotA);30413042 3043 3044 3045 3046 3047 3048 let entryCount: number | null = null;3049 if (derivedKey && vaultMode === "real") {3050 try {3051 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3052 } catch {3053 entryCount = null;3054 }3055 }30563057 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3058 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3059 ? existingRaw3060 : { snapshots: [] };30613062 3063 3064 3065 const latest = history.snapshots[0];3066 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3067 return;3068 }30693070 const snapshot: VaultSnapshot = {3071 id: crypto.randomUUID(),3072 savedAt: Date.now(),3073 reason,3074 vault_a,3075 vault_b,3076 entryCount,3077 };30783079 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3080 await chrome.storage.local.set({ [HISTORY_KEY]: history });3081 } catch (err) {3082 3083 3084 3085 3086 console.warn('[Peach] takeVaultSnapshot failed:', err);3087 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3088 try {3089 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3090 } catch {3091 3092 }3093 }3094 }3095}309630973098export async function clearVaultHistory(): Promise<void> {3099 try {3100 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3101 } catch {3102 3103 }3104}310531063107export async function getVaultHistory(): Promise<VaultSnapshot[]> {3108 const raw = await chrome.storage.local.get(HISTORY_KEY);3109 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3110 if (!history || !Array.isArray(history.snapshots)) return [];3111 return history.snapshots;3112}31133114311531163117311831193120export async function restoreVaultSnapshot(3121 snapshotId: string,3122): Promise<{ ok: true } | { ok: false; error: string }> {3123 const history = await getVaultHistory();3124 const snapshot = history.find((s) => s.id === snapshotId);3125 if (!snapshot) return { ok: false, error: "snapshot_not_found" };31263127 3128 3129 3130 await takeVaultSnapshot("restore");31313132 let restoredA: StoredVault;3133 let restoredB: StoredVault;3134 try {3135 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3136 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3137 } catch {3138 return { ok: false, error: "snapshot_corrupt" };3139 }31403141 await writeSlot(SLOT_A, restoredA);3142 await writeSlot(SLOT_B, restoredB);3143 await stampLocalVaultMutation();31443145 3146 3147 3148 await lock();31493150 return { ok: true };3151}315231533154315531563157315831593160316131623163316431653166export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3167 if (!derivedKey || vaultMode !== "real") {3168 return { ok: false, error: "vault_locked" };3169 }3170 const slotA = await loadSlot(SLOT_A);3171 if (!slotA) return { ok: false, error: "missing_slot_a" };31723173 3174 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3175 await generateAndWrapRootKey();31763177 3178 const newRootKey = unhex(rawRootKey);3179 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);31803181 3182 3183 3184 3185 await takeVaultSnapshot("local_save");3186 const nextSlotA: StoredVault = {3187 ...slotA,3188 recovery_salt: recoverySalt,3189 recovery_wrapped_root_key: wrappedRootKey,3190 recovery_nonce: wrappedNonce,3191 recovery_tag: wrappedTag,3192 ...pwRoot,3193 };3194 await writeSlot(SLOT_A, nextSlotA);31953196 3197 if (rootKey) zeroKey(rootKey);3198 rootKey = newRootKey;31993200 3201 await clearPairedDevices();32023203 3204 await setRecoveryConfirmPending(); 32053206 await stampLocalVaultMutation();3207 return { ok: true };3208}32093210321132123213321432153216export async function changePassword(3217 currentPassword: string,3218 newPassword: string3219): Promise<boolean> {3220 if (!derivedKey || vaultMode !== "real") return false;32213222 3223 const valid = await verifyMainPassword(currentPassword);3224 if (!valid) return false;32253226 const slotA = await loadSlot(SLOT_A);3227 if (!slotA) return false;3228 if (!rootKey) return false;32293230 3231 const newSalt = generateSalt();3232 const newKey = await deriveKey(newPassword, newSalt);32333234 3235 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3236 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");32373238 3239 3240 3241 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);32423243 const finalSlotA: StoredVault = {3244 ...slotA,3245 salt: hex(newSalt),3246 nonce,3247 ciphertext,3248 tag,3249 ...pwRoot,3250 };32513252 3253 3254 3255 await takeVaultSnapshot("local_save");32563257 3258 3259 3260 3261 await writeSlot(SLOT_A, finalSlotA);32623263 3264 3265 3266 zeroKey(derivedKey);3267 derivedKey = newKey;32683269 3270 3271 3272 3273 void saveSessionKey(newKey, "real");32743275 3276 3277 3278 if (!duressConfigured) {3279 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3280 }3281 await clearPinUnlockStorage();3282 await stampLocalVaultMutation();32833284 return true;3285}