135 lines
5.6 KiB
JavaScript
135 lines
5.6 KiB
JavaScript
// 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
|
|
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;
|
|
}
|
|
// 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");
|
|
if (v) {
|
|
return v;
|
|
}
|
|
} catch (e) {
|
|
// state not available (e.g. unit tests) -> fall through to throw
|
|
}
|
|
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(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);
|
|
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 };
|