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";15081509async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1510 if (!chrome.storage.session) return;1511 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1512}15131514async function clearSessionKey(): Promise<void> {1515 if (!chrome.storage.session) return;1516 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1517}15181519152015211522152315241525let vaultEpoch = 0;1526152715281529let restoreInFlight: Promise<boolean> | null = null;153015311532153315341535153615371538export async function restoreFromSession(): Promise<boolean> {1539 if (derivedKey !== null) return true;1540 if (restoreInFlight) return restoreInFlight;1541 const task = restoreFromSessionInner();1542 restoreInFlight = task;1543 try {1544 return await task;1545 } finally {1546 restoreInFlight = null;1547 }1548}15491550async function restoreFromSessionInner(): Promise<boolean> {1551 const epoch = vaultEpoch;1552 const stale = () => epoch !== vaultEpoch;1553 if (derivedKey !== null) return true;1554 if (!chrome.storage.session) return false;1555 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1556 if (stale()) return false;1557 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1558 if (!entry?.keyHex) return false;15591560 const key = unhex(entry.keyHex);1561 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1562 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1563 const slot = await loadSlot(slotKey);1564 if (stale()) { zeroKey(key); return false; }1565 if (!slot) { await clearSessionKey(); return false; }15661567 const result = await tryDecryptSlotResult(slot, key, aadTag);1568 if (stale()) { zeroKey(key); return false; }1569 if (result.status !== "ok") { await clearSessionKey(); return false; }15701571 1572 1573 const payload = result.payload;1574 await loadSettings();1575 if (stale()) { zeroKey(key); return false; }15761577 1578 1579 let freshRoot: Uint8Array | null = null;1580 if (entry.mode === "real") {1581 freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1582 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1583 if (!freshRoot) {1584 await lock();1585 return false;1586 }1587 }15881589 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1590 derivedKey = key;1591 vaultMode = entry.mode;1592 entries = payload.entries;1593 tombstones = payload.tombstones;1594 duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1595 if (freshRoot) rootKey = freshRoot;1596 buildDomainIndexSync(entries);1597 touchAutoLock();15981599 await lockedSyncUnlockHook?.().catch((error) => {1600 console.warn("[Peach sync] locked inbox drain failed:", error);1601 });1602 if (stale()) return false;16031604 return true;1605}16061607export async function lock(): Promise<void> {1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 vaultEpoch++;1618 if (derivedKey) {1619 zeroKey(derivedKey);1620 derivedKey = null;1621 }1622 if (rootKey) {1623 rootKey.fill(0);1624 rootKey = null;1625 }16261627 entries = [];1628 tombstones = new Map();1629 domainIndex = new Map();1630 vaultMode = "real";1631 duressConfigured = false;1632 clearAutoLock();1633 await clearSessionKey();1634 await clearRecoveryConfirmPending();1635}16361637export function isUnlocked(): boolean {1638 return derivedKey !== null;1639}16401641164216431644export async function unlockWithKey(1645 masterKeyHex: string,1646): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1647 try {1648 const masterKeyBytes = unhex(masterKeyHex);1649 const stored = await loadSlot(SLOT_A);1650 if (!stored) return { ok: false, error: "invalid_key" };1651 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1652 if (!payload) return { ok: false, error: "invalid_key" };16531654 derivedKey = masterKeyBytes;1655 vaultMode = "real";1656 entries = payload.entries;1657 tombstones = payload.tombstones;1658 duressConfigured = payload.duressConfigured;1659 buildDomainIndexSync(entries);1660 await loadSettings();1661 touchAutoLock();16621663 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1664 if (!rootKey) {1665 await lock();1666 return { ok: false, error: "invalid_key" };1667 }1668 return { ok: true, entries };1669 } catch {1670 return { ok: false, error: "invalid_key" };1671 }1672}167316741675167616771678167916801681export async function exportDuressSlotForAdoption(): Promise<string | null> {1682 if (!(await isDuressEnabled())) return null;1683 const slotB = await loadSlot(SLOT_B);1684 if (!slotB) return null;1685 return JSON.stringify(slotB);1686}16871688168916901691169216931694export async function installAdoptedDuressSlot(json: string): Promise<void> {1695 let parsed: StoredVault;1696 try {1697 const raw = JSON.parse(json) as unknown;1698 if (!hasValidVaultSchema(raw)) {1699 throw new Error("invalid_duress_slot_shape");1700 }1701 parsed = raw;1702 } catch (err) {1703 if (err instanceof SyntaxError) {1704 throw new Error("invalid_duress_slot_shape");1705 }1706 throw err;1707 }1708 await writeSlot(SLOT_B, parsed);1709}17101711171217131714171517161717171817191720172117221723172417251726172717281729export async function unlockFromCodex(params: {1730 slotA: string; 1731 slotB: string | null; 1732 timestamp: number;1733 masterPassphrase: string;1734}): Promise<1735 | { ok: true; entryCount: number }1736 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1737> {1738 let parsedSlotA: StoredVault;1739 let parsedSlotB: StoredVault | null = null;1740 try {1741 const rawA = JSON.parse(params.slotA) as unknown;1742 if (!hasValidVaultSchema(rawA)) {1743 return { ok: false, reason: "invalid_slot_format" };1744 }1745 parsedSlotA = rawA;1746 if (params.slotB !== null) {1747 const rawB = JSON.parse(params.slotB) as unknown;1748 if (!hasValidVaultSchema(rawB)) {1749 return { ok: false, reason: "invalid_slot_format" };1750 }1751 parsedSlotB = rawB;1752 }1753 } catch {1754 return { ok: false, reason: "invalid_slot_format" };1755 }17561757 1758 1759 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1760 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1761 if (!trialPayload) {1762 zeroKey(trialKey);1763 return { ok: false, reason: "wrong_passphrase" };1764 }1765 1766 1767 zeroKey(trialKey);17681769 1770 1771 await takeVaultSnapshot("restore");17721773 await writeSlot(SLOT_A, parsedSlotA);1774 if (parsedSlotB) {1775 await writeSlot(SLOT_B, parsedSlotB);1776 } else {1777 1778 1779 1780 await writeSlot(1781 SLOT_B,1782 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1783 );1784 }1785 await chrome.storage.local.set({1786 peach_last_modified_at: String(params.timestamp),1787 });17881789 1790 1791 const ok = await unlock(params.masterPassphrase);1792 if (!ok) {1793 1794 1795 1796 return { ok: false, reason: "wrong_passphrase" };1797 }1798 return { ok: true, entryCount: getEntries().length };1799}18001801180218031804export function getDerivedKeyHex(): string {1805 if (!derivedKey) throw new Error("vault_not_unlocked");1806 return hex(derivedKey);1807}18081809181018111812181318141815181618171818181918201821182218231824export function getRootKeyHex(): string | null {1825 return rootKey ? hex(rootKey) : null;1826}18271828export function getEntries(): VaultEntry[] {1829 if (!derivedKey) throw new Error("vault_locked");1830 return entries.filter((entry) => !entry.deletedAt);1831}18321833const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;18341835export function getIdentities(): SavedIdentity[] {1836 if (!derivedKey) throw new Error("vault_locked");1837 const active = entries.filter((e) => !e.deletedAt && e.type === "login");18381839 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1840 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();18411842 for (const entry of active) {1843 const domain = extractDomain(entry.url ?? "") ?? "";1844 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;18451846 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1847 const existing = map.get(v);1848 if (!existing || modifiedAt > existing.lastUsed) {1849 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1850 } else {1851 map.set(v, { ...existing, count: existing.count + 1 });1852 }1853 }18541855 const uVal = entry.username?.trim();1856 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);18571858 1859 1860 const eVal = entry.email?.trim();1861 if (eVal) tally(emailMap, eVal);1862 }18631864 const toIdentity = (type: "email" | "username") =>1865 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1866 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1867 });18681869 return [1870 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1871 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1872 ];1873}18741875export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1876 if (!derivedKey) throw new Error("vault_locked");18771878 const now = Date.now();1879 const added: VaultEntry[] = [];1880 for (const draft of drafts) {1881 try {1882 const sanitized = sanitizeEntryDraft(draft);1883 const nextEntry: VaultEntry = {1884 ...sanitized,1885 id: crypto.randomUUID(),1886 createdAt: now,1887 modifiedAt: monotonicEntryTimestamp(),1888 };1889 entries.push(nextEntry);1890 added.push(nextEntry);1891 } catch {1892 1893 }1894 }18951896 if (added.length > 0) {1897 await persistEntries();1898 }18991900 return added;1901}190219031904190519061907190819091910export async function restoreEntries(1911 incoming: VaultEntry[],1912 mode: "replace" | "merge"1913): Promise<{ added: number; updated: number; skipped: number }> {1914 if (!derivedKey) throw new Error("vault_locked");19151916 1917 1918 1919 if (mode === "replace" && incoming.length === 0) {1920 throw new Error("refuse to replace with empty entry set");1921 }19221923 let added = 0;1924 let updated = 0;1925 let skipped = 0;19261927 if (mode === "replace") {1928 entries = [];1929 for (const entry of incoming) {1930 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1931 if (!normalized) {1932 skipped += 1;1933 continue;1934 }1935 entries.push(normalized);1936 if (normalized.deletedAt) {1937 tombstones.set(1938 normalized.id,1939 Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),1940 );1941 } else {1942 tombstones.delete(normalized.id);1943 }1944 added += 1;1945 }1946 } else {1947 const byId = new Map<string, VaultEntry>();1948 for (const e of entries) byId.set(e.id, e);19491950 for (const entry of incoming) {1951 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1952 if (!normalized) {1953 skipped += 1;1954 continue;1955 }1956 const existing = byId.get(normalized.id);1957 if (!existing) {1958 entries.push(normalized);1959 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1960 else tombstones.delete(normalized.id);1961 added += 1;1962 } else if (normalized.modifiedAt > existing.modifiedAt) {1963 const idx = entries.findIndex((e) => e.id === existing.id);1964 if (idx !== -1) {1965 entries[idx] = normalized;1966 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1967 else tombstones.delete(normalized.id);1968 updated += 1;1969 }1970 } else {1971 skipped += 1;1972 }1973 }1974 }19751976 await persistEntries("import");1977 return { added, updated, skipped };1978}197919801981198219831984198519861987198819891990199119921993199419951996const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;19971998function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {1999 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2000 return { ...entry, id: crypto.randomUUID() };2001}200220032004200520062007200820092010function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2011 if (!entry || typeof entry !== "object") return null;2012 const title = typeof entry.title === "string" ? entry.title.trim() : "";2013 if (!title) return null;2014 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2015 const now = Date.now();2016 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2017 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;20182019 2020 2021 const normalized = normalizeEntry({2022 ...entry,2023 id,2024 title,2025 type: entry.type ?? "login",2026 createdAt,2027 modifiedAt,2028 });20292030 if (normalized.passkey) {2031 normalized.passkey = sanitizePasskey(normalized.passkey);2032 }2033 if (normalized.canary) {2034 normalized.canary = sanitizeCanary(normalized.canary);2035 }2036 2037 normalized.tags = sanitizeTags(normalized.tags);20382039 2040 2041 if (2042 normalized.type === "login" &&2043 !normalized.passkey &&2044 typeof normalized.password !== "string"2045 ) {2046 return null;2047 }2048 if (entry.passkey && !normalized.passkey) {2049 2050 2051 return null;2052 }20532054 return normalized;2055}2056205720582059206020612062function sanitizePasskey(pk: unknown): PasskeyData | undefined {2063 if (!pk || typeof pk !== "object") return undefined;2064 const r = pk as Record<string, unknown>;2065 if (2066 typeof r.rpId !== "string" || !r.rpId ||2067 typeof r.credentialId !== "string" || !r.credentialId ||2068 typeof r.publicKeyCose !== "string" ||2069 typeof r.userHandle !== "string" ||2070 typeof r.userName !== "string"2071 ) {2072 return undefined;2073 }2074 return {2075 rpId: r.rpId,2076 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2077 credentialId: r.credentialId,2078 publicKeyCose: r.publicKeyCose,2079 privateKeyJwk:2080 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2081 ? (r.privateKeyJwk as JsonWebKey)2082 : undefined,2083 userHandle: r.userHandle,2084 userName: r.userName,2085 userDisplayName:2086 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2087 algorithm: r.algorithm === -8 ? -8 : -7,2088 signCount: typeof r.signCount === "number" ? r.signCount : 0,2089 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2090 backupEligible: r.backupEligible === true,2091 backupState: r.backupState === true,2092 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2093 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2094 transports: Array.isArray(r.transports)2095 ? r.transports.filter((t): t is string => typeof t === "string")2096 : undefined,2097 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2098 };2099}21002101210221032104210521062107210821092110function sanitizeCanary(c: unknown): Canary | undefined {2111 if (!c || typeof c !== "object") return undefined;2112 const r = c as Record<string, unknown>;2113 if (typeof r.webhookUrl !== "string") return undefined;2114 let url: URL;2115 try {2116 url = new URL(r.webhookUrl);2117 } catch {2118 return undefined;2119 }2120 if (url.protocol !== "https:") return undefined;2121 return {2122 webhookUrl: url.toString(),2123 label: typeof r.label === "string" ? r.label : undefined,2124 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2125 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2126 };2127}21282129213021312132213321342135function sanitizeTags(tags: unknown): string[] | undefined {2136 if (!Array.isArray(tags)) return undefined;2137 const out: string[] = [];2138 for (const t of tags) {2139 if (typeof t !== "string") continue;2140 const trimmed = t.trim();2141 if (!trimmed) continue;2142 out.push(trimmed.slice(0, 256));2143 if (out.length >= 255) break;2144 }2145 return out.length > 0 ? out : undefined;2146}214721482149215021512152export async function clearVaultEntries(2153 confirmation: "REPLACE_ALL"2154): Promise<void> {2155 if (confirmation !== "REPLACE_ALL") {2156 throw new Error("clearVaultEntries requires explicit confirmation");2157 }2158 if (!derivedKey) throw new Error("vault_locked");2159 for (const entry of entries) {2160 if (!entry.deletedAt) {2161 const stamp = monotonicEntryTimestamp(Math.max(2162 entry.modifiedAt,2163 tombstones.get(entry.id) ?? 0,2164 ));2165 entry.deletedAt = stamp;2166 entry.modifiedAt = stamp;2167 tombstones.set(entry.id, stamp);2168 }2169 }2170 await persistEntries();2171}21722173export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2174 if (!derivedKey) throw new Error("vault_locked");21752176 const sanitized = sanitizeEntryDraft(entry);2177 const now = Date.now();2178 const stamp = monotonicEntryTimestamp();2179 const nextEntry: VaultEntry = {2180 ...sanitized,2181 id: crypto.randomUUID(),2182 createdAt: now,2183 modifiedAt: stamp,2184 2185 2186 passwordChangedAt: sanitized.password ? stamp : undefined,2187 };21882189 2190 2191 2192 const rollback = entries;2193 entries = [...entries, nextEntry];2194 try {2195 await persistEntries();2196 } catch (err) {2197 entries = rollback;2198 throw err;2199 }22002201 2202 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2203 checkBreach(nextEntry.password).then(async (count) => {2204 if (count > 0) {2205 const idx = entries.findIndex((e) => e.id === nextEntry.id);2206 if (idx !== -1) {2207 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2208 await persistEntries();2209 }2210 }2211 });2212 }22132214 fireSyncTrigger("vault_change");22152216 return nextEntry;2217}22182219222022212222222322242225export async function consolidatePasskeyEntries(): Promise<void> {2226 if (!derivedKey) return;2227 const { merged } = mergePasskeyOnlyIntoLogins(entries);2228 if (merged) {2229 await persistEntries();2230 }2231}22322233export async function updateEntry(2234 id: string,2235 patch: Partial<VaultEntry>2236): Promise<void> {2237 if (!derivedKey) throw new Error("vault_locked");22382239 const idx = entries.findIndex((entry) => entry.id === id);2240 if (idx === -1 || entries[idx].deletedAt) {2241 throw new Error("entry_not_found");2242 }22432244 const current = entries[idx];2245 const sanitizedPatch = sanitizeEntryPatch(patch);2246 2247 2248 2249 const passwordChanged =2250 "password" in patch &&2251 typeof sanitizedPatch.password === "string" &&2252 sanitizedPatch.password !== current.password;2253 const stamp = monotonicEntryTimestamp(current.modifiedAt);2254 2255 2256 2257 2258 2259 2260 2261 const priorPassword = current.password;2262 const shouldCapture =2263 passwordChanged &&2264 typeof priorPassword === "string" &&2265 priorPassword.length > 0 &&2266 current.passwordHistory?.[0]?.password !== priorPassword;2267 const nextHistory = shouldCapture2268 ? [2269 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2270 ...(current.passwordHistory ?? []),2271 ].slice(0, PASSWORD_HISTORY_MAX)2272 : current.passwordHistory;2273 entries[idx] = {2274 ...current,2275 ...sanitizedPatch,2276 modifiedAt: stamp,2277 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2278 };2279228022812282 2283 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2284 entries[idx] = current;2285 throw new Error("card number is required for card entries");2286 }22872288 2289 2290 2291 2292 try {2293 await persistEntries();2294 } catch (err) {2295 entries[idx] = current;2296 throw err;2297 }22982299 2300 2301 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2302 entries[idx] = { ...entries[idx], breach: undefined };2303 await persistEntries();2304 const password = entries[idx].password;2305 if (password) {2306 checkBreach(password).then(async (count) => {2307 const i = entries.findIndex((e) => e.id === id);2308 if (i !== -1 && count > 0) {2309 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2310 await persistEntries();2311 }2312 });2313 }2314 }23152316 fireSyncTrigger("vault_change");2317}23182319export async function deleteEntry(id: string): Promise<void> {2320 if (!derivedKey) throw new Error("vault_locked");23212322 const entry = entries.find((item) => item.id === id);2323 if (!entry || entry.deletedAt) {2324 throw new Error("entry_not_found");2325 }23262327 2328 2329 2330 const previousModifiedAt = entry.modifiedAt;2331 const previousTombstone = tombstones.get(id);2332 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2333 entry.deletedAt = stamp;2334 entry.modifiedAt = stamp;2335 tombstones.set(id, stamp);2336 2337 2338 try {2339 await persistEntries();2340 } catch (err) {2341 delete entry.deletedAt;2342 entry.modifiedAt = previousModifiedAt;2343 if (previousTombstone === undefined) tombstones.delete(id);2344 else tombstones.set(id, previousTombstone);2345 throw err;2346 }2347 fireSyncTrigger("vault_change");2348}23492350export function getDeletedEntries(): VaultEntry[] {2351 if (!derivedKey) return [];2352 return entries.filter((e) => !!e.deletedAt);2353}235423552356export function getTombstoneManifest(): Record<string, number> {2357 if (!derivedKey) return {};2358 return Object.fromEntries(tombstones);2359}2360236123622363236423652366export async function getVaultVersion(): Promise<number> {2367 try {2368 const raw = await chrome.storage.local.get("peach_last_modified_at");2369 const v = (raw as Record<string, unknown>).peach_last_modified_at;2370 if (typeof v === "number") return v;2371 if (typeof v === "string" && v !== "") {2372 const n = Number.parseInt(v, 10);2373 return Number.isFinite(n) ? n : 0;2374 }2375 return 0;2376 } catch {2377 return 0;2378 }2379}2380238123822383238423852386238723882389239023912392export async function applyMergedRemoteEntries(2393 remoteEntries: VaultEntry[],2394 remoteTombstones: Record<string, number>,2395 _context: { deviceName?: string } = {},2396): Promise<{ received: number; applied: number; conflicts: number }> {2397 2398 2399 2400 2401 2402 2403 if (!derivedKey) throw new Error("vault_locked");24042405 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2406 let applied = 0;2407 let conflicts = 0;24082409 const byId = new Map<string, VaultEntry>();2410 for (const e of entries) byId.set(e.id, e);24112412 const mergeNow = Date.now();2413 const remoteTombstoneTimes = new Map<string, number>();2414 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2415 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2416 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2417 }2418 }24192420 for (const remote of remoteEntries) {2421 const sanitized = sanitizeRestoredEntry(remote);2422 if (!sanitized) continue;2423 2424 2425 2426 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2427 if (typeof sanitized.deletedAt === "number") {2428 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2429 }2430 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2431 const localTombstoneTs = tombstones.get(sanitized.id);2432 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2433 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2434 continue;2435 }2436 const local = byId.get(sanitized.id);2437 if (!local) {2438 entries.push(sanitized);2439 byId.set(sanitized.id, sanitized);2440 tombstones.delete(sanitized.id);2441 applied += 1;2442 } else if (sanitized.modifiedAt > local.modifiedAt) {2443 const idx = entries.findIndex((e) => e.id === local.id);2444 if (idx !== -1) {2445 entries[idx] = sanitized;2446 byId.set(sanitized.id, sanitized);2447 tombstones.delete(sanitized.id);2448 applied += 1;2449 if (!local.deletedAt) conflicts += 1;2450 }2451 }2452 }24532454 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2455 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2456 2457 const deletedAt = remoteTombstoneTimes.get(id)!;2458 const knownTombstone = tombstones.get(id);2459 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2460 const local = byId.get(id);2461 if (!local) {2462 tombstones.set(id, deletedAt);2463 applied += 1;2464 continue;2465 }2466 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2467 if (deletedAt < local.modifiedAt) continue;2468 const idx = entries.findIndex((e) => e.id === id);2469 if (idx !== -1) {2470 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2471 tombstones.set(id, deletedAt);2472 applied += 1;2473 }2474 }24752476 if (applied > 0) {2477 await persistEntries("sync_received");2478 buildDomainIndexSync(entries);2479 }2480 return { received, applied, conflicts };2481}24822483export async function restoreEntry(id: string): Promise<void> {2484 if (!derivedKey) throw new Error("vault_locked");2485 const entry = entries.find((e) => e.id === id && e.deletedAt);2486 if (!entry) throw new Error("deleted entry not found");2487 const previousDeletedAt = entry.deletedAt;2488 const previousModifiedAt = entry.modifiedAt;2489 const previousTombstone = tombstones.get(id);2490 delete entry.deletedAt;2491 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2492 entry.modifiedAt,2493 previousTombstone ?? 0,2494 ));2495 tombstones.delete(id);2496 try {2497 await persistEntries();2498 } catch (err) {2499 entry.deletedAt = previousDeletedAt;2500 entry.modifiedAt = previousModifiedAt;2501 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2502 throw err;2503 }2504 buildDomainIndexSync(entries);2505}25062507export async function purgeEntry(id: string): Promise<void> {2508 if (!derivedKey) throw new Error("vault_locked");2509 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2510 if (idx === -1) throw new Error("deleted entry not found");2511 const entry = entries[idx]!;2512 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2513 entries.splice(idx, 1);2514 await persistEntries();2515}251625172518export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2519 if (!derivedKey || retentionMs === 0) return 0;2520 const cutoff = Date.now() - retentionMs;2521 const before = entries.length;2522 for (const entry of entries) {2523 if (entry.deletedAt && entry.deletedAt < cutoff) {2524 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2525 }2526 }2527 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2528 const purged = before - entries.length;2529 if (purged > 0) await persistEntries();2530 return purged;2531}25322533export function queryDomain(domain: string): VaultEntry[] {2534 return domainIndex.get(domain) ?? [];2535}25362537export function getEntryById(id: string): VaultEntry | undefined {2538 return entries.find((entry) => entry.id === id && !entry.deletedAt);2539}25402541async function persistSlotA(2542 reason: VaultSnapshot["reason"] = "local_save",2543): Promise<StoredVault> {2544 if (!derivedKey) throw new Error("vault_locked");25452546 const slotA = await loadSlot(SLOT_A);2547 if (!slotA) throw new Error("missing slot A");25482549 2550 2551 await takeVaultSnapshot(reason);25522553 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2554 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");25552556 2557 const nextSlotA: StoredVault = {2558 ...slotA,2559 nonce,2560 ciphertext,2561 tag,2562 2563 };2564 await writeSlot(SLOT_A, nextSlotA);25652566 2567 2568 2569 2570 2571 2572 try {2573 2574 2575 2576 if (!duressConfigured) {2577 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2578 }2579 2580 2581 2582 2583 2584 if (reason !== "sync_received") {2585 await stampLocalVaultMutation();2586 }2587 } catch (err) {2588 2589 2590 await writeSlot(SLOT_A, slotA).catch(() => {});2591 throw err;2592 }25932594 return nextSlotA;2595}25962597async function persistSlotB(): Promise<void> {2598 if (!derivedKey) throw new Error("vault_locked");25992600 const slotB = await loadSlot(SLOT_B);2601 if (!slotB) throw new Error("missing slot B");26022603 2604 2605 2606 2607 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2608 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");26092610 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2611 2612 2613 2614 try {2615 await stampLocalVaultMutation();2616 } catch (err) {2617 await writeSlot(SLOT_B, slotB).catch(() => {});2618 throw err;2619 }2620}26212622async function persistEntries(2623 reason: VaultSnapshot["reason"] = "local_save",2624): Promise<void> {2625 if (!derivedKey) throw new Error("vault_locked");26262627 2628 2629 const previousTombstones = tombstones;2630 tombstones = new Map(tombstones);2631 reconcileTombstones();2632 try {2633 if (vaultMode === "decoy") {2634 await persistSlotB();2635 } else {2636 await persistSlotA(reason);2637 }2638 } catch (err) {2639 tombstones = previousTombstones;2640 throw err;2641 }2642 buildDomainIndexSync(entries);2643 await refreshLockedSyncState();2644}2645264626472648264926502651function allowHttpUrl(url: string | undefined): string {2652 const trimmed = url?.trim() ?? "";2653 if (!trimmed) return "";2654 try {2655 const parsed = new URL(trimmed);2656 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2657 } catch {2658 2659 }2660 return trimmed;2661}26622663function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2664 if (!Array.isArray(values)) return undefined;2665 const seen = new Set<string>();2666 const cleaned: string[] = [];2667 for (const value of values) {2668 const trimmed = typeof value === "string" ? value.trim() : "";2669 if (!trimmed) continue;2670 if (!allowHttpUrl(trimmed)) continue;2671 const key = trimmed.toLowerCase();2672 if (seen.has(key)) continue;2673 seen.add(key);2674 cleaned.push(trimmed);2675 }2676 return cleaned.length > 0 ? cleaned : undefined;2677}26782679function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2680 const title = entry.title.trim();2681 const type: VaultEntry["type"] = entry.type ?? "login";26822683 if (!title) throw new Error("title is required");26842685 if (entry.passkey) {2686 const p = entry.passkey;2687 if (2688 !p.rpId ||2689 !p.credentialId ||2690 !p.publicKeyCose ||2691 !p.userHandle ||2692 !p.userName ||2693 (p.algorithm !== -7 && p.algorithm !== -8)2694 ) {2695 throw new Error("invalid passkey credential");2696 }2697 }2698 if (type === "card") {2699 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2700 throw new Error("cardholder name and card number are required");2701 }2702 }27032704 return {2705 type,2706 title,2707 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2708 email: type === "login" ? entry.email?.trim() || undefined : undefined,2709 password: type === "login" ? entry.password : undefined,2710 url: type === "login" ? allowHttpUrl(entry.url) : undefined,2711 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2712 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2713 2714 2715 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2716 totpDigits: type === "login" ? entry.totpDigits : undefined,2717 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2718 notes: entry.notes?.trim() || undefined,2719 breach: type === "login" ? entry.breach : undefined,2720 2721 2722 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2723 tags: sanitizeTags(entry.tags),2724 passkey: entry.passkey,2725 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2726 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2727 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2728 expYear: type === "card" ? entry.expYear?.trim() : undefined,2729 cvc: type === "card" ? entry.cvc?.trim() : undefined,2730 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2731 network: type === "card" ? entry.network : undefined,2732 last4: type === "card" ? entry.last4?.trim() : undefined,2733 };2734}27352736function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2737 const nextPatch: Partial<VaultEntry> = {};27382739 if ("type" in patch) {2740 nextPatch.type = patch.type;2741 }27422743 if ("title" in patch) {2744 const title = patch.title?.trim() ?? "";2745 if (!title) throw new Error("title is required");2746 nextPatch.title = title;2747 }27482749 if ("url" in patch) {2750 nextPatch.url = allowHttpUrl(patch.url);2751 }27522753 if ("additionalUrls" in patch) {2754 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2755 const seen = new Set<string>();2756 const cleaned: string[] = [];2757 for (const value of incoming) {2758 const trimmed = typeof value === "string" ? value.trim() : "";2759 if (!trimmed || !allowHttpUrl(trimmed)) continue;2760 const key = trimmed.toLowerCase();2761 if (seen.has(key)) continue;2762 seen.add(key);2763 cleaned.push(trimmed);2764 }2765 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2766 }27672768 if ("username" in patch) {2769 nextPatch.username = patch.username?.trim() ?? "";2770 }27712772 if ("email" in patch) {2773 nextPatch.email = patch.email?.trim() || undefined;2774 }27752776 if ("password" in patch) {2777 const password = patch.password ?? "";2778 2779 nextPatch.password = password || undefined;2780 }27812782 if ("totp" in patch) {2783 nextPatch.totp = patch.totp?.trim() || undefined;2784 }27852786 if ("notes" in patch) {2787 nextPatch.notes = patch.notes?.trim() || undefined;2788 }27892790 if ("breach" in patch) {2791 nextPatch.breach = patch.breach;2792 }27932794 if ("canary" in patch) {2795 2796 2797 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2798 }27992800 if ("passkey" in patch) {2801 2802 2803 if (patch.passkey === undefined || patch.passkey === null) {2804 nextPatch.passkey = undefined;2805 } else {2806 const sanitized = sanitizePasskey(patch.passkey);2807 if (!sanitized) throw new Error("invalid passkey credential");2808 nextPatch.passkey = sanitized;2809 }2810 }28112812 if ("tags" in patch) {2813 2814 nextPatch.tags = sanitizeTags(patch.tags);2815 }28162817 if ("cardholderName" in patch) {2818 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2819 }2820 if ("cardNumber" in patch) {2821 const n = patch.cardNumber ?? "";2822 nextPatch.cardNumber = n || undefined;2823 }2824 if ("expMonth" in patch) {2825 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2826 }2827 if ("expYear" in patch) {2828 nextPatch.expYear = patch.expYear?.trim() || undefined;2829 }2830 if ("cvc" in patch) {2831 nextPatch.cvc = patch.cvc?.trim() || undefined;2832 }2833 if ("cardNickname" in patch) {2834 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2835 }2836 if ("network" in patch) {2837 nextPatch.network = patch.network;2838 }2839 if ("last4" in patch) {2840 nextPatch.last4 = patch.last4?.trim() || undefined;2841 }28422843 return nextPatch;2844}28452846function normalizeEntry(entry: VaultEntry): VaultEntry {2847 const base: VaultEntry = {2848 id: entry.id,2849 type: entry.type,2850 title: entry.title,2851 username: entry.username,2852 email: entry.email,2853 password: entry.password,2854 url: entry.url,2855 additionalUrls:2856 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2857 ? entry.additionalUrls2858 : undefined,2859 totp: entry.totp,2860 2861 totpAlgorithm: entry.totpAlgorithm,2862 totpDigits: entry.totpDigits,2863 totpPeriod: entry.totpPeriod,2864 notes: entry.notes,2865 breach: entry.breach,2866 2867 2868 2869 passwordChangedAt:2870 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2871 2872 2873 2874 passwordHistory:2875 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2876 ? entry.passwordHistory2877 : undefined,2878 canary: entry.canary,2879 passkey: entry.passkey,2880 createdAt: entry.createdAt,2881 modifiedAt: entry.modifiedAt,2882 deletedAt: entry.deletedAt,2883 tags: entry.tags,2884 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2885 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2886 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2887 expYear: "expYear" in entry ? entry.expYear : undefined,2888 cvc: "cvc" in entry ? entry.cvc : undefined,2889 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2890 network: "network" in entry ? entry.network : undefined,2891 last4: "last4" in entry ? entry.last4 : undefined,2892 };28932894 return base;2895}2896289728982899290029012902function entryDupeKey(e: VaultEntry): string {2903 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2904 2905 2906 2907 2908 if (e.passkey) {2909 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2910 }2911 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2912}291329142915export function countDuplicates(): number {2916 const best = new Map<string, VaultEntry>();2917 let dupeCount = 0;2918 for (const e of entries.filter((e) => !e.deletedAt)) {2919 const key = entryDupeKey(e);2920 const existing = best.get(key);2921 if (!existing) {2922 best.set(key, e);2923 } else {2924 dupeCount++;2925 2926 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2927 best.set(key, e);2928 }2929 }2930 }2931 return dupeCount;2932}293329342935293629372938export async function deduplicateEntries(): Promise<number> {2939 if (!derivedKey) throw new Error("vault_locked");2940 2941 2942 if (vaultMode === "decoy") {2943 throw new Error("deduplicate disabled in decoy mode");2944 }29452946 const best = new Map<string, VaultEntry>();2947 for (const e of entries.filter((en) => !en.deletedAt)) {2948 const key = entryDupeKey(e);2949 const existing = best.get(key);2950 if (!existing) {2951 best.set(key, e);2952 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2953 best.set(key, e);2954 }2955 }29562957 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2958 let removed = 0;2959 entries = entries.map((e) => {2960 if (!e.deletedAt && !keepIds.has(e.id)) {2961 removed++;2962 const deletedAt = monotonicEntryTimestamp(Math.max(2963 e.modifiedAt,2964 tombstones.get(e.id) ?? 0,2965 ));2966 tombstones.set(e.id, deletedAt);2967 return { ...e, deletedAt };2968 }2969 return e;2970 });29712972 if (removed > 0) await persistEntries();2973 return removed;2974}297529762977297829792980298129822983const HISTORY_KEY = "peach_vault_history";2984const HISTORY_MAX = 5;298529862987298829892990function bucketEntryCount(n: number): number {2991 if (n <= 0) return 0;2992 if (n <= 10) return 10;2993 if (n <= 25) return 25;2994 if (n <= 50) return 50;2995 if (n <= 100) return 100;2996 if (n <= 250) return 250;2997 if (n <= 500) return 500;2998 return Math.ceil(n / 500) * 500;2999}30003001300230033004300530063007export async function takeVaultSnapshot(3008 reason: VaultSnapshot["reason"],3009): Promise<void> {3010 try {3011 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3012 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3013 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];30143015 3016 3017 3018 if (!slotA) return;30193020 const vault_a = JSON.stringify(slotA);3021 const vault_b = JSON.stringify(slotB ?? slotA);30223023 3024 3025 3026 3027 3028 3029 let entryCount: number | null = null;3030 if (derivedKey && vaultMode === "real") {3031 try {3032 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3033 } catch {3034 entryCount = null;3035 }3036 }30373038 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3039 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3040 ? existingRaw3041 : { snapshots: [] };30423043 3044 3045 3046 const latest = history.snapshots[0];3047 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3048 return;3049 }30503051 const snapshot: VaultSnapshot = {3052 id: crypto.randomUUID(),3053 savedAt: Date.now(),3054 reason,3055 vault_a,3056 vault_b,3057 entryCount,3058 };30593060 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3061 await chrome.storage.local.set({ [HISTORY_KEY]: history });3062 } catch (err) {3063 3064 3065 3066 3067 console.warn('[Peach] takeVaultSnapshot failed:', err);3068 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3069 try {3070 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3071 } catch {3072 3073 }3074 }3075 }3076}307730783079export async function clearVaultHistory(): Promise<void> {3080 try {3081 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3082 } catch {3083 3084 }3085}308630873088export async function getVaultHistory(): Promise<VaultSnapshot[]> {3089 const raw = await chrome.storage.local.get(HISTORY_KEY);3090 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3091 if (!history || !Array.isArray(history.snapshots)) return [];3092 return history.snapshots;3093}30943095309630973098309931003101export async function restoreVaultSnapshot(3102 snapshotId: string,3103): Promise<{ ok: true } | { ok: false; error: string }> {3104 const history = await getVaultHistory();3105 const snapshot = history.find((s) => s.id === snapshotId);3106 if (!snapshot) return { ok: false, error: "snapshot_not_found" };31073108 3109 3110 3111 await takeVaultSnapshot("restore");31123113 let restoredA: StoredVault;3114 let restoredB: StoredVault;3115 try {3116 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3117 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3118 } catch {3119 return { ok: false, error: "snapshot_corrupt" };3120 }31213122 await writeSlot(SLOT_A, restoredA);3123 await writeSlot(SLOT_B, restoredB);3124 await stampLocalVaultMutation();31253126 3127 3128 3129 await lock();31303131 return { ok: true };3132}313331343135313631373138313931403141314231433144314531463147export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3148 if (!derivedKey || vaultMode !== "real") {3149 return { ok: false, error: "vault_locked" };3150 }3151 const slotA = await loadSlot(SLOT_A);3152 if (!slotA) return { ok: false, error: "missing_slot_a" };31533154 3155 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3156 await generateAndWrapRootKey();31573158 3159 const newRootKey = unhex(rawRootKey);3160 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);31613162 3163 3164 3165 3166 await takeVaultSnapshot("local_save");3167 const nextSlotA: StoredVault = {3168 ...slotA,3169 recovery_salt: recoverySalt,3170 recovery_wrapped_root_key: wrappedRootKey,3171 recovery_nonce: wrappedNonce,3172 recovery_tag: wrappedTag,3173 ...pwRoot,3174 };3175 await writeSlot(SLOT_A, nextSlotA);31763177 3178 if (rootKey) zeroKey(rootKey);3179 rootKey = newRootKey;31803181 3182 await clearPairedDevices();31833184 3185 await setRecoveryConfirmPending(); 31863187 await stampLocalVaultMutation();3188 return { ok: true };3189}31903191319231933194319531963197export async function changePassword(3198 currentPassword: string,3199 newPassword: string3200): Promise<boolean> {3201 if (!derivedKey || vaultMode !== "real") return false;32023203 3204 const valid = await verifyMainPassword(currentPassword);3205 if (!valid) return false;32063207 const slotA = await loadSlot(SLOT_A);3208 if (!slotA) return false;3209 if (!rootKey) return false;32103211 3212 const newSalt = generateSalt();3213 const newKey = await deriveKey(newPassword, newSalt);32143215 3216 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3217 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");32183219 3220 3221 3222 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);32233224 const finalSlotA: StoredVault = {3225 ...slotA,3226 salt: hex(newSalt),3227 nonce,3228 ciphertext,3229 tag,3230 ...pwRoot,3231 };32323233 3234 3235 3236 await takeVaultSnapshot("local_save");32373238 3239 3240 3241 3242 await writeSlot(SLOT_A, finalSlotA);32433244 3245 3246 3247 zeroKey(derivedKey);3248 derivedKey = newKey;32493250 3251 3252 3253 3254 void saveSessionKey(newKey, "real");32553256 3257 3258 3259 if (!duressConfigured) {3260 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3261 }3262 await clearPinUnlockStorage();3263 await stampLocalVaultMutation();32643265 return true;3266}