// Per-user store of the credentials users connected for plugins. One // encrypted document per mail account; the secrets never leave the server // again except as an auth header on a request the proxy forwards to the // credential's own origin. import { createHash } from 'node:crypto'; import path from 'node:path'; import { decryptPayload, encryptPayload } from '@/lib/auth/crypto'; import { getStore, type CollectionOptions } from '@/lib/shared-store'; import { isStoreUnavailable, isStoredDataUnreadable, StoredDataUnreadableError } from '@/lib/stored-data-errors'; import { moveUnreadableAside } from '@/lib/stored-data-quarantine'; import type { CredentialInject } from './credentials'; export interface VaultUser { username: string; serverUrl: string; } export interface StoredCredential { origin: string; inject: CredentialInject; label: string; username?: string; secret?: string; createdAt: string; updatedAt: string; } /** What the browser may see about a stored credential: never the secret. */ export interface CredentialSummary { pluginId: string; credentialId: string; label: string; origin: string; username?: string; connected: boolean; updatedAt: string; } type VaultData = Record>; /** Cap on credentials per user, across all plugins. */ export const MAX_USER_CREDENTIALS = 64; export function vaultDir(): string { return process.env.PLUGIN_SECRETS_DIR || path.join(process.env.SETTINGS_DATA_DIR || path.join(process.cwd(), 'data', 'settings'), '..', 'plugin-secrets'); } export function userKey(user: VaultUser): string { return createHash('sha256').update(`${user.username}:${user.serverUrl}`).digest('hex'); } const VAULT_FILE = /^[0-9a-f]{64}\.enc$/; /** * One document per account, keyed by userKey (#1066). Local mode: today's * `/.enc`, base64 `encryptPayload` text, mode 0600. */ export const VAULT_COLLECTION: CollectionOptions = { local: { dir: () => vaultDir(), file: (id) => `${id}.enc`, idOf: (relative) => (VAULT_FILE.test(relative) ? relative.slice(0, -'.enc'.length) : null), mode: 0o600, }, }; function collection() { return getStore().collection('plugin-secrets', VAULT_COLLECTION); } /** * The user's credentials from a stored document; {} for none. One that * doesn't decrypt (another session secret) throws StoredDataUnreadableError, * so no write lands on top of it before it is moved aside (see modify). */ function decode(user: VaultUser, raw: Buffer | null): VaultData { if (!raw) return {}; const payload = decryptPayload(raw.toString('utf8'), 'plugin-secrets'); if (!payload) throw new StoredDataUnreadableError('plugin credentials'); // Bound to this user: a file copied over from another account's name // decrypts, but carries the other account's key and is ignored. if (payload.u !== userKey(user)) return {}; const data = payload.d; return data && typeof data === 'object' ? (data as VaultData) : {}; } async function load(user: VaultUser): Promise { let stored; try { stored = await collection().get(userKey(user)); } catch (error) { if (isStoreUnavailable(error)) throw error; throw new StoredDataUnreadableError('plugin credentials', { cause: error }); } try { return decode(user, stored?.data ?? null); } catch (error) { // Reads as no credentials; the next write moves it aside. if (isStoredDataUnreadable(error)) return {}; throw error; } } /** * Change the user's credentials in one step against what is stored now, so * two writes at once (two tabs, two replicas) both land. `change` edits the * data it gets and says whether it changed anything; it may run more than * once. An empty vault is deleted. * * A vault that doesn't decrypt is moved aside first (kept next to the * original as `.unreadable-