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 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 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}151815191520152115221523152415251526export async function restoreFromSession(): Promise<boolean> {1527 if (derivedKey !== null) return true;1528 if (!chrome.storage.session) return false;1529 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1530 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1531 if (!entry?.keyHex) return false;15321533 const key = unhex(entry.keyHex);1534 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1535 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1536 const slot = await loadSlot(slotKey);1537 if (!slot) { await clearSessionKey(); return false; }15381539 const result = await tryDecryptSlotResult(slot, key, aadTag);1540 if (result.status !== "ok") { await clearSessionKey(); return false; }15411542 derivedKey = key;1543 vaultMode = entry.mode;1544 entries = result.payload.entries;1545 tombstones = result.payload.tombstones;1546 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1547 buildDomainIndexSync(entries);1548 await loadSettings();1549 touchAutoLock();15501551 1552 1553 if (entry.mode === "real") {1554 rootKey = await deriveRootKeyFromDerivedKey(key, slot);1555 if (!rootKey) {1556 lock();1557 return false;1558 }1559 }15601561 await lockedSyncUnlockHook?.().catch((error) => {1562 console.warn("[Peach sync] locked inbox drain failed:", error);1563 });15641565 return true;1566}15671568export function lock(): void {1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 void clearSessionKey();1579 void clearRecoveryConfirmPending();1580 if (derivedKey) {1581 zeroKey(derivedKey);1582 derivedKey = null;1583 }1584 if (rootKey) {1585 rootKey.fill(0);1586 rootKey = null;1587 }15881589 entries = [];1590 tombstones = new Map();1591 domainIndex = new Map();1592 vaultMode = "real";1593 duressConfigured = false;1594 clearAutoLock();1595}15961597export function isUnlocked(): boolean {1598 return derivedKey !== null;1599}16001601160216031604export async function unlockWithKey(1605 masterKeyHex: string,1606): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1607 try {1608 const masterKeyBytes = unhex(masterKeyHex);1609 const stored = await loadSlot(SLOT_A);1610 if (!stored) return { ok: false, error: "invalid_key" };1611 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1612 if (!payload) return { ok: false, error: "invalid_key" };16131614 derivedKey = masterKeyBytes;1615 vaultMode = "real";1616 entries = payload.entries;1617 tombstones = payload.tombstones;1618 duressConfigured = payload.duressConfigured;1619 buildDomainIndexSync(entries);1620 await loadSettings();1621 touchAutoLock();16221623 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1624 if (!rootKey) {1625 lock();1626 return { ok: false, error: "invalid_key" };1627 }1628 return { ok: true, entries };1629 } catch {1630 return { ok: false, error: "invalid_key" };1631 }1632}163316341635163616371638163916401641export async function exportDuressSlotForAdoption(): Promise<string | null> {1642 if (!(await isDuressEnabled())) return null;1643 const slotB = await loadSlot(SLOT_B);1644 if (!slotB) return null;1645 return JSON.stringify(slotB);1646}16471648164916501651165216531654export async function installAdoptedDuressSlot(json: string): Promise<void> {1655 let parsed: StoredVault;1656 try {1657 const raw = JSON.parse(json) as unknown;1658 if (!hasValidVaultSchema(raw)) {1659 throw new Error("invalid_duress_slot_shape");1660 }1661 parsed = raw;1662 } catch (err) {1663 if (err instanceof SyntaxError) {1664 throw new Error("invalid_duress_slot_shape");1665 }1666 throw err;1667 }1668 await writeSlot(SLOT_B, parsed);1669}16701671167216731674167516761677167816791680168116821683168416851686168716881689export async function unlockFromCodex(params: {1690 slotA: string; 1691 slotB: string | null; 1692 timestamp: number;1693 masterPassphrase: string;1694}): Promise<1695 | { ok: true; entryCount: number }1696 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1697> {1698 let parsedSlotA: StoredVault;1699 let parsedSlotB: StoredVault | null = null;1700 try {1701 const rawA = JSON.parse(params.slotA) as unknown;1702 if (!hasValidVaultSchema(rawA)) {1703 return { ok: false, reason: "invalid_slot_format" };1704 }1705 parsedSlotA = rawA;1706 if (params.slotB !== null) {1707 const rawB = JSON.parse(params.slotB) as unknown;1708 if (!hasValidVaultSchema(rawB)) {1709 return { ok: false, reason: "invalid_slot_format" };1710 }1711 parsedSlotB = rawB;1712 }1713 } catch {1714 return { ok: false, reason: "invalid_slot_format" };1715 }17161717 1718 1719 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1720 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1721 if (!trialPayload) {1722 zeroKey(trialKey);1723 return { ok: false, reason: "wrong_passphrase" };1724 }1725 1726 1727 zeroKey(trialKey);17281729 1730 1731 await takeVaultSnapshot("restore");17321733 await writeSlot(SLOT_A, parsedSlotA);1734 if (parsedSlotB) {1735 await writeSlot(SLOT_B, parsedSlotB);1736 } else {1737 1738 1739 1740 await writeSlot(1741 SLOT_B,1742 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1743 );1744 }1745 await chrome.storage.local.set({1746 peach_last_modified_at: String(params.timestamp),1747 });17481749 1750 1751 const ok = await unlock(params.masterPassphrase);1752 if (!ok) {1753 1754 1755 1756 return { ok: false, reason: "wrong_passphrase" };1757 }1758 return { ok: true, entryCount: getEntries().length };1759}17601761176217631764export function getDerivedKeyHex(): string {1765 if (!derivedKey) throw new Error("vault_not_unlocked");1766 return hex(derivedKey);1767}17681769177017711772177317741775177617771778177917801781178217831784export function getRootKeyHex(): string | null {1785 return rootKey ? hex(rootKey) : null;1786}17871788export function getEntries(): VaultEntry[] {1789 if (!derivedKey) throw new Error("vault_locked");1790 return entries.filter((entry) => !entry.deletedAt);1791}17921793const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;17941795export function getIdentities(): SavedIdentity[] {1796 if (!derivedKey) throw new Error("vault_locked");1797 const active = entries.filter((e) => !e.deletedAt && e.type === "login");17981799 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1800 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();18011802 for (const entry of active) {1803 const domain = extractDomain(entry.url ?? "") ?? "";1804 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;18051806 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1807 const existing = map.get(v);1808 if (!existing || modifiedAt > existing.lastUsed) {1809 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1810 } else {1811 map.set(v, { ...existing, count: existing.count + 1 });1812 }1813 }18141815 const uVal = entry.username?.trim();1816 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);18171818 1819 1820 const eVal = entry.email?.trim();1821 if (eVal) tally(emailMap, eVal);1822 }18231824 const toIdentity = (type: "email" | "username") =>1825 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1826 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1827 });18281829 return [1830 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1831 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1832 ];1833}18341835export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1836 if (!derivedKey) throw new Error("vault_locked");18371838 const now = Date.now();1839 const added: VaultEntry[] = [];1840 for (const draft of drafts) {1841 try {1842 const sanitized = sanitizeEntryDraft(draft);1843 const nextEntry: VaultEntry = {1844 ...sanitized,1845 id: crypto.randomUUID(),1846 createdAt: now,1847 modifiedAt: monotonicEntryTimestamp(),1848 };1849 entries.push(nextEntry);1850 added.push(nextEntry);1851 } catch {1852 1853 }1854 }18551856 if (added.length > 0) {1857 await persistEntries();1858 }18591860 return added;1861}186218631864186518661867186818691870export async function restoreEntries(1871 incoming: VaultEntry[],1872 mode: "replace" | "merge"1873): Promise<{ added: number; updated: number; skipped: number }> {1874 if (!derivedKey) throw new Error("vault_locked");18751876 1877 1878 1879 if (mode === "replace" && incoming.length === 0) {1880 throw new Error("refuse to replace with empty entry set");1881 }18821883 let added = 0;1884 let updated = 0;1885 let skipped = 0;18861887 if (mode === "replace") {1888 entries = [];1889 for (const entry of incoming) {1890 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1891 if (!normalized) {1892 skipped += 1;1893 continue;1894 }1895 entries.push(normalized);1896 if (normalized.deletedAt) {1897 tombstones.set(1898 normalized.id,1899 Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),1900 );1901 } else {1902 tombstones.delete(normalized.id);1903 }1904 added += 1;1905 }1906 } else {1907 const byId = new Map<string, VaultEntry>();1908 for (const e of entries) byId.set(e.id, e);19091910 for (const entry of incoming) {1911 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1912 if (!normalized) {1913 skipped += 1;1914 continue;1915 }1916 const existing = byId.get(normalized.id);1917 if (!existing) {1918 entries.push(normalized);1919 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1920 else tombstones.delete(normalized.id);1921 added += 1;1922 } else if (normalized.modifiedAt > existing.modifiedAt) {1923 const idx = entries.findIndex((e) => e.id === existing.id);1924 if (idx !== -1) {1925 entries[idx] = normalized;1926 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1927 else tombstones.delete(normalized.id);1928 updated += 1;1929 }1930 } else {1931 skipped += 1;1932 }1933 }1934 }19351936 await persistEntries("import");1937 return { added, updated, skipped };1938}193919401941194219431944194519461947194819491950195119521953195419551956const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;19571958function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {1959 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;1960 return { ...entry, id: crypto.randomUUID() };1961}196219631964196519661967196819691970function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1971 if (!entry || typeof entry !== "object") return null;1972 const title = typeof entry.title === "string" ? entry.title.trim() : "";1973 if (!title) return null;1974 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1975 const now = Date.now();1976 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1977 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;19781979 1980 1981 const normalized = normalizeEntry({1982 ...entry,1983 id,1984 title,1985 type: entry.type ?? "login",1986 createdAt,1987 modifiedAt,1988 });19891990 if (normalized.passkey) {1991 normalized.passkey = sanitizePasskey(normalized.passkey);1992 }1993 if (normalized.canary) {1994 normalized.canary = sanitizeCanary(normalized.canary);1995 }1996 1997 normalized.tags = sanitizeTags(normalized.tags);19981999 2000 2001 if (2002 normalized.type === "login" &&2003 !normalized.passkey &&2004 typeof normalized.password !== "string"2005 ) {2006 return null;2007 }2008 if (entry.passkey && !normalized.passkey) {2009 2010 2011 return null;2012 }20132014 return normalized;2015}2016201720182019202020212022function sanitizePasskey(pk: unknown): PasskeyData | undefined {2023 if (!pk || typeof pk !== "object") return undefined;2024 const r = pk as Record<string, unknown>;2025 if (2026 typeof r.rpId !== "string" || !r.rpId ||2027 typeof r.credentialId !== "string" || !r.credentialId ||2028 typeof r.publicKeyCose !== "string" ||2029 typeof r.userHandle !== "string" ||2030 typeof r.userName !== "string"2031 ) {2032 return undefined;2033 }2034 return {2035 rpId: r.rpId,2036 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2037 credentialId: r.credentialId,2038 publicKeyCose: r.publicKeyCose,2039 privateKeyJwk:2040 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2041 ? (r.privateKeyJwk as JsonWebKey)2042 : undefined,2043 userHandle: r.userHandle,2044 userName: r.userName,2045 userDisplayName:2046 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2047 algorithm: r.algorithm === -8 ? -8 : -7,2048 signCount: typeof r.signCount === "number" ? r.signCount : 0,2049 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2050 backupEligible: r.backupEligible === true,2051 backupState: r.backupState === true,2052 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2053 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2054 transports: Array.isArray(r.transports)2055 ? r.transports.filter((t): t is string => typeof t === "string")2056 : undefined,2057 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2058 };2059}20602061206220632064206520662067206820692070function sanitizeCanary(c: unknown): Canary | undefined {2071 if (!c || typeof c !== "object") return undefined;2072 const r = c as Record<string, unknown>;2073 if (typeof r.webhookUrl !== "string") return undefined;2074 let url: URL;2075 try {2076 url = new URL(r.webhookUrl);2077 } catch {2078 return undefined;2079 }2080 if (url.protocol !== "https:") return undefined;2081 return {2082 webhookUrl: url.toString(),2083 label: typeof r.label === "string" ? r.label : undefined,2084 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2085 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2086 };2087}20882089209020912092209320942095function sanitizeTags(tags: unknown): string[] | undefined {2096 if (!Array.isArray(tags)) return undefined;2097 const out: string[] = [];2098 for (const t of tags) {2099 if (typeof t !== "string") continue;2100 const trimmed = t.trim();2101 if (!trimmed) continue;2102 out.push(trimmed.slice(0, 256));2103 if (out.length >= 255) break;2104 }2105 return out.length > 0 ? out : undefined;2106}210721082109211021112112export async function clearVaultEntries(2113 confirmation: "REPLACE_ALL"2114): Promise<void> {2115 if (confirmation !== "REPLACE_ALL") {2116 throw new Error("clearVaultEntries requires explicit confirmation");2117 }2118 if (!derivedKey) throw new Error("vault_locked");2119 for (const entry of entries) {2120 if (!entry.deletedAt) {2121 const stamp = monotonicEntryTimestamp(Math.max(2122 entry.modifiedAt,2123 tombstones.get(entry.id) ?? 0,2124 ));2125 entry.deletedAt = stamp;2126 entry.modifiedAt = stamp;2127 tombstones.set(entry.id, stamp);2128 }2129 }2130 await persistEntries();2131}21322133export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2134 if (!derivedKey) throw new Error("vault_locked");21352136 const sanitized = sanitizeEntryDraft(entry);2137 const now = Date.now();2138 const stamp = monotonicEntryTimestamp();2139 const nextEntry: VaultEntry = {2140 ...sanitized,2141 id: crypto.randomUUID(),2142 createdAt: now,2143 modifiedAt: stamp,2144 2145 2146 passwordChangedAt: sanitized.password ? stamp : undefined,2147 };21482149 2150 2151 2152 const rollback = entries;2153 entries = [...entries, nextEntry];2154 try {2155 await persistEntries();2156 } catch (err) {2157 entries = rollback;2158 throw err;2159 }21602161 2162 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2163 checkBreach(nextEntry.password).then(async (count) => {2164 if (count > 0) {2165 const idx = entries.findIndex((e) => e.id === nextEntry.id);2166 if (idx !== -1) {2167 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2168 await persistEntries();2169 }2170 }2171 });2172 }21732174 fireSyncTrigger("vault_change");21752176 return nextEntry;2177}21782179218021812182218321842185export async function consolidatePasskeyEntries(): Promise<void> {2186 if (!derivedKey) return;2187 const { merged } = mergePasskeyOnlyIntoLogins(entries);2188 if (merged) {2189 await persistEntries();2190 }2191}21922193export async function updateEntry(2194 id: string,2195 patch: Partial<VaultEntry>2196): Promise<void> {2197 if (!derivedKey) throw new Error("vault_locked");21982199 const idx = entries.findIndex((entry) => entry.id === id);2200 if (idx === -1 || entries[idx].deletedAt) {2201 throw new Error("entry_not_found");2202 }22032204 const current = entries[idx];2205 const sanitizedPatch = sanitizeEntryPatch(patch);2206 2207 2208 2209 const passwordChanged =2210 "password" in patch &&2211 typeof sanitizedPatch.password === "string" &&2212 sanitizedPatch.password !== current.password;2213 const stamp = monotonicEntryTimestamp(current.modifiedAt);2214 2215 2216 2217 2218 2219 2220 2221 const priorPassword = current.password;2222 const shouldCapture =2223 passwordChanged &&2224 typeof priorPassword === "string" &&2225 priorPassword.length > 0 &&2226 current.passwordHistory?.[0]?.password !== priorPassword;2227 const nextHistory = shouldCapture2228 ? [2229 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2230 ...(current.passwordHistory ?? []),2231 ].slice(0, PASSWORD_HISTORY_MAX)2232 : current.passwordHistory;2233 entries[idx] = {2234 ...current,2235 ...sanitizedPatch,2236 modifiedAt: stamp,2237 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2238 };2239224022412242 2243 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2244 entries[idx] = current;2245 throw new Error("card number is required for card entries");2246 }22472248 2249 2250 2251 2252 try {2253 await persistEntries();2254 } catch (err) {2255 entries[idx] = current;2256 throw err;2257 }22582259 2260 2261 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2262 entries[idx] = { ...entries[idx], breach: undefined };2263 await persistEntries();2264 const password = entries[idx].password;2265 if (password) {2266 checkBreach(password).then(async (count) => {2267 const i = entries.findIndex((e) => e.id === id);2268 if (i !== -1 && count > 0) {2269 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2270 await persistEntries();2271 }2272 });2273 }2274 }22752276 fireSyncTrigger("vault_change");2277}22782279export async function deleteEntry(id: string): Promise<void> {2280 if (!derivedKey) throw new Error("vault_locked");22812282 const entry = entries.find((item) => item.id === id);2283 if (!entry || entry.deletedAt) {2284 throw new Error("entry_not_found");2285 }22862287 2288 2289 2290 const previousModifiedAt = entry.modifiedAt;2291 const previousTombstone = tombstones.get(id);2292 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2293 entry.deletedAt = stamp;2294 entry.modifiedAt = stamp;2295 tombstones.set(id, stamp);2296 2297 2298 try {2299 await persistEntries();2300 } catch (err) {2301 delete entry.deletedAt;2302 entry.modifiedAt = previousModifiedAt;2303 if (previousTombstone === undefined) tombstones.delete(id);2304 else tombstones.set(id, previousTombstone);2305 throw err;2306 }2307 fireSyncTrigger("vault_change");2308}23092310export function getDeletedEntries(): VaultEntry[] {2311 if (!derivedKey) return [];2312 return entries.filter((e) => !!e.deletedAt);2313}231423152316export function getTombstoneManifest(): Record<string, number> {2317 if (!derivedKey) return {};2318 return Object.fromEntries(tombstones);2319}2320232123222323232423252326export async function getVaultVersion(): Promise<number> {2327 try {2328 const raw = await chrome.storage.local.get("peach_last_modified_at");2329 const v = (raw as Record<string, unknown>).peach_last_modified_at;2330 if (typeof v === "number") return v;2331 if (typeof v === "string" && v !== "") {2332 const n = Number.parseInt(v, 10);2333 return Number.isFinite(n) ? n : 0;2334 }2335 return 0;2336 } catch {2337 return 0;2338 }2339}2340234123422343234423452346234723482349235023512352export async function applyMergedRemoteEntries(2353 remoteEntries: VaultEntry[],2354 remoteTombstones: Record<string, number>,2355 _context: { deviceName?: string } = {},2356): Promise<{ received: number; applied: number; conflicts: number }> {2357 2358 2359 2360 2361 2362 2363 if (!derivedKey) throw new Error("vault_locked");23642365 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2366 let applied = 0;2367 let conflicts = 0;23682369 const byId = new Map<string, VaultEntry>();2370 for (const e of entries) byId.set(e.id, e);23712372 const mergeNow = Date.now();2373 const remoteTombstoneTimes = new Map<string, number>();2374 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2375 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2376 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2377 }2378 }23792380 for (const remote of remoteEntries) {2381 const sanitized = sanitizeRestoredEntry(remote);2382 if (!sanitized) continue;2383 2384 2385 2386 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2387 if (typeof sanitized.deletedAt === "number") {2388 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2389 }2390 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2391 const localTombstoneTs = tombstones.get(sanitized.id);2392 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2393 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2394 continue;2395 }2396 const local = byId.get(sanitized.id);2397 if (!local) {2398 entries.push(sanitized);2399 byId.set(sanitized.id, sanitized);2400 tombstones.delete(sanitized.id);2401 applied += 1;2402 } else if (sanitized.modifiedAt > local.modifiedAt) {2403 const idx = entries.findIndex((e) => e.id === local.id);2404 if (idx !== -1) {2405 entries[idx] = sanitized;2406 byId.set(sanitized.id, sanitized);2407 tombstones.delete(sanitized.id);2408 applied += 1;2409 if (!local.deletedAt) conflicts += 1;2410 }2411 }2412 }24132414 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2415 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2416 2417 const deletedAt = remoteTombstoneTimes.get(id)!;2418 const knownTombstone = tombstones.get(id);2419 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2420 const local = byId.get(id);2421 if (!local) {2422 tombstones.set(id, deletedAt);2423 applied += 1;2424 continue;2425 }2426 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2427 if (deletedAt < local.modifiedAt) continue;2428 const idx = entries.findIndex((e) => e.id === id);2429 if (idx !== -1) {2430 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2431 tombstones.set(id, deletedAt);2432 applied += 1;2433 }2434 }24352436 if (applied > 0) {2437 await persistEntries("sync_received");2438 buildDomainIndexSync(entries);2439 }2440 return { received, applied, conflicts };2441}24422443export async function restoreEntry(id: string): Promise<void> {2444 if (!derivedKey) throw new Error("vault_locked");2445 const entry = entries.find((e) => e.id === id && e.deletedAt);2446 if (!entry) throw new Error("deleted entry not found");2447 const previousDeletedAt = entry.deletedAt;2448 const previousModifiedAt = entry.modifiedAt;2449 const previousTombstone = tombstones.get(id);2450 delete entry.deletedAt;2451 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2452 entry.modifiedAt,2453 previousTombstone ?? 0,2454 ));2455 tombstones.delete(id);2456 try {2457 await persistEntries();2458 } catch (err) {2459 entry.deletedAt = previousDeletedAt;2460 entry.modifiedAt = previousModifiedAt;2461 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2462 throw err;2463 }2464 buildDomainIndexSync(entries);2465}24662467export async function purgeEntry(id: string): Promise<void> {2468 if (!derivedKey) throw new Error("vault_locked");2469 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2470 if (idx === -1) throw new Error("deleted entry not found");2471 const entry = entries[idx]!;2472 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2473 entries.splice(idx, 1);2474 await persistEntries();2475}247624772478export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2479 if (!derivedKey || retentionMs === 0) return 0;2480 const cutoff = Date.now() - retentionMs;2481 const before = entries.length;2482 for (const entry of entries) {2483 if (entry.deletedAt && entry.deletedAt < cutoff) {2484 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2485 }2486 }2487 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2488 const purged = before - entries.length;2489 if (purged > 0) await persistEntries();2490 return purged;2491}24922493export function queryDomain(domain: string): VaultEntry[] {2494 return domainIndex.get(domain) ?? [];2495}24962497export function getEntryById(id: string): VaultEntry | undefined {2498 return entries.find((entry) => entry.id === id && !entry.deletedAt);2499}25002501async function persistSlotA(2502 reason: VaultSnapshot["reason"] = "local_save",2503): Promise<StoredVault> {2504 if (!derivedKey) throw new Error("vault_locked");25052506 const slotA = await loadSlot(SLOT_A);2507 if (!slotA) throw new Error("missing slot A");25082509 2510 2511 await takeVaultSnapshot(reason);25122513 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2514 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");25152516 2517 const nextSlotA: StoredVault = {2518 ...slotA,2519 nonce,2520 ciphertext,2521 tag,2522 2523 };2524 await writeSlot(SLOT_A, nextSlotA);25252526 2527 2528 2529 2530 2531 2532 try {2533 2534 2535 2536 if (!duressConfigured) {2537 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2538 }2539 2540 2541 2542 2543 2544 if (reason !== "sync_received") {2545 await stampLocalVaultMutation();2546 }2547 } catch (err) {2548 2549 2550 await writeSlot(SLOT_A, slotA).catch(() => {});2551 throw err;2552 }25532554 return nextSlotA;2555}25562557async function persistSlotB(): Promise<void> {2558 if (!derivedKey) throw new Error("vault_locked");25592560 const slotB = await loadSlot(SLOT_B);2561 if (!slotB) throw new Error("missing slot B");25622563 2564 2565 2566 2567 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2568 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");25692570 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2571 2572 2573 2574 try {2575 await stampLocalVaultMutation();2576 } catch (err) {2577 await writeSlot(SLOT_B, slotB).catch(() => {});2578 throw err;2579 }2580}25812582async function persistEntries(2583 reason: VaultSnapshot["reason"] = "local_save",2584): Promise<void> {2585 if (!derivedKey) throw new Error("vault_locked");25862587 2588 2589 const previousTombstones = tombstones;2590 tombstones = new Map(tombstones);2591 reconcileTombstones();2592 try {2593 if (vaultMode === "decoy") {2594 await persistSlotB();2595 } else {2596 await persistSlotA(reason);2597 }2598 } catch (err) {2599 tombstones = previousTombstones;2600 throw err;2601 }2602 buildDomainIndexSync(entries);2603 await refreshLockedSyncState();2604}2605260626072608260926102611function allowHttpUrl(url: string | undefined): string {2612 const trimmed = url?.trim() ?? "";2613 if (!trimmed) return "";2614 try {2615 const parsed = new URL(trimmed);2616 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2617 } catch {2618 2619 }2620 return trimmed;2621}26222623function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2624 if (!Array.isArray(values)) return undefined;2625 const seen = new Set<string>();2626 const cleaned: string[] = [];2627 for (const value of values) {2628 const trimmed = typeof value === "string" ? value.trim() : "";2629 if (!trimmed) continue;2630 if (!allowHttpUrl(trimmed)) continue;2631 const key = trimmed.toLowerCase();2632 if (seen.has(key)) continue;2633 seen.add(key);2634 cleaned.push(trimmed);2635 }2636 return cleaned.length > 0 ? cleaned : undefined;2637}26382639function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2640 const title = entry.title.trim();2641 const type: VaultEntry["type"] = entry.type ?? "login";26422643 if (!title) throw new Error("title is required");26442645 if (entry.passkey) {2646 const p = entry.passkey;2647 if (2648 !p.rpId ||2649 !p.credentialId ||2650 !p.publicKeyCose ||2651 !p.userHandle ||2652 !p.userName ||2653 (p.algorithm !== -7 && p.algorithm !== -8)2654 ) {2655 throw new Error("invalid passkey credential");2656 }2657 }2658 if (type === "card") {2659 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2660 throw new Error("cardholder name and card number are required");2661 }2662 }26632664 return {2665 type,2666 title,2667 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2668 email: type === "login" ? entry.email?.trim() || undefined : undefined,2669 password: type === "login" ? entry.password : undefined,2670 url: type === "login" ? allowHttpUrl(entry.url) : undefined,2671 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2672 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2673 2674 2675 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2676 totpDigits: type === "login" ? entry.totpDigits : undefined,2677 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2678 notes: entry.notes?.trim() || undefined,2679 breach: type === "login" ? entry.breach : undefined,2680 2681 2682 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2683 tags: sanitizeTags(entry.tags),2684 passkey: entry.passkey,2685 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2686 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2687 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2688 expYear: type === "card" ? entry.expYear?.trim() : undefined,2689 cvc: type === "card" ? entry.cvc?.trim() : undefined,2690 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2691 network: type === "card" ? entry.network : undefined,2692 last4: type === "card" ? entry.last4?.trim() : undefined,2693 };2694}26952696function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2697 const nextPatch: Partial<VaultEntry> = {};26982699 if ("type" in patch) {2700 nextPatch.type = patch.type;2701 }27022703 if ("title" in patch) {2704 const title = patch.title?.trim() ?? "";2705 if (!title) throw new Error("title is required");2706 nextPatch.title = title;2707 }27082709 if ("url" in patch) {2710 nextPatch.url = allowHttpUrl(patch.url);2711 }27122713 if ("additionalUrls" in patch) {2714 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2715 const seen = new Set<string>();2716 const cleaned: string[] = [];2717 for (const value of incoming) {2718 const trimmed = typeof value === "string" ? value.trim() : "";2719 if (!trimmed || !allowHttpUrl(trimmed)) continue;2720 const key = trimmed.toLowerCase();2721 if (seen.has(key)) continue;2722 seen.add(key);2723 cleaned.push(trimmed);2724 }2725 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2726 }27272728 if ("username" in patch) {2729 nextPatch.username = patch.username?.trim() ?? "";2730 }27312732 if ("email" in patch) {2733 nextPatch.email = patch.email?.trim() || undefined;2734 }27352736 if ("password" in patch) {2737 const password = patch.password ?? "";2738 2739 nextPatch.password = password || undefined;2740 }27412742 if ("totp" in patch) {2743 nextPatch.totp = patch.totp?.trim() || undefined;2744 }27452746 if ("notes" in patch) {2747 nextPatch.notes = patch.notes?.trim() || undefined;2748 }27492750 if ("breach" in patch) {2751 nextPatch.breach = patch.breach;2752 }27532754 if ("canary" in patch) {2755 2756 2757 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2758 }27592760 if ("passkey" in patch) {2761 2762 2763 if (patch.passkey === undefined || patch.passkey === null) {2764 nextPatch.passkey = undefined;2765 } else {2766 const sanitized = sanitizePasskey(patch.passkey);2767 if (!sanitized) throw new Error("invalid passkey credential");2768 nextPatch.passkey = sanitized;2769 }2770 }27712772 if ("tags" in patch) {2773 2774 nextPatch.tags = sanitizeTags(patch.tags);2775 }27762777 if ("cardholderName" in patch) {2778 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2779 }2780 if ("cardNumber" in patch) {2781 const n = patch.cardNumber ?? "";2782 nextPatch.cardNumber = n || undefined;2783 }2784 if ("expMonth" in patch) {2785 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2786 }2787 if ("expYear" in patch) {2788 nextPatch.expYear = patch.expYear?.trim() || undefined;2789 }2790 if ("cvc" in patch) {2791 nextPatch.cvc = patch.cvc?.trim() || undefined;2792 }2793 if ("cardNickname" in patch) {2794 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2795 }2796 if ("network" in patch) {2797 nextPatch.network = patch.network;2798 }2799 if ("last4" in patch) {2800 nextPatch.last4 = patch.last4?.trim() || undefined;2801 }28022803 return nextPatch;2804}28052806function normalizeEntry(entry: VaultEntry): VaultEntry {2807 const base: VaultEntry = {2808 id: entry.id,2809 type: entry.type,2810 title: entry.title,2811 username: entry.username,2812 email: entry.email,2813 password: entry.password,2814 url: entry.url,2815 additionalUrls:2816 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2817 ? entry.additionalUrls2818 : undefined,2819 totp: entry.totp,2820 2821 totpAlgorithm: entry.totpAlgorithm,2822 totpDigits: entry.totpDigits,2823 totpPeriod: entry.totpPeriod,2824 notes: entry.notes,2825 breach: entry.breach,2826 2827 2828 2829 passwordChangedAt:2830 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2831 2832 2833 2834 passwordHistory:2835 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2836 ? entry.passwordHistory2837 : undefined,2838 canary: entry.canary,2839 passkey: entry.passkey,2840 createdAt: entry.createdAt,2841 modifiedAt: entry.modifiedAt,2842 deletedAt: entry.deletedAt,2843 tags: entry.tags,2844 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2845 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2846 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2847 expYear: "expYear" in entry ? entry.expYear : undefined,2848 cvc: "cvc" in entry ? entry.cvc : undefined,2849 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2850 network: "network" in entry ? entry.network : undefined,2851 last4: "last4" in entry ? entry.last4 : undefined,2852 };28532854 return base;2855}2856285728582859286028612862function entryDupeKey(e: VaultEntry): string {2863 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2864 2865 2866 2867 2868 if (e.passkey) {2869 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2870 }2871 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2872}287328742875export function countDuplicates(): number {2876 const best = new Map<string, VaultEntry>();2877 let dupeCount = 0;2878 for (const e of entries.filter((e) => !e.deletedAt)) {2879 const key = entryDupeKey(e);2880 const existing = best.get(key);2881 if (!existing) {2882 best.set(key, e);2883 } else {2884 dupeCount++;2885 2886 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2887 best.set(key, e);2888 }2889 }2890 }2891 return dupeCount;2892}289328942895289628972898export async function deduplicateEntries(): Promise<number> {2899 if (!derivedKey) throw new Error("vault_locked");2900 2901 2902 if (vaultMode === "decoy") {2903 throw new Error("deduplicate disabled in decoy mode");2904 }29052906 const best = new Map<string, VaultEntry>();2907 for (const e of entries.filter((en) => !en.deletedAt)) {2908 const key = entryDupeKey(e);2909 const existing = best.get(key);2910 if (!existing) {2911 best.set(key, e);2912 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2913 best.set(key, e);2914 }2915 }29162917 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2918 let removed = 0;2919 entries = entries.map((e) => {2920 if (!e.deletedAt && !keepIds.has(e.id)) {2921 removed++;2922 const deletedAt = monotonicEntryTimestamp(Math.max(2923 e.modifiedAt,2924 tombstones.get(e.id) ?? 0,2925 ));2926 tombstones.set(e.id, deletedAt);2927 return { ...e, deletedAt };2928 }2929 return e;2930 });29312932 if (removed > 0) await persistEntries();2933 return removed;2934}293529362937293829392940294129422943const HISTORY_KEY = "peach_vault_history";2944const HISTORY_MAX = 5;294529462947294829492950function bucketEntryCount(n: number): number {2951 if (n <= 0) return 0;2952 if (n <= 10) return 10;2953 if (n <= 25) return 25;2954 if (n <= 50) return 50;2955 if (n <= 100) return 100;2956 if (n <= 250) return 250;2957 if (n <= 500) return 500;2958 return Math.ceil(n / 500) * 500;2959}29602961296229632964296529662967export async function takeVaultSnapshot(2968 reason: VaultSnapshot["reason"],2969): Promise<void> {2970 try {2971 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2972 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2973 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];29742975 2976 2977 2978 if (!slotA) return;29792980 const vault_a = JSON.stringify(slotA);2981 const vault_b = JSON.stringify(slotB ?? slotA);29822983 2984 2985 2986 2987 2988 2989 let entryCount: number | null = null;2990 if (derivedKey && vaultMode === "real") {2991 try {2992 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2993 } catch {2994 entryCount = null;2995 }2996 }29972998 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2999 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3000 ? existingRaw3001 : { snapshots: [] };30023003 3004 3005 3006 const latest = history.snapshots[0];3007 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3008 return;3009 }30103011 const snapshot: VaultSnapshot = {3012 id: crypto.randomUUID(),3013 savedAt: Date.now(),3014 reason,3015 vault_a,3016 vault_b,3017 entryCount,3018 };30193020 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3021 await chrome.storage.local.set({ [HISTORY_KEY]: history });3022 } catch (err) {3023 3024 3025 3026 3027 console.warn('[Peach] takeVaultSnapshot failed:', err);3028 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3029 try {3030 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3031 } catch {3032 3033 }3034 }3035 }3036}303730383039export async function clearVaultHistory(): Promise<void> {3040 try {3041 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3042 } catch {3043 3044 }3045}304630473048export async function getVaultHistory(): Promise<VaultSnapshot[]> {3049 const raw = await chrome.storage.local.get(HISTORY_KEY);3050 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3051 if (!history || !Array.isArray(history.snapshots)) return [];3052 return history.snapshots;3053}30543055305630573058305930603061export async function restoreVaultSnapshot(3062 snapshotId: string,3063): Promise<{ ok: true } | { ok: false; error: string }> {3064 const history = await getVaultHistory();3065 const snapshot = history.find((s) => s.id === snapshotId);3066 if (!snapshot) return { ok: false, error: "snapshot_not_found" };30673068 3069 3070 3071 await takeVaultSnapshot("restore");30723073 let restoredA: StoredVault;3074 let restoredB: StoredVault;3075 try {3076 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3077 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3078 } catch {3079 return { ok: false, error: "snapshot_corrupt" };3080 }30813082 await writeSlot(SLOT_A, restoredA);3083 await writeSlot(SLOT_B, restoredB);3084 await stampLocalVaultMutation();30853086 3087 3088 3089 lock();30903091 return { ok: true };3092}309330943095309630973098309931003101310231033104310531063107export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3108 if (!derivedKey || vaultMode !== "real") {3109 return { ok: false, error: "vault_locked" };3110 }3111 const slotA = await loadSlot(SLOT_A);3112 if (!slotA) return { ok: false, error: "missing_slot_a" };31133114 3115 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3116 await generateAndWrapRootKey();31173118 3119 const newRootKey = unhex(rawRootKey);3120 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);31213122 3123 3124 3125 3126 await takeVaultSnapshot("local_save");3127 const nextSlotA: StoredVault = {3128 ...slotA,3129 recovery_salt: recoverySalt,3130 recovery_wrapped_root_key: wrappedRootKey,3131 recovery_nonce: wrappedNonce,3132 recovery_tag: wrappedTag,3133 ...pwRoot,3134 };3135 await writeSlot(SLOT_A, nextSlotA);31363137 3138 if (rootKey) zeroKey(rootKey);3139 rootKey = newRootKey;31403141 3142 await clearPairedDevices();31433144 3145 await setRecoveryConfirmPending(); 31463147 await stampLocalVaultMutation();3148 return { ok: true };3149}31503151315231533154315531563157export async function changePassword(3158 currentPassword: string,3159 newPassword: string3160): Promise<boolean> {3161 if (!derivedKey || vaultMode !== "real") return false;31623163 3164 const valid = await verifyMainPassword(currentPassword);3165 if (!valid) return false;31663167 const slotA = await loadSlot(SLOT_A);3168 if (!slotA) return false;3169 if (!rootKey) return false;31703171 3172 const newSalt = generateSalt();3173 const newKey = await deriveKey(newPassword, newSalt);31743175 3176 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3177 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");31783179 3180 3181 3182 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);31833184 const finalSlotA: StoredVault = {3185 ...slotA,3186 salt: hex(newSalt),3187 nonce,3188 ciphertext,3189 tag,3190 ...pwRoot,3191 };31923193 3194 3195 3196 await takeVaultSnapshot("local_save");31973198 3199 3200 3201 3202 await writeSlot(SLOT_A, finalSlotA);32033204 3205 3206 3207 zeroKey(derivedKey);3208 derivedKey = newKey;32093210 3211 3212 3213 3214 void saveSessionKey(newKey, "real");32153216 3217 3218 3219 if (!duressConfigured) {3220 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3221 }3222 await clearPinUnlockStorage();3223 await stampLocalVaultMutation();32243225 return true;3226}