sc-postiz-poster/lib/secretsAtRest.js
2026-06-17 17:40:57 -05:00

96 lines
3.5 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
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 };