168 lines
5.3 KiB
JavaScript
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
|
|
};
|