diff --git a/README.md b/README.md index 010da0a..6cd2eae 100644 --- a/README.md +++ b/README.md @@ -43,16 +43,26 @@ in `_sc_plugins.configuration`, which would otherwise expose the key to DB backups, admin reads, and dev-deploy's journal sync. Instead: - `lib/secretsAtRest.js` seals the key with AES-256-GCM under a key derived - (HKDF-SHA256) from Saltcorn's `session_secret` -- which lives in the env / - config file, not the database. Ciphertext in the DB is useless without it. + (HKDF-SHA256) from Saltcorn's `session_secret`, which it resolves from the + Saltcorn installation itself -- no extra env var required. `getSessionSecret` + tries, in order: `SALTCORN_SESSION_SECRET` (an optional override, checked + first and wins when present), `db.connectObj.session_secret` (Saltcorn's + resolved value, which already merges the env var and the `~/.config/.saltcorn` + config file), `getConfigFile()` reading `~/.config/.saltcorn` directly (for + when the db module is not yet initialised), and finally the `session_secret` + DB config. It throws (`secretsAtRest: session_secret unavailable ...`) only if + all four miss. The derived KEK is cached keyed by a hash of the resolved + secret, so changing the secret re-derives it. Ciphertext in the DB is useless + without it. - Because Saltcorn's config form autosaves raw values, `onLoad` re-seals any plaintext key the moment the plugin (re)loads after a save; the action decrypts only at the point of use. - **Threat model:** this protects DB-only exposure (backups, sync, config-table - reads). It does not protect a host that leaks both the database and the env / - config file. **Rotating `session_secret` invalidates the sealed key** -- you - must re-enter it in the plugin config (the action raises a clear error saying - so). + reads). It does not protect a host that leaks both the database and the + resolved `session_secret` (config file, env override, or DB config). + **Rotating `session_secret` invalidates the sealed key** (the hash-keyed KEK + cache re-derives) -- you must re-enter it in the plugin config (the action + raises a clear error saying so). - For the same reason, the sealed key is environment-specific: it will not decrypt on a peer with a different `session_secret`, so exclude it from any dev-deploy sync and set it per environment. diff --git a/lib/secretsAtRest.js b/lib/secretsAtRest.js index e397a50..f77443a 100644 --- a/lib/secretsAtRest.js +++ b/lib/secretsAtRest.js @@ -24,15 +24,45 @@ const IV_BYTES = 12; const TAG_BYTES = 16; const KEY_BYTES = 32; -const kekCache = new Map(); // info label -> 32-byte derived key +const kekCache = new Map(); // info label -> 32-byte derived key +let cachedSecretHash = null; // last session-secret hash; a change clears kekCache +// Resolve the session secret from the Saltcorn installation, mirroring the +// precedence Saltcorn itself uses in db/connect.ts (env var -> ~/.config/.saltcorn +// -> generated). Leaning on Saltcorn's own resolution rather than requiring +// SALTCORN_SESSION_SECRET in the plugin's process environment lets the plugin +// install cleanly on an instance configured purely through the config file. const getSessionSecret = () => { + // 1. Explicit environment override (also what Saltcorn checks first). const fromEnv = process.env.SALTCORN_SESSION_SECRET; if (fromEnv && fromEnv.length > 0) { return fromEnv; } - // Fall back to the value Saltcorn loaded into its config state. + // 2. Saltcorn's resolved connection object. session_secret here has already + // been merged from the env var and the .saltcorn config file (and is the + // exact value Saltcorn signs its own session cookies with), so this is the + // single source of truth whenever the db module is initialised. + try { + const db = require("@saltcorn/data/db"); + if (db.connectObj && db.connectObj.session_secret) { + return db.connectObj.session_secret; + } + } catch (e) { + // db module not loadable in this context; fall through + } + // 3. Read the Saltcorn config file (~/.config/.saltcorn) directly, in case + // the db module is not yet initialised when we are called. + try { + const { getConfigFile } = require("@saltcorn/data/db/connect"); + const cfg = getConfigFile(); + if (cfg && cfg.session_secret) { + return cfg.session_secret; + } + } catch (e) { + // connect module not available; fall through + } + // 4. DB-stored config value Saltcorn loaded into its config state, if any. try { const { getState } = require("@saltcorn/data/db/state"); const v = getState().getConfig("session_secret"); @@ -42,16 +72,25 @@ const getSessionSecret = () => { } catch (e) { // state not available (e.g. unit tests) -> fall through to throw } - throw new Error("secretsAtRest: SALTCORN_SESSION_SECRET unavailable; cannot derive key"); + throw new Error("secretsAtRest: session_secret unavailable from environment, the Saltcorn config file, or DB config; cannot derive key"); }; const getKek = (info) => { + // Re-derive if the session secret changes so a rotated secret never keeps + // serving a stale KEK. session_secret is global, so a change invalidates + // every per-label KEK at once. + const sessionSecret = getSessionSecret(); + const secretHash = crypto.createHash(HASH).update(sessionSecret, "utf8").digest("hex"); + if (cachedSecretHash !== secretHash) { + kekCache.clear(); + cachedSecretHash = secretHash; + } const cached = kekCache.get(info); if (cached) { return cached; } - const ikm = Buffer.from(getSessionSecret(), "utf8"); + const ikm = Buffer.from(sessionSecret, "utf8"); const salt = Buffer.from(`${BLOB_PREFIX}${info}`, "utf8"); const kek = Buffer.from(crypto.hkdfSync(HASH, ikm, salt, Buffer.from(info, "utf8"), KEY_BYTES)); kekCache.set(info, kek);