// Crypto primitives for the saltcorn-idp plugin. // // seal/open — AES-256-GCM at-rest encryption. The 32-byte KEK is // derived once per process via HKDF-SHA256 from // SALTCORN_SESSION_SECRET. // sealText/openText — same, but with hex-string fields for DB storage // (Saltcorn's sqlite layer JSON-stringifies values, so // raw Buffers must not be stored directly). // generateSigningKeyPair / publicKeyToJwk / exportPrivatePem / importPrivatePem // — asymmetric signing keys for OIDC id_token signing and // the public JWKS. // // The KEK info string is domain-separated from other plugins so the IdP's KEK // differs from dev-deploy's even though both derive from the same session // secret. Rotating SALTCORN_SESSION_SECRET invalidates all sealed data (the KEK // changes, so existing ciphertexts no longer decrypt). Documented behavior. const crypto = require("crypto"); const KEK_INFO = "saltcorn-idp:at-rest:aes-gcm-key:v1"; const KEK_SALT = "saltcorn-idp:at-rest:aes-gcm-salt:v1"; const HKDF_HASH = "sha256"; const GCM_ALGORITHM = "aes-256-gcm"; const IV_BYTES = 12; const KEK_BYTES = 32; const KID_BYTES = 16; let cachedKek = null; let cachedSecretHash = null; const getSessionSecret = () => { const fromEnv = process.env.SALTCORN_SESSION_SECRET; if (fromEnv && fromEnv.length > 0) { return fromEnv; } try { const { getState } = require("@saltcorn/data/db/state"); const v = getState().getConfig("session_secret"); if (v) { return v; } } catch (e) { // getState not available (e.g. outside a request context); fall through } throw new Error("saltcorn-idp: SALTCORN_SESSION_SECRET not available; cannot derive KEK"); }; const getKek = () => { // Re-derive if the session secret changes so a rotated secret never keeps // serving a stale KEK. The KEK is global (Saltcorn's session_secret is // global), so this cache is shared safely across tenants. const sessionSecret = getSessionSecret(); const secretHash = crypto.createHash(HKDF_HASH).update(sessionSecret, "utf8").digest("hex"); if (cachedKek && cachedSecretHash === secretHash) { return cachedKek; } const ikm = Buffer.from(sessionSecret, "utf8"); const salt = Buffer.from(KEK_SALT, "utf8"); const info = Buffer.from(KEK_INFO, "utf8"); cachedKek = Buffer.from(crypto.hkdfSync(HKDF_HASH, ikm, salt, info, KEK_BYTES)); cachedSecretHash = secretHash; return cachedKek; }; const seal = (plaintext) => { const iv = crypto.randomBytes(IV_BYTES); const cipher = crypto.createCipheriv(GCM_ALGORITHM, getKek(), iv); const buf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext); const ct = Buffer.concat([cipher.update(buf), cipher.final()]); const tag = cipher.getAuthTag(); return { ciphertext: ct, iv: iv, tag: tag }; }; const open = (sealed) => { const decipher = crypto.createDecipheriv(GCM_ALGORITHM, getKek(), sealed.iv); decipher.setAuthTag(sealed.tag); return Buffer.concat([decipher.update(sealed.ciphertext), decipher.final()]); }; const sealText = (plaintext) => { const sealed = seal(plaintext); return { ciphertext: sealed.ciphertext.toString("hex"), iv: sealed.iv.toString("hex"), tag: sealed.tag.toString("hex") }; }; const openText = (sealedHex) => { const sealed = { ciphertext: Buffer.from(sealedHex.ciphertext, "hex"), iv: Buffer.from(sealedHex.iv, "hex"), tag: Buffer.from(sealedHex.tag, "hex") }; return open(sealed); }; const generateSigningKeyPair = (modulusBits) => { return crypto.generateKeyPairSync("rsa", { modulusLength: modulusBits }); }; const publicKeyToJwk = (publicKey, kid, alg) => { const jwk = publicKey.export({ format: "jwk" }); if (jwk.kty !== "RSA" || !jwk.n || !jwk.e) { throw new Error("saltcorn-idp: exported JWK is not a complete RSA public key"); } jwk.kid = kid; jwk.alg = alg; jwk.use = "sig"; return jwk; }; const exportPrivatePem = (privateKey) => { return privateKey.export({ type: "pkcs8", format: "pem" }); }; const importPrivatePem = (pem) => { return crypto.createPrivateKey(pem); }; const newKid = () => { return crypto.randomBytes(KID_BYTES).toString("hex"); }; const privateKeyToJwk = (privateKey, kid, alg) => { const jwk = privateKey.export({ format: "jwk" }); jwk.kid = kid; jwk.alg = alg; jwk.use = "sig"; return jwk; }; // Deterministic secret derived from SALTCORN_SESSION_SECRET (distinct info per // use). Used for oidc-provider cookie-signing keys so they survive restarts. const deriveSecretHex = (info, bytes) => { const ikm = Buffer.from(getSessionSecret(), "utf8"); const salt = Buffer.from(info, "utf8"); const out = crypto.hkdfSync(HKDF_HASH, ikm, salt, Buffer.from(info, "utf8"), bytes); return Buffer.from(out).toString("hex"); }; module.exports = { seal, open, sealText, openText, generateSigningKeyPair, publicKeyToJwk, privateKeyToJwk, exportPrivatePem, importPrivatePem, newKid, deriveSecretHex };