sc-dev-deploy/lib/crypto.js
2026-06-18 17:22:52 -05:00

202 lines
6.8 KiB
JavaScript

// Crypto primitives for dev-deploy peer auth.
//
// seal/open — AES-256-GCM for at-rest encryption of peer secrets.
// The 32-byte KEK is derived once per process via
// HKDF-SHA256 from Saltcorn's session secret (resolved
// from the installation: env var, ~/.config/.saltcorn,
// or DB config -- see getSessionSecret).
// sign/verify — HMAC-SHA256 for signed peer-to-peer requests.
// buildCanonical — canonical string format every signed request agrees on.
// randomSecret — 32 random bytes (peer_secret) at pairing time.
//
// Rotating SALTCORN_SESSION_SECRET invalidates all peer pairings (the KEK
// changes, so existing ciphertexts no longer decrypt). Documented behavior.
const crypto = require("crypto");
const KEK_INFO = "dev-deploy:peer-secrets:aes-gcm-key:v1";
const HMAC_ALGORITHM = "sha256";
const GCM_ALGORITHM = "aes-256-gcm";
const IV_BYTES = 12;
const TAG_BYTES = 16;
const SECRET_BYTES = 32;
const NONCE_BYTES = 16;
const SKEW_TOLERANCE_MS = 5 * 60 * 1000;
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). Leaning on Saltcorn's own resolution rather than requiring
// SALTCORN_SESSION_SECRET in the plugin's process environment lets dev-deploy
// install 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("dev-deploy: 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. session_secret is global, so this cache is shared
// safely across tenants.
const sessionSecret = getSessionSecret();
const secretHash = crypto.createHash(HMAC_ALGORITHM).update(sessionSecret, "utf8").digest("hex");
if (cachedKek && cachedSecretHash === secretHash) {
return cachedKek;
}
const salt = Buffer.from(KEK_INFO, "utf8");
const ikm = Buffer.from(sessionSecret, "utf8");
cachedKek = crypto.hkdfSync(HMAC_ALGORITHM, ikm, salt, Buffer.from(KEK_INFO, "utf8"), 32);
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 randomSecret = () => {
return crypto.randomBytes(SECRET_BYTES);
};
const randomNonce = () => {
return crypto.randomBytes(NONCE_BYTES);
};
const sha256Hex = (data) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data || "");
return crypto.createHash("sha256").update(buf).digest("hex");
};
const buildCanonical = ({ timestamp, nonce, method, path, targetHost, body }) => {
const bodyHash = sha256Hex(body || "");
return [
String(timestamp),
String(nonce),
String(method).toUpperCase(),
String(path),
String(targetHost || ""),
bodyHash
].join("\n");
};
// Normalize a host[:port] so the signing side (outbound, derived from the peer
// base_url) and the verifying side (inbound, from the Host header) produce
// byte-identical canonicals: lowercase, trim, and drop the default port
// (clients omit :80/:443 from the Host header). Binding this into the canonical
// stops a request signed for one tenant being replayed against another tenant
// on the same multi-tenant server.
const normalizeHost = (h) => {
return String(h || "").trim().toLowerCase().replace(/:(?:80|443)$/, "");
};
const sign = (secret, canonical) => {
const mac = crypto.createHmac(HMAC_ALGORITHM, secret);
mac.update(canonical, "utf8");
return mac.digest("hex");
};
const verifySignature = (secret, canonical, providedHex) => {
if (!providedHex || typeof providedHex !== "string") {
return false;
}
let expectedBuf;
let providedBuf;
try {
expectedBuf = Buffer.from(sign(secret, canonical), "hex");
providedBuf = Buffer.from(providedHex, "hex");
} catch (e) {
return false;
}
if (expectedBuf.length !== providedBuf.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuf, providedBuf);
};
const timestampWithinSkew = (tsString) => {
const ts = Number(tsString);
if (!Number.isFinite(ts)) {
return false;
}
const now = Date.now();
return Math.abs(now - ts) <= SKEW_TOLERANCE_MS;
};
module.exports = {
seal,
open,
randomSecret,
randomNonce,
buildCanonical,
normalizeHost,
sign,
verifySignature,
timestampWithinSkew,
sha256Hex,
SKEW_TOLERANCE_MS
};