// Encryption-at-rest for plugin secrets kept in _sc_plugins.configuration. // // Saltcorn persists plugin configuration PLAINTEXT in the database, so a DB // dump, a backup, or a dev-deploy journal sync would otherwise expose any API // key or signing secret stored there. This module seals such fields 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 therefore useless without the separately-held key. // // Threat model: this protects DB-only exposure (backups, peer sync, an admin // reading the config table). It does NOT protect an attacker who also holds the // host env / config file -- they have the key. Rotating session_secret // invalidates every sealed value (the secret must be re-entered in the UI). // // Shareable verbatim across plugins: callers pass a domain label (info) so each // plugin/field derives a distinct key. const crypto = require("crypto"); const BLOB_PREFIX = "scs1:"; // saltcorn-secret, format v1 const HASH = "sha256"; const GCM = "aes-256-gcm"; const IV_BYTES = 12; const TAG_BYTES = 16; const KEY_BYTES = 32; const kekCache = new Map(); // info label -> 32-byte derived key const getSessionSecret = () => { 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. try { const { getState } = require("@saltcorn/data/db/state"); const v = getState().getConfig("session_secret"); if (v) { return v; } } catch (e) { // state not available (e.g. unit tests) -> fall through to throw } throw new Error("secretsAtRest: SALTCORN_SESSION_SECRET unavailable; cannot derive key"); }; const getKek = (info) => { const cached = kekCache.get(info); if (cached) { return cached; } const ikm = Buffer.from(getSessionSecret(), "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); return kek; }; const isEncryptedBlob = (value) => { return typeof value === "string" && value.startsWith(BLOB_PREFIX); }; const encryptSecret = (plaintext, info) => { const iv = crypto.randomBytes(IV_BYTES); const cipher = crypto.createCipheriv(GCM, getKek(info), iv); const ct = Buffer.concat([cipher.update(Buffer.from(String(plaintext), "utf8")), cipher.final()]); const tag = cipher.getAuthTag(); return BLOB_PREFIX + Buffer.concat([iv, tag, ct]).toString("base64"); }; // Returns the plaintext, or null if the blob can't be opened (wrong/rotated // key, tampering, corruption). Callers treat null as "re-enter the secret". const decryptSecret = (blob, info) => { if (!isEncryptedBlob(blob)) { return null; } try { const raw = Buffer.from(blob.slice(BLOB_PREFIX.length), "base64"); const iv = raw.subarray(0, IV_BYTES); const tag = raw.subarray(IV_BYTES, IV_BYTES + TAG_BYTES); const ct = raw.subarray(IV_BYTES + TAG_BYTES); const decipher = crypto.createDecipheriv(GCM, getKek(info), iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8"); } catch (e) { return null; } }; module.exports = { encryptSecret, decryptSecret, isEncryptedBlob };