sc-idp/lib/crypto.js
2026-06-01 16:40:54 -05:00

168 lines
5.3 KiB
JavaScript

// 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
};