// Signing-key lifecycle for the saltcorn-idp OIDC provider. // // Keys are per-tenant, stored in _sc_config (key CFG_KEYS) as an array of key // objects rather than a _idp_keys table. _sc_config survives backup AND is // restored BEFORE onLoad, so a restored instance keeps its signing keys and // ensureActiveKey() does not generate a duplicate ACTIVE key (a registered // table, imported AFTER onLoad, would leave two ACTIVE keys -> broken JWKS). // // Each stored key object: { kid, alg, public_jwk (object), private ({ciphertext, // iv, tag} hex from crypto.sealText), status, created_at, retire_after }. The // private key is sealed at rest (AES-256-GCM under the plugin KEK); only the // PUBLIC JWK is ever exposed, via JWKS. const crypto = require("./crypto"); const constants = require("./constants"); const { readKey, writeKey } = require("./configStore"); const loadKeys = async () => { const arr = await readKey(constants.CFG_KEYS); return Array.isArray(arr) ? arr : []; }; const saveKeys = async (keys) => { await writeKey(constants.CFG_KEYS, keys); }; const byStatus = (keys, status) => keys.filter((k) => k.status === status); // Generate + seal a fresh RSA signing keypair and append it as the new ACTIVE // key. Returns only safe metadata; the sealed private material is never handed // back. Caller persists the mutated array. const buildActiveKey = () => { const kid = crypto.newKid(); const pair = crypto.generateSigningKeyPair(constants.RSA_MODULUS_BITS); const jwk = crypto.publicKeyToJwk(pair.publicKey, kid, constants.SIGNING_ALG); const pem = crypto.exportPrivatePem(pair.privateKey); const sealed = crypto.sealText(pem); const now = new Date().toISOString(); return { kid: kid, alg: constants.SIGNING_ALG, public_jwk: jwk, private: { ciphertext: sealed.ciphertext, iv: sealed.iv, tag: sealed.tag }, status: constants.KEY_STATUS.ACTIVE, created_at: now, retire_after: null }; }; const ensureActiveKey = async () => { const keys = await loadKeys(); const existing = byStatus(keys, constants.KEY_STATUS.ACTIVE)[0]; if (existing) { return { kid: existing.kid, alg: existing.alg, status: existing.status, created_at: existing.created_at }; } const k = buildActiveKey(); keys.push(k); await saveKeys(keys); return { kid: k.kid, alg: k.alg, status: k.status, created_at: k.created_at }; }; const getJwks = async () => { const keys = await loadKeys(); const pub = byStatus(keys, constants.KEY_STATUS.ACTIVE) .concat(byStatus(keys, constants.KEY_STATUS.RETIRING)) .map((k) => k.public_jwk); return { keys: pub }; }; const getActiveKeyMeta = async () => { const k = byStatus(await loadKeys(), constants.KEY_STATUS.ACTIVE)[0]; if (!k) { return null; } return { kid: k.kid, alg: k.alg, created_at: k.created_at }; }; const openPrivate = (k) => { return crypto.openText(k.private).toString("utf8"); }; // Decrypts and returns the private signing key for id_token signing. const getActiveSigningKey = async () => { const k = byStatus(await loadKeys(), constants.KEY_STATUS.ACTIVE)[0]; if (!k) { return null; } return { kid: k.kid, alg: k.alg, privateKey: crypto.importPrivatePem(openPrivate(k)) }; }; const getActivePrivateJwk = async () => { const key = await getActiveSigningKey(); if (!key) { return null; } return crypto.privateKeyToJwk(key.privateKey, key.kid, key.alg); }; // All non-retired private signing keys as JWKs for oidc-provider's jwks config: // the ACTIVE key FIRST, then any RETIRING keys (so id_tokens issued before a // rotation still verify against JWKS during the grace window). RETIRED excluded. const getSigningPrivateJwks = async () => { const keys = await loadKeys(); const ordered = byStatus(keys, constants.KEY_STATUS.ACTIVE).concat(byStatus(keys, constants.KEY_STATUS.RETIRING)); return ordered.map((k) => crypto.privateKeyToJwk(crypto.importPrivatePem(openPrivate(k)), k.kid, k.alg)); }; // Flip any RETIRING keys whose grace window has elapsed to RETIRED, dropping // them from JWKS. Idempotent. A null retire_after never expires. const retireExpiredKeys = async () => { const now = new Date().toISOString(); const keys = await loadKeys(); let changed = false; for (const k of keys) { if (k.status === constants.KEY_STATUS.RETIRING && k.retire_after && k.retire_after <= now) { k.status = constants.KEY_STATUS.RETIRED; changed = true; } } if (changed) { await saveKeys(keys); } }; // Admin-triggered rotation. Generates a NEW ACTIVE keypair (new kid) and demotes // the prior ACTIVE key to RETIRING with retire_after = now + the grace window, so // id_tokens signed by the old key keep verifying until the window elapses. Also // sweeps already-expired RETIRING keys to RETIRED and clears the provider cache. const rotateActiveKey = async () => { const keys = await loadKeys(); const prior = byStatus(keys, constants.KEY_STATUS.ACTIVE)[0]; if (prior) { prior.status = constants.KEY_STATUS.RETIRING; prior.retire_after = new Date(Date.now() + constants.KEY_RETIRE_GRACE_MS).toISOString(); } const k = buildActiveKey(); keys.push(k); // sweep expired retiring keys in the same array before persisting const now = new Date().toISOString(); for (const e of keys) { if (e.status === constants.KEY_STATUS.RETIRING && e.retire_after && e.retire_after <= now) { e.status = constants.KEY_STATUS.RETIRED; } } await saveKeys(keys); // Lazy-require to avoid a require cycle (provider.js requires this module). const { clearProviderCache } = require("./oidc/provider"); clearProviderCache(); return { kid: k.kid, alg: k.alg, status: k.status, created_at: k.created_at }; }; module.exports = { ensureActiveKey, getJwks, getActiveKeyMeta, getActiveSigningKey, getActivePrivateJwk, getSigningPrivateJwks, rotateActiveKey, retireExpiredKeys };