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