163 lines
4.7 KiB
JavaScript
163 lines
4.7 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_SESSION_SECRET.
|
|
// 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;
|
|
|
|
|
|
const getSessionSecret = () => {
|
|
const fromEnv = process.env.SALTCORN_SESSION_SECRET;
|
|
if (fromEnv && fromEnv.length > 0) {
|
|
return fromEnv;
|
|
}
|
|
// Fallback to Saltcorn state config if available
|
|
try {
|
|
const { getState } = require("@saltcorn/data/db/state");
|
|
const v = getState().getConfig("session_secret");
|
|
if (v) return v;
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
throw new Error("dev-deploy: SALTCORN_SESSION_SECRET not available; cannot derive KEK");
|
|
};
|
|
|
|
|
|
const getKek = () => {
|
|
if (cachedKek) {
|
|
return cachedKek;
|
|
}
|
|
const sessionSecret = getSessionSecret();
|
|
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);
|
|
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
|
|
};
|