199 lines
6.9 KiB
JavaScript
199 lines
6.9 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;
|
|
|
|
|
|
// Resolve the session secret from the Saltcorn installation, mirroring the
|
|
// precedence Saltcorn itself uses in db/connect.ts (env var -> ~/.config/.saltcorn
|
|
// -> generated). We deliberately lean on Saltcorn's own resolution rather than
|
|
// requiring SALTCORN_SESSION_SECRET to be exported into the plugin's process
|
|
// environment, so the plugin installs 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, if any.
|
|
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: session_secret not available from environment, the Saltcorn config file, or DB config; 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
|
|
};
|