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 declinedCardFingerprints?: string[];127 128129130131132 textSize?: TextSize;133 134135136137138 reduceMotion?: boolean;139 140 pinEnabled?: boolean;141 142 pinPreferred?: boolean;143 144145146147148149150 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;151}152153interface StoredPinWrap {154 version: 1;155 salt: string;156 nonce: string;157 ciphertext: string;158 tag: string;159}160161const PIN_WRAP_STORAGE = "peach_pin_wrap";162const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";163const PIN_MIN_LENGTH = 4;164const PIN_MAX_LENGTH = 12;165const PIN_MAX_ATTEMPTS = 3;166167let settings: VaultSettings = {};168let inMemoryPinWrap: StoredPinWrap | null = null;169let inMemoryPinFailedAttempts = 0;170let sessionPinStorageFailed = false;171172export function getSettings(): VaultSettings {173 return settings;174}175176export async function ensureSettingsLoaded(): Promise<VaultSettings> {177 await loadSettings();178 return settings;179}180181export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {182 await loadSettings();183 184 185 186 187 const safe = Object.fromEntries(188 Object.entries(patch).filter(189 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),190 ),191 ) as Partial<VaultSettings>;192 settings = { ...settings, ...safe };193 await persistSettings();194}195196function isValidPin(pin: string): boolean {197 return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);198}199200201export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {202 if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;203 if (!(await verifyMainPassword(currentPassword))) return false;204205 const salt = generateSalt();206 const pinKey = await deriveKey(pin, salt);207 try {208 const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");209 await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });210 await updateSettings({ pinEnabled: true });211 return true;212 } finally {213 zeroKey(pinKey);214 }215}216217async function clearPinUnlockStorage(): Promise<void> {218 inMemoryPinWrap = null;219 inMemoryPinFailedAttempts = 0;220 sessionPinStorageFailed = false;221 if (chrome.storage.session) {222 await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);223 }224 await updateSettings({ pinEnabled: false });225}226227228export async function disablePinUnlock(currentPassword: string): Promise<boolean> {229 if (!derivedKey || vaultMode !== "real") return false;230 if (!(await verifyMainPassword(currentPassword))) return false;231 await clearPinUnlockStorage();232 return true;233}234235236export async function unlockWithPin(pin: string): Promise<boolean> {237 if (!isValidPin(pin)) return false;238 await loadSettings();239 if (!settings.pinEnabled) return false;240241 const { stored, failedAttempts } = await loadPinState();242 if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;243 if (244 !stored ||245 stored.version !== 1 ||246 !stored.salt ||247 !stored.nonce ||248 !stored.ciphertext ||249 !stored.tag250 ) return false;251252 const pinKey = await deriveKey(pin, unhex(stored.salt));253 let unwrappedKey: Uint8Array | null = null;254 try {255 const masterKeyHex = await decrypt(256 pinKey,257 stored.nonce,258 stored.ciphertext,259 stored.tag,260 "pin_wrap_v1",261 );262 if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;263 unwrappedKey = unhex(masterKeyHex);264 const result = await unlockWithKey(masterKeyHex);265 if (!result.ok) return false;266 await saveSessionKey(unwrappedKey, "real");267 await resetPinFailedAttempts();268 return true;269 } catch {270 await recordPinFailure(failedAttempts + 1);271 return false;272 } finally {273 zeroKey(pinKey);274 if (unwrappedKey) zeroKey(unwrappedKey);275 }276}277278async function savePinWrap(stored: StoredPinWrap): Promise<void> {279 inMemoryPinWrap = stored;280 inMemoryPinFailedAttempts = 0;281 sessionPinStorageFailed = !chrome.storage.session;282 if (!chrome.storage.session) return;283 try {284 await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });285 sessionPinStorageFailed = false;286 } catch {287 288 sessionPinStorageFailed = true;289 }290}291292async function loadPinState(): Promise<{293 stored: StoredPinWrap | undefined;294 failedAttempts: number;295}> {296 if (!chrome.storage.session) {297 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };298 }299 try {300 const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);301 return {302 stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??303 (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),304 failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"305 ? result[PIN_ATTEMPTS_STORAGE]306 : 0,307 };308 } catch {309 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };310 }311}312313async function recordPinFailure(failedAttempts: number): Promise<void> {314 inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);315 if (!chrome.storage.session) return;316 try {317 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });318 } catch { }319}320321async function resetPinFailedAttempts(): Promise<void> {322 inMemoryPinFailedAttempts = 0;323 if (!chrome.storage.session) return;324 try {325 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });326 } catch { }327}328329export async function getPinUnlockStatus(): Promise<{330 configured: boolean;331 available: boolean;332 attemptsRemaining: number;333}> {334 await loadSettings();335 if (!settings.pinEnabled) {336 return { configured: false, available: false, attemptsRemaining: 0 };337 }338 const { stored, failedAttempts } = await loadPinState();339 const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;340 return {341 configured: true,342 available,343 attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,344 };345}346347async function persistSettings(): Promise<void> {348 try {349 await chrome.storage.local.set({ peach_settings: settings });350 } catch (err) {351 throw mapStorageError(err);352 }353}354355function mapStorageError(err: unknown): Error {356 const message = err instanceof Error ? err.message : String(err);357 if (/QUOTA_BYTES|quota/i.test(message)) {358 return new Error("STORAGE_QUOTA_EXCEEDED");359 }360 return err instanceof Error ? err : new Error(message);361}362363async function loadSettings(): Promise<void> {364 const result = await chrome.storage.local.get("peach_settings");365 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};366}367368369370function fireSyncTrigger(reason: "vault_change"): void {371 void Promise.resolve()372 .then(() => triggerSyncIfReachable({ reason }))373 .catch(() => {374 375 });376}377378let lockedSyncRefreshHook: (() => Promise<void>) | null = null;379let lockedSyncUnlockHook: (() => Promise<void>) | null = null;380381export function setLockedSyncHooks(hooks: {382 refresh: () => Promise<void>;383 afterUnlock: () => Promise<void>;384}): void {385 lockedSyncRefreshHook = hooks.refresh;386 lockedSyncUnlockHook = hooks.afterUnlock;387}388389async function refreshLockedSyncState(): Promise<void> {390 if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;391 await lockedSyncRefreshHook().catch((error) => {392 console.warn("[Peach sync] locked spool refresh failed:", error);393 });394}395396397398399400export async function checkBreach(password: string): Promise<number> {401 402 403 const encoder = new TextEncoder();404 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));405 const hashHex = Array.from(new Uint8Array(hashBuffer))406 .map((b) => b.toString(16).padStart(2, "0"))407 .join("")408 .toUpperCase();409410 const prefix = hashHex.slice(0, 5);411 const suffix = hashHex.slice(5);412413 try {414 415 416 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {417 headers: { "Add-Padding": "true" },418 });419 if (!res.ok) return 0;420 const text = await res.text();421 const match = text.split("\r\n").find((l) => l.startsWith(suffix));422 return match ? parseInt(match.split(":")[1], 10) : 0;423 } catch {424 return 0; 425 }426}427428export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {429 const fingerprint = await cardFingerprint(last4, expMonth, expYear);430 await loadSettings();431 settings = {432 ...settings,433 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],434 };435 await persistSettings();436}437438export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {439 await loadSettings();440 const fingerprint = await cardFingerprint(last4, expMonth, expYear);441 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);442}443444async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {445 const encoder = new TextEncoder();446 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));447 return Array.from(new Uint8Array(buf).slice(0, 8))448 .map((b) => b.toString(16).padStart(2, "0"))449 .join("");450}451452function hex(buf: Uint8Array): string {453 return Array.from(buf)454 .map((b) => b.toString(16).padStart(2, "0"))455 .join("");456}457458function unhex(s: string): Uint8Array {459 const out = new Uint8Array(s.length / 2);460 for (let i = 0; i < s.length; i += 2) {461 out[i / 2] = parseInt(s.slice(i, i + 2), 16);462 }463 return out;464}465466467468469470471472473474475476477478479480481482483484485async function generateAndWrapRootKey(): Promise<{486 rawRootKey: string; 487 recoverySalt: string;488 wrappedRootKey: string;489 wrappedNonce: string;490 wrappedTag: string;491}> {492 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));493494 495 const words = await rootKeyToWords(rootKeyBytes);496 const wordMaterial = await wordsToRootKeyMaterial(words);497 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));498 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);499500 501 502 const nonce = crypto.getRandomValues(new Uint8Array(12));503 const encoded = new TextEncoder().encode(hex(rootKeyBytes));504 const raw = await crypto.subtle.encrypt(505 {506 name: "AES-GCM",507 iv: nonce,508 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,509 tagLength: 128,510 },511 recoveryKey,512 encoded513 );514 515 const rawU8 = new Uint8Array(raw);516 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));517 const tag = hex(rawU8.slice(rawU8.length - 16));518519 return {520 rawRootKey: hex(rootKeyBytes),521 recoverySalt: hex(recoverySalt),522 wrappedRootKey: ciphertext,523 wrappedNonce: hex(nonce),524 wrappedTag: tag,525 };526}527528async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{529 recoverySalt: string;530 wrappedRootKey: string;531 wrappedNonce: string;532 wrappedTag: string;533}> {534 if (rootKeyBytes.length !== 32) {535 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");536 }537538 const words = await rootKeyToWords(rootKeyBytes);539 const wordMaterial = await wordsToRootKeyMaterial(words);540 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));541 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);542 const nonce = crypto.getRandomValues(new Uint8Array(12));543 const encoded = new TextEncoder().encode(hex(rootKeyBytes));544 const raw = await crypto.subtle.encrypt(545 {546 name: "AES-GCM",547 iv: nonce,548 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,549 tagLength: 128,550 },551 recoveryKey,552 encoded,553 );554 const rawU8 = new Uint8Array(raw);555 return {556 recoverySalt: hex(recoverySalt),557 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),558 wrappedNonce: hex(nonce),559 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),560 };561}562563564565566567568export async function unwrapRootKeyForRecovery(569 wrappedRootKey: string,570 wrappedNonce: string,571 wrappedTag: string,572 recoverySaltHex: string,573 words: string[]574): Promise<void> {575 const wordMaterial = await wordsToRootKeyMaterial(words);576 const recoverySalt = unhex(recoverySaltHex);577 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);578579 580 581 582 583 584 const nonceRaw = unhex(wrappedNonce);585 const ciphertextRaw = unhex(wrappedRootKey);586 const tagRaw = unhex(wrappedTag);587 588 589 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;590 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);591 combined.set(ciphertextRaw, 0);592 combined.set(tagRaw, ciphertextRaw.length);593 const raw = await crypto.subtle.decrypt(594 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },595 recoveryKey,596 combined597 );598 const decryptedHex = new TextDecoder().decode(raw);599 rootKey = unhex(decryptedHex);600}601602603604605606607608609610611export async function rewrapRootKeyForPassword(612 newDerivedKey: Uint8Array,613 _newSalt: Uint8Array614): Promise<void> {615 if (!rootKey) throw new Error("root key not set");616 const slotA = await loadSlot(SLOT_A);617 if (!slotA) throw new Error("missing slot A");618 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);619 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });620}621622623624625626627async function reencryptVaultForNewKey(628 newKey: Uint8Array,629 slotA: StoredVault630): Promise<void> {631 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);632 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");633 await writeSlot(SLOT_A, {634 ...slotA,635 nonce,636 ciphertext,637 tag,638 });639}640641642643644645646export async function changePasswordWithNewKey(647 newSalt: Uint8Array,648 newKey: Uint8Array649): Promise<void> {650 const slotA = await loadSlot(SLOT_A);651 if (!slotA) throw new Error("missing slot A");652 653 654 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });655 if (derivedKey) zeroKey(derivedKey);656 derivedKey = newKey;657 658 659 660 void saveSessionKey(newKey, "real");661 662 663 664 665 if (rootKey) {666 const slotAfter = await loadSlot(SLOT_A);667 if (slotAfter) {668 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);669 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });670 }671 }672 if (!duressConfigured) {673 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));674 }675 await clearPinUnlockStorage();676 await stampLocalVaultMutation();677}678679680681682683684export { deriveKey, generateSalt };685686export async function loadSlotA(): Promise<StoredVault | null> {687 return loadSlot(SLOT_A);688}689690691692693694695696697698699async function deriveRootKeyFromDerivedKey(700 derivedKeyBytes: Uint8Array,701 slotA: StoredVault,702): Promise<Uint8Array | null> {703 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {704 return null;705 }706 try {707 const rootHex = await decrypt(708 derivedKeyBytes,709 slotA.pw_root_nonce,710 slotA.pw_root_ciphertext,711 slotA.pw_root_tag,712 "pw_root_v1",713 );714 return unhex(rootHex);715 } catch {716 return null;717 }718}719720721722async function wrapRootKeyForDerivedKey(723 derivedKeyBytes: Uint8Array,724 rootKeyBytes: Uint8Array,725): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {726 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");727 return {728 pw_root_ciphertext: ciphertext,729 pw_root_nonce: nonce,730 pw_root_tag: tag,731 };732}733734735export async function getRecoveryWords(): Promise<string[]> {736 if (!rootKey) {737 throw new Error("recovery not set up");738 }739 return rootKeyToWords(rootKey);740}741742async function loadSlot(key: string): Promise<StoredVault | null> {743 const result = await chrome.storage.local.get(key);744 return (result as Record<string, StoredVault | undefined>)[key] ?? null;745}746747async function writeSlot(key: string, vault: StoredVault): Promise<void> {748 try {749 await chrome.storage.local.set({ [key]: vault });750 } catch (err) {751 throw mapStorageError(err);752 }753}754755756757758759760761762763764export async function rollbackAdoptedVault(): Promise<void> {765 lock();766 try {767 await chrome.storage.local.remove([768 SLOT_A,769 SLOT_B,770 "peach_last_modified_at",771 ]);772 } catch (err) {773 throw mapStorageError(err);774 }775}776777async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {778 const raw = await chrome.storage.local.get("peach_last_modified_at");779 const previous = Number(780 (raw as Record<string, unknown>).peach_last_modified_at,781 );782 const next = Math.max(783 Date.now(),784 Number.isFinite(previous) ? previous + 1 : 0,785 );786 try {787 await chrome.storage.local.set({788 [SLOT_A]: slotA,789 [SLOT_B]: slotB,790 peach_last_modified_at: String(next),791 });792 } catch (err) {793 throw mapStorageError(err);794 }795}796797798799800801802803804let lastEntryTimestamp = 0;805function monotonicEntryTimestamp(currentModifiedAt?: number): number {806 const base = Math.max(807 Date.now(),808 (currentModifiedAt ?? 0) + 1,809 lastEntryTimestamp + 1,810 );811 lastEntryTimestamp = base;812 return base;813}814815async function stampLocalVaultMutation(): Promise<void> {816 const raw = await chrome.storage.local.get("peach_last_modified_at");817 const previous = Number(818 (raw as Record<string, unknown>).peach_last_modified_at,819 );820 const next = Math.max(821 Date.now(),822 Number.isFinite(previous) ? previous + 1 : 0,823 );824 await chrome.storage.local.set({825 peach_last_modified_at: String(next),826 });827}828829830831832833834835836837838839function padPlaintext(json: string, minBytes = 0): string {840 const encoded = new TextEncoder().encode(json);841 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);842 843 const target = Math.max(bucket, minBytes);844 const padLen = target - encoded.length;845 return json + " ".repeat(padLen);846}847848849850851852853854855856857858const CSPRNG_CHUNK = 65_536;859860function fillRandom(buf: Uint8Array): Uint8Array {861 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {862 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);863 const chunk = new Uint8Array(end - offset);864 crypto.getRandomValues(chunk);865 buf.set(chunk, offset);866 }867 return buf;868}869870function generateNoiseBlob(ciphertextBytes: number): StoredVault {871 const salt = crypto.getRandomValues(new Uint8Array(16));872 const nonce = crypto.getRandomValues(new Uint8Array(12));873 const cipher = fillRandom(new Uint8Array(ciphertextBytes));874 const tag = crypto.getRandomValues(new Uint8Array(16));875 return {876 version: 1,877 salt: hex(salt),878 nonce: hex(nonce),879 ciphertext: hex(cipher),880 tag: hex(tag),881 };882}883884885886887888type VaultPayload = {889 entries: VaultEntry[];890 tombstones?: Record<string, number>;891 duressConfigured?: boolean;892};893894type DecryptedPayload = {895 entries: VaultEntry[];896 tombstones: Map<string, number>;897 duressConfigured: boolean;898};899900function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {901 const envelope: VaultPayload = {902 entries,903 tombstones: Object.fromEntries(tombstones),904 duressConfigured: duress,905 };906 return padPlaintext(JSON.stringify(envelope), minBytes);907}908909function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {910 const result = new Map<string, number>();911 if (raw && typeof raw === "object" && !Array.isArray(raw)) {912 for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {913 if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {914 result.set(id, timestamp);915 }916 }917 }918 919 920 for (const entry of allEntries) {921 if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {922 continue;923 }924 if ((result.get(entry.id) ?? 0) < entry.deletedAt) {925 result.set(entry.id, entry.deletedAt);926 }927 }928 return result;929}930931function reconcileTombstones(): void {932 const byId = new Map(entries.map((entry) => [entry.id, entry]));933 for (const [id] of tombstones) {934 if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);935 }936 for (const entry of entries) {937 if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {938 if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {939 tombstones.set(entry.id, entry.deletedAt);940 }941 }942 }943}944945function decodePayload(plain: string): DecryptedPayload {946 const parsed = JSON.parse(plain) as VaultPayload;947 if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {948 throw new Error("invalid vault payload");949 }950 const normalized = parsed.entries.map(normalizeEntry);951 return {952 entries: normalized,953 tombstones: decodeTombstones(parsed.tombstones, normalized),954 duressConfigured: parsed.duressConfigured === true,955 };956}957958959960961962963964965966967968969970971972973974975976977978function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {979 let merged = false;980 const now = Date.now();981982 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);983 const byRpId = new Map<string, VaultEntry[]>();984 for (const e of candidates) {985 const rpId = e.passkey!.rpId;986 const list = byRpId.get(rpId);987 if (list) list.push(e);988 else byRpId.set(rpId, [e]);989 }990991 for (const [rpId, group] of byRpId) {992 if (group.length === 0) continue;993 group.sort((a, b) => {994 const aT =995 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;996 const bT =997 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;998 return bT - aT;999 });1000 const winner = group[0]!;10011002 1003 1004 1005 const loginTarget = entries.find((e) => {1006 if (e.deletedAt || e.passkey || e.type !== "login") return false;1007 if (e.url && extractDomain(e.url) === rpId) return true;1008 for (const extra of e.additionalUrls ?? []) {1009 if (extractDomain(extra) === rpId) return true;1010 }1011 return false;1012 });10131014 if (loginTarget) {1015 loginTarget.passkey = winner.passkey;1016 1017 1018 1019 if (!loginTarget.totp && winner.totp) {1020 loginTarget.totp = winner.totp;1021 loginTarget.totpAlgorithm = winner.totpAlgorithm;1022 loginTarget.totpDigits = winner.totpDigits;1023 loginTarget.totpPeriod = winner.totpPeriod;1024 }1025 loginTarget.modifiedAt = now;1026 for (const dup of group) {1027 if (!dup.deletedAt) {1028 dup.deletedAt = now;1029 dup.modifiedAt = now;1030 }1031 }1032 merged = true;1033 } else if (group.length > 1) {1034 for (let i = 1; i < group.length; i += 1) {1035 const dup = group[i]!;1036 if (!dup.deletedAt) {1037 dup.deletedAt = now;1038 dup.modifiedAt = now;1039 }1040 }1041 merged = true;1042 }1043 }10441045 return { merged };1046}10471048104910501051105210531054function hasValidVaultSchema(v: unknown): v is StoredVault {1055 if (typeof v !== "object" || v === null) return false;1056 const r = v as Record<string, unknown>;1057 return (1058 r.version === 1 &&1059 typeof r.salt === "string" &&1060 typeof r.nonce === "string" &&1061 typeof r.ciphertext === "string" &&1062 typeof r.tag === "string"1063 );1064}10651066export type TryDecryptResult =1067 | { status: "ok"; payload: DecryptedPayload }1068 | { status: "wrong_key" }1069 | { status: "corrupted"; reason: string };1070107110721073107410751076export async function tryDecryptSlot(1077 vault: StoredVault,1078 key: Uint8Array,1079 aadTag?: string,1080): Promise<DecryptedPayload | null> {1081 const res = await tryDecryptSlotResult(vault, key, aadTag);1082 return res.status === "ok" ? res.payload : null;1083}10841085export async function tryDecryptSlotResult(1086 vault: StoredVault,1087 key: Uint8Array,1088 aadTag?: string,1089): Promise<TryDecryptResult> {1090 if (!hasValidVaultSchema(vault)) {1091 return { status: "corrupted", reason: "invalid_slot_schema" };1092 }1093 let plain: string;1094 try {1095 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1096 } catch {1097 return { status: "wrong_key" };1098 }1099 try {1100 const parsed = JSON.parse(plain) as unknown;1101 if (typeof parsed !== "object" || parsed === null) {1102 return { status: "corrupted", reason: "invalid_payload_shape" };1103 }1104 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1105 return { status: "corrupted", reason: "invalid_payload_shape" };1106 }1107 return { status: "ok", payload: decodePayload(plain) };1108 } catch {1109 return { status: "corrupted", reason: "invalid_payload_json" };1110 }1111}111211131114111511161117function buildDomainIndexSync(all: VaultEntry[]): void {1118 domainIndex = new Map();11191120 for (const entry of all) {1121 if (entry.deletedAt) continue;1122 if (entry.type === "note" || entry.type === "card") continue;11231124 1125 1126 1127 1128 const domains = new Set<string>();1129 const primary = extractDomain(entry.url ?? "");1130 if (primary) domains.add(primary);1131 for (const extra of entry.additionalUrls ?? []) {1132 const d = extractDomain(extra);1133 if (d) domains.add(d);1134 }1135 if (domains.size === 0) continue;11361137 for (const domain of domains) {1138 const bucket = domainIndex.get(domain) ?? [];1139 bucket.push(entry);1140 domainIndex.set(domain, bucket);1141 }1142 }1143}11441145export async function createVault(password: string): Promise<void> {1146 const salt = generateSalt();1147 derivedKey = await deriveKey(password, salt);1148 entries = [];1149 tombstones = new Map();1150 vaultMode = "real";1151 duressConfigured = false;1152 buildDomainIndexSync(entries);11531154 1155 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1156 await generateAndWrapRootKey();1157 rootKey = unhex(rawRootKey);11581159 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1160 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");11611162 1163 1164 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);11651166 const slotA: StoredVault = {1167 version: 1,1168 salt: hex(salt),1169 nonce,1170 ciphertext,1171 tag,1172 recovery_salt: recoverySalt,1173 recovery_wrapped_root_key: wrappedRootKey,1174 recovery_nonce: wrappedNonce,1175 recovery_tag: wrappedTag,1176 ...pwRoot,1177 };1178 await writeSlot(SLOT_A, slotA);11791180 1181 1182 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1183 await stampLocalVaultMutation();11841185 touchAutoLock();1186}1187118811891190119111921193export async function createVaultFromRootKey(1194 password: string,1195 rootKeyHex: string,1196): Promise<{ vaultId: string }> {1197 if (typeof password !== "string" || password.length === 0) {1198 throw new Error("ADOPT_INVALID_PASSWORD");1199 }1200 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1201 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1202 }12031204 const rootKeyBytes = unhex(rootKeyHex);1205 if (rootKeyBytes.length !== 32) {1206 rootKeyBytes.fill(0);1207 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1208 }12091210 let nextDerivedKey: Uint8Array | null = null;1211 let sessionRootKey: Uint8Array | null = null;1212 try {1213 if ((await loadSlot(SLOT_A)) !== null) {1214 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1215 }12161217 const nextEntries: VaultEntry[] = [];1218 const nextDuressConfigured = false;1219 const vaultId = await computeVaultId(rootKeyBytes);1220 const salt = generateSalt();1221 nextDerivedKey = await deriveKey(password, salt);1222 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);12231224 sessionRootKey = new Uint8Array(rootKeyBytes);1225 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1226 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1227 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1228 const slotA: StoredVault = {1229 version: 1,1230 salt: hex(salt),1231 nonce,1232 ciphertext,1233 tag,1234 recovery_salt: recovery.recoverySalt,1235 recovery_wrapped_root_key: recovery.wrappedRootKey,1236 recovery_nonce: recovery.wrappedNonce,1237 recovery_tag: recovery.wrappedTag,1238 ...pwRoot,1239 };1240 const slotB = generateNoiseBlob(ciphertext.length / 2);1241 await commitCreatedVaultSlots(slotA, slotB);12421243 const previousDerivedKey = derivedKey;1244 const previousRootKey = rootKey;1245 derivedKey = nextDerivedKey;1246 rootKey = sessionRootKey;1247 nextDerivedKey = null;1248 sessionRootKey = null;1249 entries = nextEntries;1250 tombstones = new Map();1251 vaultMode = "real";1252 duressConfigured = nextDuressConfigured;1253 buildDomainIndexSync(entries);1254 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1255 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1256 touchAutoLock();12571258 return { vaultId };1259 } finally {1260 rootKeyBytes.fill(0);1261 if (nextDerivedKey) zeroKey(nextDerivedKey);1262 if (sessionRootKey) sessionRootKey.fill(0);1263 }1264}12651266export async function hasVault(): Promise<boolean> {1267 return (await loadSlot(SLOT_A)) !== null;1268}1269127012711272127312741275export async function isDuressEnabled(): Promise<boolean> {1276 return vaultMode === "real" && duressConfigured;1277}12781279export async function setupDuress(duressPassword: string): Promise<void> {1280 if (!derivedKey || vaultMode !== "real") {1281 throw new Error("vault must be unlocked in real mode");1282 }1283 1284 1285 1286 const slotA = await loadSlot(SLOT_A);1287 if (!slotA) throw new Error("missing slot A");12881289 const decoySalt = generateSalt();1290 const decoyKey = await deriveKey(duressPassword, decoySalt);1291 try {1292 1293 1294 1295 1296 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1297 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1298 await writeSlot(SLOT_B, {1299 version: 1,1300 salt: hex(decoySalt),1301 nonce,1302 ciphertext,1303 tag,1304 });1305 } finally {1306 zeroKey(decoyKey);1307 }13081309 1310 1311 duressConfigured = true;1312 await persistSlotA();1313}13141315export async function disableDuress(): Promise<void> {1316 if (!derivedKey || vaultMode !== "real") {1317 throw new Error("vault must be unlocked in real mode");1318 }1319 1320 1321 1322 const slotA = await loadSlot(SLOT_A);1323 if (slotA) {1324 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1325 }13261327 duressConfigured = false;1328 await persistSlotA();1329}13301331133213331334133513361337export async function verifyMainPassword(password: string): Promise<boolean> {1338 const slotA = await loadSlot(SLOT_A);1339 if (!slotA) return false;1340 const key = await deriveKey(password, unhex(slotA.salt));1341 try {1342 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1343 } finally {1344 zeroKey(key);1345 }1346}13471348export async function unlock(password: string): Promise<boolean> {1349 return attemptUnlockWithDerive(password, deriveKey);1350}13511352async function attemptUnlockWithDerive(1353 password: string,1354 derive: typeof deriveKey,1355): Promise<boolean> {1356 1357 1358 1359 1360 1361 1362 const [slotA, slotB] = await Promise.all([1363 loadSlot(SLOT_A),1364 loadSlot(SLOT_B),1365 ]);13661367 1368 const [keyA, keyB] = await Promise.all([1369 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1370 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1371 ]);13721373 1374 1375 1376 const [resultA, resultB] = await Promise.all([1377 keyA && slotA1378 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1379 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1380 keyB && slotB1381 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1382 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1383 ]);1384 const payloadA = resultA.status === "ok" ? resultA.payload : null;1385 const payloadB = resultB.status === "ok" ? resultB.payload : null;13861387 if (payloadA && keyA) {1388 derivedKey = keyA;1389 void saveSessionKey(keyA, "real");1390 vaultMode = "real";1391 entries = payloadA.entries;1392 tombstones = payloadA.tombstones;1393 duressConfigured = payloadA.duressConfigured;1394 buildDomainIndexSync(entries);1395 await loadSettings();1396 touchAutoLock();1397 if (keyB) zeroKey(keyB);13981399 1400 1401 rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1402 if (!rootKey) {1403 lock();1404 return false;1405 }14061407 await lockedSyncUnlockHook?.().catch((error) => {1408 console.warn("[Peach sync] locked inbox drain failed:", error);1409 });1410 await resetPinFailedAttempts();1411 return true;1412 }14131414 if (payloadB && keyB) {1415 derivedKey = keyB;1416 void saveSessionKey(keyB, "decoy");1417 vaultMode = "decoy";1418 entries = payloadB.entries;1419 tombstones = payloadB.tombstones;1420 duressConfigured = false;1421 buildDomainIndexSync(entries);1422 await loadSettings();1423 touchAutoLock();1424 if (keyA) zeroKey(keyA);1425 return true;1426 }14271428 if (keyA) zeroKey(keyA);1429 if (keyB) zeroKey(keyB);1430 1431 1432 if (resultA.status === "corrupted") {1433 lastUnlockFailureReason = resultA.reason;1434 } else if (resultB.status === "corrupted") {1435 lastUnlockFailureReason = resultB.reason;1436 }1437 return false;1438}14391440144114421443144414451446const SESSION_KEY_STORAGE = "peach_session_dk";14471448async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1449 if (!chrome.storage.session) return;1450 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1451}14521453async function clearSessionKey(): Promise<void> {1454 if (!chrome.storage.session) return;1455 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1456}145714581459146014611462146314641465export async function restoreFromSession(): Promise<boolean> {1466 if (derivedKey !== null) return true;1467 if (!chrome.storage.session) return false;1468 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1469 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1470 if (!entry?.keyHex) return false;14711472 const key = unhex(entry.keyHex);1473 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1474 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1475 const slot = await loadSlot(slotKey);1476 if (!slot) { await clearSessionKey(); return false; }14771478 const result = await tryDecryptSlotResult(slot, key, aadTag);1479 if (result.status !== "ok") { await clearSessionKey(); return false; }14801481 derivedKey = key;1482 vaultMode = entry.mode;1483 entries = result.payload.entries;1484 tombstones = result.payload.tombstones;1485 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1486 buildDomainIndexSync(entries);1487 await loadSettings();1488 touchAutoLock();14891490 1491 1492 if (entry.mode === "real") {1493 rootKey = await deriveRootKeyFromDerivedKey(key, slot);1494 if (!rootKey) {1495 lock();1496 return false;1497 }1498 }14991500 await lockedSyncUnlockHook?.().catch((error) => {1501 console.warn("[Peach sync] locked inbox drain failed:", error);1502 });15031504 return true;1505}15061507export function lock(): void {1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 void clearSessionKey();1518 void clearRecoveryConfirmPending();1519 if (derivedKey) {1520 zeroKey(derivedKey);1521 derivedKey = null;1522 }1523 if (rootKey) {1524 rootKey.fill(0);1525 rootKey = null;1526 }15271528 entries = [];1529 tombstones = new Map();1530 domainIndex = new Map();1531 vaultMode = "real";1532 duressConfigured = false;1533 clearAutoLock();1534}15351536export function isUnlocked(): boolean {1537 return derivedKey !== null;1538}15391540154115421543export async function unlockWithKey(1544 masterKeyHex: string,1545): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1546 try {1547 const masterKeyBytes = unhex(masterKeyHex);1548 const stored = await loadSlot(SLOT_A);1549 if (!stored) return { ok: false, error: "invalid_key" };1550 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1551 if (!payload) return { ok: false, error: "invalid_key" };15521553 derivedKey = masterKeyBytes;1554 vaultMode = "real";1555 entries = payload.entries;1556 tombstones = payload.tombstones;1557 duressConfigured = payload.duressConfigured;1558 buildDomainIndexSync(entries);1559 await loadSettings();1560 touchAutoLock();15611562 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1563 if (!rootKey) {1564 lock();1565 return { ok: false, error: "invalid_key" };1566 }1567 return { ok: true, entries };1568 } catch {1569 return { ok: false, error: "invalid_key" };1570 }1571}157215731574157515761577157815791580export async function exportDuressSlotForAdoption(): Promise<string | null> {1581 if (!(await isDuressEnabled())) return null;1582 const slotB = await loadSlot(SLOT_B);1583 if (!slotB) return null;1584 return JSON.stringify(slotB);1585}15861587158815891590159115921593export async function installAdoptedDuressSlot(json: string): Promise<void> {1594 let parsed: StoredVault;1595 try {1596 const raw = JSON.parse(json) as unknown;1597 if (!hasValidVaultSchema(raw)) {1598 throw new Error("invalid_duress_slot_shape");1599 }1600 parsed = raw;1601 } catch (err) {1602 if (err instanceof SyntaxError) {1603 throw new Error("invalid_duress_slot_shape");1604 }1605 throw err;1606 }1607 await writeSlot(SLOT_B, parsed);1608}16091610161116121613161416151616161716181619162016211622162316241625162616271628export async function unlockFromCodex(params: {1629 slotA: string; 1630 slotB: string | null; 1631 timestamp: number;1632 masterPassphrase: string;1633}): Promise<1634 | { ok: true; entryCount: number }1635 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1636> {1637 let parsedSlotA: StoredVault;1638 let parsedSlotB: StoredVault | null = null;1639 try {1640 const rawA = JSON.parse(params.slotA) as unknown;1641 if (!hasValidVaultSchema(rawA)) {1642 return { ok: false, reason: "invalid_slot_format" };1643 }1644 parsedSlotA = rawA;1645 if (params.slotB !== null) {1646 const rawB = JSON.parse(params.slotB) as unknown;1647 if (!hasValidVaultSchema(rawB)) {1648 return { ok: false, reason: "invalid_slot_format" };1649 }1650 parsedSlotB = rawB;1651 }1652 } catch {1653 return { ok: false, reason: "invalid_slot_format" };1654 }16551656 1657 1658 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1659 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1660 if (!trialPayload) {1661 zeroKey(trialKey);1662 return { ok: false, reason: "wrong_passphrase" };1663 }1664 1665 1666 zeroKey(trialKey);16671668 1669 1670 await takeVaultSnapshot("restore");16711672 await writeSlot(SLOT_A, parsedSlotA);1673 if (parsedSlotB) {1674 await writeSlot(SLOT_B, parsedSlotB);1675 } else {1676 1677 1678 1679 await writeSlot(1680 SLOT_B,1681 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1682 );1683 }1684 await chrome.storage.local.set({1685 peach_last_modified_at: String(params.timestamp),1686 });16871688 1689 1690 const ok = await unlock(params.masterPassphrase);1691 if (!ok) {1692 1693 1694 1695 return { ok: false, reason: "wrong_passphrase" };1696 }1697 return { ok: true, entryCount: getEntries().length };1698}16991700170117021703export function getDerivedKeyHex(): string {1704 if (!derivedKey) throw new Error("vault_not_unlocked");1705 return hex(derivedKey);1706}17071708170917101711171217131714171517161717171817191720172117221723export function getRootKeyHex(): string | null {1724 return rootKey ? hex(rootKey) : null;1725}17261727export function getEntries(): VaultEntry[] {1728 if (!derivedKey) throw new Error("vault_locked");1729 return entries.filter((entry) => !entry.deletedAt);1730}17311732const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;17331734export function getIdentities(): SavedIdentity[] {1735 if (!derivedKey) throw new Error("vault_locked");1736 const active = entries.filter((e) => !e.deletedAt && e.type === "login");17371738 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1739 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();17401741 for (const entry of active) {1742 const domain = extractDomain(entry.url ?? "") ?? "";1743 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;17441745 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1746 const existing = map.get(v);1747 if (!existing || modifiedAt > existing.lastUsed) {1748 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1749 } else {1750 map.set(v, { ...existing, count: existing.count + 1 });1751 }1752 }17531754 const uVal = entry.username?.trim();1755 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);17561757 1758 1759 const eVal = entry.email?.trim();1760 if (eVal) tally(emailMap, eVal);1761 }17621763 const toIdentity = (type: "email" | "username") =>1764 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1765 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1766 });17671768 return [1769 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1770 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1771 ];1772}17731774export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1775 if (!derivedKey) throw new Error("vault_locked");17761777 const now = Date.now();1778 const added: VaultEntry[] = [];1779 for (const draft of drafts) {1780 try {1781 const sanitized = sanitizeEntryDraft(draft);1782 const nextEntry: VaultEntry = {1783 ...sanitized,1784 id: crypto.randomUUID(),1785 createdAt: now,1786 modifiedAt: monotonicEntryTimestamp(),1787 };1788 entries.push(nextEntry);1789 added.push(nextEntry);1790 } catch {1791 1792 }1793 }17941795 if (added.length > 0) {1796 await persistEntries();1797 }17981799 return added;1800}180118021803180418051806180718081809export async function restoreEntries(1810 incoming: VaultEntry[],1811 mode: "replace" | "merge"1812): Promise<{ added: number; updated: number; skipped: number }> {1813 if (!derivedKey) throw new Error("vault_locked");18141815 1816 1817 1818 if (mode === "replace" && incoming.length === 0) {1819 throw new Error("refuse to replace with empty entry set");1820 }18211822 let added = 0;1823 let updated = 0;1824 let skipped = 0;18251826 if (mode === "replace") {1827 entries = [];1828 for (const entry of incoming) {1829 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1830 if (!normalized) {1831 skipped += 1;1832 continue;1833 }1834 entries.push(normalized);1835 if (normalized.deletedAt) {1836 tombstones.set(1837 normalized.id,1838 Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),1839 );1840 } else {1841 tombstones.delete(normalized.id);1842 }1843 added += 1;1844 }1845 } else {1846 const byId = new Map<string, VaultEntry>();1847 for (const e of entries) byId.set(e.id, e);18481849 for (const entry of incoming) {1850 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1851 if (!normalized) {1852 skipped += 1;1853 continue;1854 }1855 const existing = byId.get(normalized.id);1856 if (!existing) {1857 entries.push(normalized);1858 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1859 else tombstones.delete(normalized.id);1860 added += 1;1861 } else if (normalized.modifiedAt > existing.modifiedAt) {1862 const idx = entries.findIndex((e) => e.id === existing.id);1863 if (idx !== -1) {1864 entries[idx] = normalized;1865 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);1866 else tombstones.delete(normalized.id);1867 updated += 1;1868 }1869 } else {1870 skipped += 1;1871 }1872 }1873 }18741875 await persistEntries("import");1876 return { added, updated, skipped };1877}187818791880188118821883188418851886188718881889189018911892189318941895const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;18961897function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {1898 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;1899 return { ...entry, id: crypto.randomUUID() };1900}190119021903190419051906190719081909function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1910 if (!entry || typeof entry !== "object") return null;1911 const title = typeof entry.title === "string" ? entry.title.trim() : "";1912 if (!title) return null;1913 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1914 const now = Date.now();1915 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1916 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;19171918 1919 1920 const normalized = normalizeEntry({1921 ...entry,1922 id,1923 title,1924 type: entry.type ?? "login",1925 createdAt,1926 modifiedAt,1927 });19281929 if (normalized.passkey) {1930 normalized.passkey = sanitizePasskey(normalized.passkey);1931 }1932 if (normalized.canary) {1933 normalized.canary = sanitizeCanary(normalized.canary);1934 }1935 1936 normalized.tags = sanitizeTags(normalized.tags);19371938 1939 1940 if (1941 normalized.type === "login" &&1942 !normalized.passkey &&1943 typeof normalized.password !== "string"1944 ) {1945 return null;1946 }1947 if (entry.passkey && !normalized.passkey) {1948 1949 1950 return null;1951 }19521953 return normalized;1954}1955195619571958195919601961function sanitizePasskey(pk: unknown): PasskeyData | undefined {1962 if (!pk || typeof pk !== "object") return undefined;1963 const r = pk as Record<string, unknown>;1964 if (1965 typeof r.rpId !== "string" || !r.rpId ||1966 typeof r.credentialId !== "string" || !r.credentialId ||1967 typeof r.publicKeyCose !== "string" ||1968 typeof r.userHandle !== "string" ||1969 typeof r.userName !== "string"1970 ) {1971 return undefined;1972 }1973 return {1974 rpId: r.rpId,1975 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,1976 credentialId: r.credentialId,1977 publicKeyCose: r.publicKeyCose,1978 privateKeyJwk:1979 r.privateKeyJwk && typeof r.privateKeyJwk === "object"1980 ? (r.privateKeyJwk as JsonWebKey)1981 : undefined,1982 userHandle: r.userHandle,1983 userName: r.userName,1984 userDisplayName:1985 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,1986 algorithm: r.algorithm === -8 ? -8 : -7,1987 signCount: typeof r.signCount === "number" ? r.signCount : 0,1988 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",1989 backupEligible: r.backupEligible === true,1990 backupState: r.backupState === true,1991 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,1992 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,1993 transports: Array.isArray(r.transports)1994 ? r.transports.filter((t): t is string => typeof t === "string")1995 : undefined,1996 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),1997 };1998}19992000200120022003200420052006200720082009function sanitizeCanary(c: unknown): Canary | undefined {2010 if (!c || typeof c !== "object") return undefined;2011 const r = c as Record<string, unknown>;2012 if (typeof r.webhookUrl !== "string") return undefined;2013 let url: URL;2014 try {2015 url = new URL(r.webhookUrl);2016 } catch {2017 return undefined;2018 }2019 if (url.protocol !== "https:") return undefined;2020 return {2021 webhookUrl: url.toString(),2022 label: typeof r.label === "string" ? r.label : undefined,2023 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2024 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2025 };2026}20272028202920302031203220332034function sanitizeTags(tags: unknown): string[] | undefined {2035 if (!Array.isArray(tags)) return undefined;2036 const out: string[] = [];2037 for (const t of tags) {2038 if (typeof t !== "string") continue;2039 const trimmed = t.trim();2040 if (!trimmed) continue;2041 out.push(trimmed.slice(0, 256));2042 if (out.length >= 255) break;2043 }2044 return out.length > 0 ? out : undefined;2045}204620472048204920502051export async function clearVaultEntries(2052 confirmation: "REPLACE_ALL"2053): Promise<void> {2054 if (confirmation !== "REPLACE_ALL") {2055 throw new Error("clearVaultEntries requires explicit confirmation");2056 }2057 if (!derivedKey) throw new Error("vault_locked");2058 for (const entry of entries) {2059 if (!entry.deletedAt) {2060 const stamp = monotonicEntryTimestamp(Math.max(2061 entry.modifiedAt,2062 tombstones.get(entry.id) ?? 0,2063 ));2064 entry.deletedAt = stamp;2065 entry.modifiedAt = stamp;2066 tombstones.set(entry.id, stamp);2067 }2068 }2069 await persistEntries();2070}20712072export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2073 if (!derivedKey) throw new Error("vault_locked");20742075 const sanitized = sanitizeEntryDraft(entry);2076 const now = Date.now();2077 const stamp = monotonicEntryTimestamp();2078 const nextEntry: VaultEntry = {2079 ...sanitized,2080 id: crypto.randomUUID(),2081 createdAt: now,2082 modifiedAt: stamp,2083 2084 2085 passwordChangedAt: sanitized.password ? stamp : undefined,2086 };20872088 2089 2090 2091 const rollback = entries;2092 entries = [...entries, nextEntry];2093 try {2094 await persistEntries();2095 } catch (err) {2096 entries = rollback;2097 throw err;2098 }20992100 2101 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2102 checkBreach(nextEntry.password).then(async (count) => {2103 if (count > 0) {2104 const idx = entries.findIndex((e) => e.id === nextEntry.id);2105 if (idx !== -1) {2106 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2107 await persistEntries();2108 }2109 }2110 });2111 }21122113 fireSyncTrigger("vault_change");21142115 return nextEntry;2116}21172118211921202121212221232124export async function consolidatePasskeyEntries(): Promise<void> {2125 if (!derivedKey) return;2126 const { merged } = mergePasskeyOnlyIntoLogins(entries);2127 if (merged) {2128 await persistEntries();2129 }2130}21312132export async function updateEntry(2133 id: string,2134 patch: Partial<VaultEntry>2135): Promise<void> {2136 if (!derivedKey) throw new Error("vault_locked");21372138 const idx = entries.findIndex((entry) => entry.id === id);2139 if (idx === -1 || entries[idx].deletedAt) {2140 throw new Error("entry_not_found");2141 }21422143 const current = entries[idx];2144 const sanitizedPatch = sanitizeEntryPatch(patch);2145 2146 2147 2148 const passwordChanged =2149 "password" in patch &&2150 typeof sanitizedPatch.password === "string" &&2151 sanitizedPatch.password !== current.password;2152 const stamp = monotonicEntryTimestamp(current.modifiedAt);2153 2154 2155 2156 2157 2158 2159 2160 const priorPassword = current.password;2161 const shouldCapture =2162 passwordChanged &&2163 typeof priorPassword === "string" &&2164 priorPassword.length > 0 &&2165 current.passwordHistory?.[0]?.password !== priorPassword;2166 const nextHistory = shouldCapture2167 ? [2168 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2169 ...(current.passwordHistory ?? []),2170 ].slice(0, PASSWORD_HISTORY_MAX)2171 : current.passwordHistory;2172 entries[idx] = {2173 ...current,2174 ...sanitizedPatch,2175 modifiedAt: stamp,2176 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2177 };2178217921802181 2182 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2183 entries[idx] = current;2184 throw new Error("card number is required for card entries");2185 }21862187 2188 2189 2190 2191 try {2192 await persistEntries();2193 } catch (err) {2194 entries[idx] = current;2195 throw err;2196 }21972198 2199 2200 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2201 entries[idx] = { ...entries[idx], breach: undefined };2202 await persistEntries();2203 const password = entries[idx].password;2204 if (password) {2205 checkBreach(password).then(async (count) => {2206 const i = entries.findIndex((e) => e.id === id);2207 if (i !== -1 && count > 0) {2208 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2209 await persistEntries();2210 }2211 });2212 }2213 }22142215 fireSyncTrigger("vault_change");2216}22172218export async function deleteEntry(id: string): Promise<void> {2219 if (!derivedKey) throw new Error("vault_locked");22202221 const entry = entries.find((item) => item.id === id);2222 if (!entry || entry.deletedAt) {2223 throw new Error("entry_not_found");2224 }22252226 2227 2228 2229 const previousModifiedAt = entry.modifiedAt;2230 const previousTombstone = tombstones.get(id);2231 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2232 entry.deletedAt = stamp;2233 entry.modifiedAt = stamp;2234 tombstones.set(id, stamp);2235 2236 2237 try {2238 await persistEntries();2239 } catch (err) {2240 delete entry.deletedAt;2241 entry.modifiedAt = previousModifiedAt;2242 if (previousTombstone === undefined) tombstones.delete(id);2243 else tombstones.set(id, previousTombstone);2244 throw err;2245 }2246 fireSyncTrigger("vault_change");2247}22482249export function getDeletedEntries(): VaultEntry[] {2250 if (!derivedKey) return [];2251 return entries.filter((e) => !!e.deletedAt);2252}225322542255export function getTombstoneManifest(): Record<string, number> {2256 if (!derivedKey) return {};2257 return Object.fromEntries(tombstones);2258}2259226022612262226322642265export async function getVaultVersion(): Promise<number> {2266 try {2267 const raw = await chrome.storage.local.get("peach_last_modified_at");2268 const v = (raw as Record<string, unknown>).peach_last_modified_at;2269 if (typeof v === "number") return v;2270 if (typeof v === "string" && v !== "") {2271 const n = Number.parseInt(v, 10);2272 return Number.isFinite(n) ? n : 0;2273 }2274 return 0;2275 } catch {2276 return 0;2277 }2278}2279228022812282228322842285228622872288228922902291export async function applyMergedRemoteEntries(2292 remoteEntries: VaultEntry[],2293 remoteTombstones: Record<string, number>,2294 _context: { deviceName?: string } = {},2295): Promise<{ received: number; applied: number; conflicts: number }> {2296 2297 2298 2299 2300 2301 2302 if (!derivedKey) throw new Error("vault_locked");23032304 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2305 let applied = 0;2306 let conflicts = 0;23072308 const byId = new Map<string, VaultEntry>();2309 for (const e of entries) byId.set(e.id, e);23102311 const mergeNow = Date.now();2312 const remoteTombstoneTimes = new Map<string, number>();2313 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2314 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2315 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2316 }2317 }23182319 for (const remote of remoteEntries) {2320 const sanitized = sanitizeRestoredEntry(remote);2321 if (!sanitized) continue;2322 2323 2324 2325 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2326 if (typeof sanitized.deletedAt === "number") {2327 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2328 }2329 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2330 const localTombstoneTs = tombstones.get(sanitized.id);2331 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2332 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2333 continue;2334 }2335 const local = byId.get(sanitized.id);2336 if (!local) {2337 entries.push(sanitized);2338 byId.set(sanitized.id, sanitized);2339 tombstones.delete(sanitized.id);2340 applied += 1;2341 } else if (sanitized.modifiedAt > local.modifiedAt) {2342 const idx = entries.findIndex((e) => e.id === local.id);2343 if (idx !== -1) {2344 entries[idx] = sanitized;2345 byId.set(sanitized.id, sanitized);2346 tombstones.delete(sanitized.id);2347 applied += 1;2348 if (!local.deletedAt) conflicts += 1;2349 }2350 }2351 }23522353 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2354 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2355 2356 const deletedAt = remoteTombstoneTimes.get(id)!;2357 const knownTombstone = tombstones.get(id);2358 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2359 const local = byId.get(id);2360 if (!local) {2361 tombstones.set(id, deletedAt);2362 applied += 1;2363 continue;2364 }2365 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2366 if (deletedAt < local.modifiedAt) continue;2367 const idx = entries.findIndex((e) => e.id === id);2368 if (idx !== -1) {2369 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2370 tombstones.set(id, deletedAt);2371 applied += 1;2372 }2373 }23742375 if (applied > 0) {2376 await persistEntries("sync_received");2377 buildDomainIndexSync(entries);2378 }2379 return { received, applied, conflicts };2380}23812382export async function restoreEntry(id: string): Promise<void> {2383 if (!derivedKey) throw new Error("vault_locked");2384 const entry = entries.find((e) => e.id === id && e.deletedAt);2385 if (!entry) throw new Error("deleted entry not found");2386 const previousDeletedAt = entry.deletedAt;2387 const previousModifiedAt = entry.modifiedAt;2388 const previousTombstone = tombstones.get(id);2389 delete entry.deletedAt;2390 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2391 entry.modifiedAt,2392 previousTombstone ?? 0,2393 ));2394 tombstones.delete(id);2395 try {2396 await persistEntries();2397 } catch (err) {2398 entry.deletedAt = previousDeletedAt;2399 entry.modifiedAt = previousModifiedAt;2400 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2401 throw err;2402 }2403 buildDomainIndexSync(entries);2404}24052406export async function purgeEntry(id: string): Promise<void> {2407 if (!derivedKey) throw new Error("vault_locked");2408 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2409 if (idx === -1) throw new Error("deleted entry not found");2410 const entry = entries[idx]!;2411 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2412 entries.splice(idx, 1);2413 await persistEntries();2414}241524162417export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2418 if (!derivedKey || retentionMs === 0) return 0;2419 const cutoff = Date.now() - retentionMs;2420 const before = entries.length;2421 for (const entry of entries) {2422 if (entry.deletedAt && entry.deletedAt < cutoff) {2423 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2424 }2425 }2426 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2427 const purged = before - entries.length;2428 if (purged > 0) await persistEntries();2429 return purged;2430}24312432export function queryDomain(domain: string): VaultEntry[] {2433 return domainIndex.get(domain) ?? [];2434}24352436export function getEntryById(id: string): VaultEntry | undefined {2437 return entries.find((entry) => entry.id === id && !entry.deletedAt);2438}24392440async function persistSlotA(2441 reason: VaultSnapshot["reason"] = "local_save",2442): Promise<StoredVault> {2443 if (!derivedKey) throw new Error("vault_locked");24442445 const slotA = await loadSlot(SLOT_A);2446 if (!slotA) throw new Error("missing slot A");24472448 2449 2450 await takeVaultSnapshot(reason);24512452 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2453 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");24542455 2456 const nextSlotA: StoredVault = {2457 ...slotA,2458 nonce,2459 ciphertext,2460 tag,2461 2462 };2463 await writeSlot(SLOT_A, nextSlotA);24642465 2466 2467 2468 2469 2470 2471 try {2472 2473 2474 2475 if (!duressConfigured) {2476 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2477 }2478 2479 2480 2481 2482 2483 if (reason !== "sync_received") {2484 await stampLocalVaultMutation();2485 }2486 } catch (err) {2487 2488 2489 await writeSlot(SLOT_A, slotA).catch(() => {});2490 throw err;2491 }24922493 return nextSlotA;2494}24952496async function persistSlotB(): Promise<void> {2497 if (!derivedKey) throw new Error("vault_locked");24982499 const slotB = await loadSlot(SLOT_B);2500 if (!slotB) throw new Error("missing slot B");25012502 2503 2504 2505 2506 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2507 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");25082509 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2510 2511 2512 2513 try {2514 await stampLocalVaultMutation();2515 } catch (err) {2516 await writeSlot(SLOT_B, slotB).catch(() => {});2517 throw err;2518 }2519}25202521async function persistEntries(2522 reason: VaultSnapshot["reason"] = "local_save",2523): Promise<void> {2524 if (!derivedKey) throw new Error("vault_locked");25252526 2527 2528 const previousTombstones = tombstones;2529 tombstones = new Map(tombstones);2530 reconcileTombstones();2531 try {2532 if (vaultMode === "decoy") {2533 await persistSlotB();2534 } else {2535 await persistSlotA(reason);2536 }2537 } catch (err) {2538 tombstones = previousTombstones;2539 throw err;2540 }2541 buildDomainIndexSync(entries);2542 await refreshLockedSyncState();2543}2544254525462547254825492550function allowHttpUrl(url: string | undefined): string {2551 const trimmed = url?.trim() ?? "";2552 if (!trimmed) return "";2553 try {2554 const parsed = new URL(trimmed);2555 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2556 } catch {2557 2558 }2559 return trimmed;2560}25612562function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2563 if (!Array.isArray(values)) return undefined;2564 const seen = new Set<string>();2565 const cleaned: string[] = [];2566 for (const value of values) {2567 const trimmed = typeof value === "string" ? value.trim() : "";2568 if (!trimmed) continue;2569 if (!allowHttpUrl(trimmed)) continue;2570 const key = trimmed.toLowerCase();2571 if (seen.has(key)) continue;2572 seen.add(key);2573 cleaned.push(trimmed);2574 }2575 return cleaned.length > 0 ? cleaned : undefined;2576}25772578function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2579 const title = entry.title.trim();2580 const type: VaultEntry["type"] = entry.type ?? "login";25812582 if (!title) throw new Error("title is required");25832584 if (entry.passkey) {2585 const p = entry.passkey;2586 if (2587 !p.rpId ||2588 !p.credentialId ||2589 !p.publicKeyCose ||2590 !p.userHandle ||2591 !p.userName ||2592 (p.algorithm !== -7 && p.algorithm !== -8)2593 ) {2594 throw new Error("invalid passkey credential");2595 }2596 }2597 if (type === "card") {2598 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2599 throw new Error("cardholder name and card number are required");2600 }2601 }26022603 return {2604 type,2605 title,2606 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2607 email: type === "login" ? entry.email?.trim() || undefined : undefined,2608 password: type === "login" ? entry.password : undefined,2609 url: type === "login" ? allowHttpUrl(entry.url) : undefined,2610 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2611 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2612 2613 2614 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2615 totpDigits: type === "login" ? entry.totpDigits : undefined,2616 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2617 notes: entry.notes?.trim() || undefined,2618 breach: type === "login" ? entry.breach : undefined,2619 2620 2621 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2622 tags: sanitizeTags(entry.tags),2623 passkey: entry.passkey,2624 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2625 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2626 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2627 expYear: type === "card" ? entry.expYear?.trim() : undefined,2628 cvc: type === "card" ? entry.cvc?.trim() : undefined,2629 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2630 network: type === "card" ? entry.network : undefined,2631 last4: type === "card" ? entry.last4?.trim() : undefined,2632 };2633}26342635function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2636 const nextPatch: Partial<VaultEntry> = {};26372638 if ("type" in patch) {2639 nextPatch.type = patch.type;2640 }26412642 if ("title" in patch) {2643 const title = patch.title?.trim() ?? "";2644 if (!title) throw new Error("title is required");2645 nextPatch.title = title;2646 }26472648 if ("url" in patch) {2649 nextPatch.url = allowHttpUrl(patch.url);2650 }26512652 if ("additionalUrls" in patch) {2653 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2654 const seen = new Set<string>();2655 const cleaned: string[] = [];2656 for (const value of incoming) {2657 const trimmed = typeof value === "string" ? value.trim() : "";2658 if (!trimmed || !allowHttpUrl(trimmed)) continue;2659 const key = trimmed.toLowerCase();2660 if (seen.has(key)) continue;2661 seen.add(key);2662 cleaned.push(trimmed);2663 }2664 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2665 }26662667 if ("username" in patch) {2668 nextPatch.username = patch.username?.trim() ?? "";2669 }26702671 if ("email" in patch) {2672 nextPatch.email = patch.email?.trim() || undefined;2673 }26742675 if ("password" in patch) {2676 const password = patch.password ?? "";2677 2678 nextPatch.password = password || undefined;2679 }26802681 if ("totp" in patch) {2682 nextPatch.totp = patch.totp?.trim() || undefined;2683 }26842685 if ("notes" in patch) {2686 nextPatch.notes = patch.notes?.trim() || undefined;2687 }26882689 if ("breach" in patch) {2690 nextPatch.breach = patch.breach;2691 }26922693 if ("canary" in patch) {2694 2695 2696 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2697 }26982699 if ("passkey" in patch) {2700 2701 2702 if (patch.passkey === undefined || patch.passkey === null) {2703 nextPatch.passkey = undefined;2704 } else {2705 const sanitized = sanitizePasskey(patch.passkey);2706 if (!sanitized) throw new Error("invalid passkey credential");2707 nextPatch.passkey = sanitized;2708 }2709 }27102711 if ("tags" in patch) {2712 2713 nextPatch.tags = sanitizeTags(patch.tags);2714 }27152716 if ("cardholderName" in patch) {2717 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2718 }2719 if ("cardNumber" in patch) {2720 const n = patch.cardNumber ?? "";2721 nextPatch.cardNumber = n || undefined;2722 }2723 if ("expMonth" in patch) {2724 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2725 }2726 if ("expYear" in patch) {2727 nextPatch.expYear = patch.expYear?.trim() || undefined;2728 }2729 if ("cvc" in patch) {2730 nextPatch.cvc = patch.cvc?.trim() || undefined;2731 }2732 if ("cardNickname" in patch) {2733 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2734 }2735 if ("network" in patch) {2736 nextPatch.network = patch.network;2737 }2738 if ("last4" in patch) {2739 nextPatch.last4 = patch.last4?.trim() || undefined;2740 }27412742 return nextPatch;2743}27442745function normalizeEntry(entry: VaultEntry): VaultEntry {2746 const base: VaultEntry = {2747 id: entry.id,2748 type: entry.type,2749 title: entry.title,2750 username: entry.username,2751 email: entry.email,2752 password: entry.password,2753 url: entry.url,2754 additionalUrls:2755 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2756 ? entry.additionalUrls2757 : undefined,2758 totp: entry.totp,2759 2760 totpAlgorithm: entry.totpAlgorithm,2761 totpDigits: entry.totpDigits,2762 totpPeriod: entry.totpPeriod,2763 notes: entry.notes,2764 breach: entry.breach,2765 2766 2767 2768 passwordChangedAt:2769 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2770 2771 2772 2773 passwordHistory:2774 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2775 ? entry.passwordHistory2776 : undefined,2777 canary: entry.canary,2778 passkey: entry.passkey,2779 createdAt: entry.createdAt,2780 modifiedAt: entry.modifiedAt,2781 deletedAt: entry.deletedAt,2782 tags: entry.tags,2783 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2784 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2785 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2786 expYear: "expYear" in entry ? entry.expYear : undefined,2787 cvc: "cvc" in entry ? entry.cvc : undefined,2788 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2789 network: "network" in entry ? entry.network : undefined,2790 last4: "last4" in entry ? entry.last4 : undefined,2791 };27922793 return base;2794}2795279627972798279928002801function entryDupeKey(e: VaultEntry): string {2802 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2803 2804 2805 2806 2807 if (e.passkey) {2808 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2809 }2810 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2811}281228132814export function countDuplicates(): number {2815 const best = new Map<string, VaultEntry>();2816 let dupeCount = 0;2817 for (const e of entries.filter((e) => !e.deletedAt)) {2818 const key = entryDupeKey(e);2819 const existing = best.get(key);2820 if (!existing) {2821 best.set(key, e);2822 } else {2823 dupeCount++;2824 2825 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2826 best.set(key, e);2827 }2828 }2829 }2830 return dupeCount;2831}283228332834283528362837export async function deduplicateEntries(): Promise<number> {2838 if (!derivedKey) throw new Error("vault_locked");2839 2840 2841 if (vaultMode === "decoy") {2842 throw new Error("deduplicate disabled in decoy mode");2843 }28442845 const best = new Map<string, VaultEntry>();2846 for (const e of entries.filter((en) => !en.deletedAt)) {2847 const key = entryDupeKey(e);2848 const existing = best.get(key);2849 if (!existing) {2850 best.set(key, e);2851 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2852 best.set(key, e);2853 }2854 }28552856 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2857 let removed = 0;2858 entries = entries.map((e) => {2859 if (!e.deletedAt && !keepIds.has(e.id)) {2860 removed++;2861 const deletedAt = monotonicEntryTimestamp(Math.max(2862 e.modifiedAt,2863 tombstones.get(e.id) ?? 0,2864 ));2865 tombstones.set(e.id, deletedAt);2866 return { ...e, deletedAt };2867 }2868 return e;2869 });28702871 if (removed > 0) await persistEntries();2872 return removed;2873}287428752876287728782879288028812882const HISTORY_KEY = "peach_vault_history";2883const HISTORY_MAX = 5;288428852886288728882889function bucketEntryCount(n: number): number {2890 if (n <= 0) return 0;2891 if (n <= 10) return 10;2892 if (n <= 25) return 25;2893 if (n <= 50) return 50;2894 if (n <= 100) return 100;2895 if (n <= 250) return 250;2896 if (n <= 500) return 500;2897 return Math.ceil(n / 500) * 500;2898}28992900290129022903290429052906export async function takeVaultSnapshot(2907 reason: VaultSnapshot["reason"],2908): Promise<void> {2909 try {2910 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2911 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2912 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];29132914 2915 2916 2917 if (!slotA) return;29182919 const vault_a = JSON.stringify(slotA);2920 const vault_b = JSON.stringify(slotB ?? slotA);29212922 2923 2924 2925 2926 2927 2928 let entryCount: number | null = null;2929 if (derivedKey && vaultMode === "real") {2930 try {2931 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);2932 } catch {2933 entryCount = null;2934 }2935 }29362937 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2938 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2939 ? existingRaw2940 : { snapshots: [] };29412942 2943 2944 2945 const latest = history.snapshots[0];2946 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2947 return;2948 }29492950 const snapshot: VaultSnapshot = {2951 id: crypto.randomUUID(),2952 savedAt: Date.now(),2953 reason,2954 vault_a,2955 vault_b,2956 entryCount,2957 };29582959 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2960 await chrome.storage.local.set({ [HISTORY_KEY]: history });2961 } catch (err) {2962 2963 2964 2965 2966 console.warn('[Peach] takeVaultSnapshot failed:', err);2967 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2968 try {2969 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2970 } catch {2971 2972 }2973 }2974 }2975}297629772978export async function clearVaultHistory(): Promise<void> {2979 try {2980 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2981 } catch {2982 2983 }2984}298529862987export async function getVaultHistory(): Promise<VaultSnapshot[]> {2988 const raw = await chrome.storage.local.get(HISTORY_KEY);2989 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2990 if (!history || !Array.isArray(history.snapshots)) return [];2991 return history.snapshots;2992}29932994299529962997299829993000export async function restoreVaultSnapshot(3001 snapshotId: string,3002): Promise<{ ok: true } | { ok: false; error: string }> {3003 const history = await getVaultHistory();3004 const snapshot = history.find((s) => s.id === snapshotId);3005 if (!snapshot) return { ok: false, error: "snapshot_not_found" };30063007 3008 3009 3010 await takeVaultSnapshot("restore");30113012 let restoredA: StoredVault;3013 let restoredB: StoredVault;3014 try {3015 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3016 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3017 } catch {3018 return { ok: false, error: "snapshot_corrupt" };3019 }30203021 await writeSlot(SLOT_A, restoredA);3022 await writeSlot(SLOT_B, restoredB);3023 await stampLocalVaultMutation();30243025 3026 3027 3028 lock();30293030 return { ok: true };3031}303230333034303530363037303830393040304130423043304430453046export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3047 if (!derivedKey || vaultMode !== "real") {3048 return { ok: false, error: "vault_locked" };3049 }3050 const slotA = await loadSlot(SLOT_A);3051 if (!slotA) return { ok: false, error: "missing_slot_a" };30523053 3054 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3055 await generateAndWrapRootKey();30563057 3058 const newRootKey = unhex(rawRootKey);3059 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);30603061 3062 3063 3064 3065 await takeVaultSnapshot("local_save");3066 const nextSlotA: StoredVault = {3067 ...slotA,3068 recovery_salt: recoverySalt,3069 recovery_wrapped_root_key: wrappedRootKey,3070 recovery_nonce: wrappedNonce,3071 recovery_tag: wrappedTag,3072 ...pwRoot,3073 };3074 await writeSlot(SLOT_A, nextSlotA);30753076 3077 if (rootKey) zeroKey(rootKey);3078 rootKey = newRootKey;30793080 3081 await clearPairedDevices();30823083 3084 await setRecoveryConfirmPending(); 30853086 await stampLocalVaultMutation();3087 return { ok: true };3088}30893090309130923093309430953096export async function changePassword(3097 currentPassword: string,3098 newPassword: string3099): Promise<boolean> {3100 if (!derivedKey || vaultMode !== "real") return false;31013102 3103 const valid = await verifyMainPassword(currentPassword);3104 if (!valid) return false;31053106 const slotA = await loadSlot(SLOT_A);3107 if (!slotA) return false;3108 if (!rootKey) return false;31093110 3111 const newSalt = generateSalt();3112 const newKey = await deriveKey(newPassword, newSalt);31133114 3115 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3116 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");31173118 3119 3120 3121 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);31223123 const finalSlotA: StoredVault = {3124 ...slotA,3125 salt: hex(newSalt),3126 nonce,3127 ciphertext,3128 tag,3129 ...pwRoot,3130 };31313132 3133 3134 3135 await takeVaultSnapshot("local_save");31363137 3138 3139 3140 3141 await writeSlot(SLOT_A, finalSlotA);31423143 3144 3145 3146 zeroKey(derivedKey);3147 derivedKey = newKey;31483149 3150 3151 3152 3153 void saveSessionKey(newKey, "real");31543155 3156 3157 3158 if (!duressConfigured) {3159 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3160 }3161 await clearPinUnlockStorage();3162 await stampLocalVaultMutation();31633164 return true;3165}