/** * Account registry — CRUD + active-selection over the AccountSpec array. * * Persistence piggybacks on the main config file (ServerConfig.accounts and * ServerConfig.activeAccountId). Writes go through the existing saveConfig * atomic-rename path. Migration from a pre-accounts config is lazy: when * the registry is first loaded and the accounts array is empty, the * top-level connection fields are lifted into a "primary" account so the * existing single-account behavior is preserved byte-for-byte. */ import { randomBytes } from "crypto"; import type { ServerConfig } from "../config/schema.js"; import { loadConfig, saveConfig, defaultConfig, withConfigWriteLockAsync } from "../config/loader.js"; import type { AccountSpec, AccountRegistry, AccountStatus } from "./types.js"; import { loadAccountCredentials, saveAccountCredentials, deleteAccountCredentials, } from "../security/keychain.js"; export function shortId(): string { return `acct-${randomBytes(4).toString("hex")}`; } /** * Build a sanitized AccountSpec from the legacy top-level connection fields * on a ServerConfig. Used for the one-time migration when a user with a * pre-accounts config first opens the new UI. */ function specFromLegacy(cfg: ServerConfig): AccountSpec { const c = cfg.connection; const isBridge = (c.smtpHost === "localhost" || c.smtpHost === "127.0.0.1") && (c.imapHost === "localhost" || c.imapHost === "127.0.0.1"); return { id: "primary", name: isBridge ? "Proton Mail (Bridge)" : (c.username || "Primary account"), providerType: isBridge ? "proton-bridge" : "imap", smtpHost: c.smtpHost, smtpPort: c.smtpPort, imapHost: c.imapHost, imapPort: c.imapPort, username: c.username, password: c.password, smtpToken: c.smtpToken || undefined, bridgeCertPath: c.bridgeCertPath || undefined, allowInsecureBridge: c.allowInsecureBridge, tlsMode: c.tlsMode, autoStartBridge: c.autoStartBridge, bridgePath: c.bridgePath || undefined, }; } export function readRegistry(): AccountRegistry { const cfg = loadConfig() ?? defaultConfig(); if (cfg.accounts && cfg.accounts.length > 0) { const activeId = cfg.activeAccountId && cfg.accounts.some(a => a.id === cfg.activeAccountId) ? cfg.activeAccountId : cfg.accounts[0].id; return { accounts: cfg.accounts, activeAccountId: activeId }; } // Legacy migration path — lift the singleton connection into an account. const primary = specFromLegacy(cfg); return { accounts: [primary], activeAccountId: primary.id }; } /** * Async variant that fills each account's `password` and `smtpToken` * from the OS keychain when the on-disk entry is empty. Callers that * need plaintext credentials (the AccountManager, connect paths, send * paths) should prefer this over `readRegistry()` which only returns * the on-disk shape. * * Legacy single-account path still works: `specFromLegacy` pulls * `c.password` from the connection block (which is blank when the * keychain is authoritative), so we also try the legacy keychain * entry name (no account-id suffix) to populate the "primary" slot. */ export async function readRegistryWithSecrets(): Promise { const reg = readRegistry(); const { loadCredentials } = await import("../security/keychain.js"); for (const acct of reg.accounts) { // The per-account keychain entry is AUTHORITATIVE for that account. Load it // unconditionally and PREFER it over any value already on the spec — that // pre-set value may be a broadcast of the LEGACY (no-suffix) keychain entry // applied by applyKeychainCredentials(), and a stale legacy entry must not // shadow the fresh per-account password a Settings save wrote. This was the // recurring daemon-restart staleness (CRED-005 once skipped this load // whenever a password was already set, which is exactly when the shadow // happened). A keychain miss falls back to the existing value / legacy key, // so config-plaintext and single-account installs are unaffected. const perAccount = await loadAccountCredentials(acct.id); let legacy: Awaited> | null | undefined; const getLegacy = async () => { if (legacy === undefined) legacy = (await loadCredentials()) ?? null; return legacy; }; if (perAccount?.password) { acct.password = perAccount.password; } else if (!acct.password && acct.id === "primary") { // Back-compat: the pre-multi-account keychain entry had no per-account // suffix. Fall back to the legacy key so existing installs keep working. const l = await getLegacy(); if (l?.password) acct.password = l.password; } if (perAccount?.smtpToken) { acct.smtpToken = perAccount.smtpToken; } else if (!acct.smtpToken && acct.id === "primary") { const l = await getLegacy(); if (l?.smtpToken) acct.smtpToken = l.smtpToken; } } return reg; } /** * Persist the registry to disk + route per-account credentials into * the OS keychain. Critical for the account-save security contract: * passwords MUST NOT land in the plaintext JSON file. * * Flow: * 1. For each account whose spec has a non-empty password, save it * to the keychain under the per-account key, then blank the * on-disk `password` field. * 2. Do the same for `smtpToken`. * 3. Also blank the legacy top-level `connection.password` / * `connection.smtpToken` — they're mirrored from the active * account below, and the active account's creds are keychain- * backed after step 1. * 4. Persist the (now secret-free) JSON via the standard atomic * rename path. * * If the keychain is unavailable (headless / no libsecret), the * function degrades to the old behavior — saves plaintext in the * file with a credentialStorage="config" marker so callers can * surface a warning. That's the same fallback saveConfigWithCredentials * uses for the legacy single-account path. * * This is an async function by necessity; the previous sync signature * cannot route secrets through the keychain without blocking. Call * sites that used to fire-and-forget should `await` it. */ export async function writeRegistry(reg: AccountRegistry): Promise { // CRED-008: hold the exclusive config lock across the whole read-modify-write // (loadConfig → merge → saveConfig) so a racing settings-UI POST or a second // writeRegistry cannot clobber this write. The inner saveConfig reuses the // held lock reentrantly. await withConfigWriteLockAsync(async () => { const cfg = loadConfig() ?? defaultConfig(); // Clone the specs so we can blank secrets without mutating the // caller's in-memory view. Downstream code that holds a reference // to an AccountSpec still gets the plaintext it passed in. const scrubbed = await Promise.all( reg.accounts.map(async (a) => { const password = a.password ?? ""; const smtpToken = a.smtpToken ?? ""; const hadSecrets = !!(password || smtpToken); let keychainOk = false; if (hadSecrets) { keychainOk = await saveAccountCredentials(a.id, password, smtpToken); } else { keychainOk = true; // nothing to save, nothing to fall back to } return { // CRED-004: carry the real saveAccountCredentials result alongside the // scrubbed spec instead of re-deriving keychain success from the // scrubbed shape. The shape inference (`!password && !smtpToken`) was // brittle — a future normalisation of empty fields to undefined would // have silently flipped credentialStorage to "keychain" even on a // plaintext-fallback host. `keychainSaved` is true only when this // account actually had secrets AND the keychain accepted them. keychainSaved: hadSecrets && keychainOk, spec: { ...a, // Only blank the on-disk password when the keychain actually // took it. Headless / no-libsecret hosts keep the legacy // behavior (plaintext on disk, credentialStorage="config"). password: keychainOk ? "" : password, smtpToken: keychainOk ? undefined : (smtpToken || undefined), } as AccountSpec, }; }), ); const scrubbedAccounts: AccountSpec[] = scrubbed.map(s => s.spec); cfg.accounts = scrubbedAccounts; cfg.activeAccountId = reg.activeAccountId; // Mark storage method based on whether ANY account actually saved to the // keychain (all-or-nothing per host; mixed mode isn't a supported // deployment). CRED-004: decided from the actual per-account keychain // result, not from inspecting the scrubbed on-disk shape. const anySecrets = reg.accounts.some(a => a.password || a.smtpToken); const anyKeychain = scrubbed.some(s => s.keychainSaved); if (anySecrets) { cfg.credentialStorage = anyKeychain ? "keychain" : "config"; } // Mirror the *active* account's connection fields back into the // legacy top-level shape so the existing service bootstrap still // finds hostnames/ports at startup. Passwords are NEVER mirrored // here — they live in the keychain or (fallback) inside the // per-account spec above. The MCP's AccountManager now loads // secrets via readRegistryWithSecrets(). const active = scrubbedAccounts.find(a => a.id === reg.activeAccountId) ?? scrubbedAccounts[0]; if (active) { cfg.connection = { ...cfg.connection, smtpHost: active.smtpHost, smtpPort: active.smtpPort, imapHost: active.imapHost, imapPort: active.imapPort, username: active.username, password: "", // keychain-backed; never on disk smtpToken: "", // same bridgeCertPath: active.bridgeCertPath ?? "", allowInsecureBridge: active.allowInsecureBridge, tlsMode: active.tlsMode, autoStartBridge: active.autoStartBridge, bridgePath: active.bridgePath, }; } saveConfig(cfg); }); } export function listStatuses(): AccountStatus[] { const reg = readRegistry(); return reg.accounts.map(a => ({ id: a.id, name: a.name, providerType: a.providerType, isActive: a.id === reg.activeAccountId, lastCheckedAt: a.lastCheckedAt, lastCheckResult: a.lastCheckResult, })); } export async function createAccount(spec: Omit): Promise { const reg = readRegistry(); const account: AccountSpec = { ...spec, id: shortId() }; reg.accounts.push(account); await writeRegistry(reg); return account; } /** * Patch fields on an existing account. Unknown IDs return null. * Passwords / smtpTokens in the patch are routed to the keychain by * writeRegistry(); the returned AccountSpec still carries the * plaintext in memory so the caller can hand it to services that * need it (AccountManager.applyKeychainCredentials, etc.). */ export async function updateAccount( id: string, patch: Partial, ): Promise { const reg = readRegistry(); const idx = reg.accounts.findIndex(a => a.id === id); if (idx < 0) return null; const merged = { ...reg.accounts[idx], ...patch, id }; reg.accounts[idx] = merged; await writeRegistry(reg); return merged; } export async function deleteAccount(id: string): Promise { const reg = readRegistry(); if (reg.accounts.length <= 1) { // Refuse to delete the last account — leaves the server without // anywhere to connect. return false; } const before = reg.accounts.length; reg.accounts = reg.accounts.filter(a => a.id !== id); if (reg.accounts.length === before) return false; if (reg.activeAccountId === id) { reg.activeAccountId = reg.accounts[0].id; } await writeRegistry(reg); // Scrub the deleted account's keychain entries so stale secrets // don't accumulate. Non-fatal on failure. try { await deleteAccountCredentials(id); } catch { /* non-fatal */ } return true; } export async function setActiveAccount(id: string): Promise { const reg = readRegistry(); const match = reg.accounts.find(a => a.id === id); if (!match) return null; reg.activeAccountId = id; await writeRegistry(reg); return match; }