// Signing-key lifecycle for the saltcorn-idp OIDC provider. // // Keys are per-tenant (stored in this tenant's _idp_keys). The private key is // sealed at rest (AES-256-GCM under the plugin KEK); only the PUBLIC JWK is // ever exposed, via JWKS. ensureActiveKey() is idempotent: it creates an active // key only if none exists for the current tenant. getActiveKeyMeta() never // touches private material so the dashboard can render without decrypting. const db = require("@saltcorn/data/db"); const crypto = require("./crypto"); const constants = require("./constants"); // Generate + seal a fresh RSA signing keypair and insert it as the new ACTIVE // key. Shared by ensureActiveKey (first key for a tenant) and rotateActiveKey // (replacement key). Returns only safe metadata; the sealed private material is // never handed back. const insertActiveKey = async () => { 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(); const row = { kid: kid, alg: constants.SIGNING_ALG, public_jwk: JSON.stringify(jwk), private_ciphertext: sealed.ciphertext, private_iv: sealed.iv, private_tag: sealed.tag, status: constants.KEY_STATUS.ACTIVE, created_at: now, retire_after: null }; await db.insert(constants.TABLE_KEYS, row, { noid: true }); return { kid: kid, alg: constants.SIGNING_ALG, status: constants.KEY_STATUS.ACTIVE, created_at: now }; }; const ensureActiveKey = async () => { const existing = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE }); if (existing) { return { kid: existing.kid, alg: existing.alg, status: existing.status, created_at: existing.created_at }; } return await insertActiveKey(); }; const getJwks = async () => { const active = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE }); const retiring = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.RETIRING }); const keys = active.concat(retiring).map((r) => JSON.parse(r.public_jwk)); return { keys: keys }; }; const getActiveKeyMeta = async () => { const row = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE }); if (!row) { return null; } return { kid: row.kid, alg: row.alg, created_at: row.created_at }; }; // Phase 1: decrypts and returns the private signing key for id_token signing. const getActiveSigningKey = async () => { const row = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE }); if (!row) { return null; } const pem = crypto.openText({ ciphertext: row.private_ciphertext, iv: row.private_iv, tag: row.private_tag }).toString("utf8"); return { kid: row.kid, alg: row.alg, privateKey: crypto.importPrivatePem(pem) }; }; // Phase 1: the active private signing key as a JWK, for oidc-provider's jwks // config (it signs id_tokens with it and serves the public half via JWKS). 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 (oidc-provider signs new tokens with the first key that // matches the requested alg) followed by any RETIRING keys, so id_tokens issued // BEFORE a rotation still verify against JWKS during the grace window. RETIRED // keys are excluded. This is what actually keeps a rotated-out key in /idp/jwks // (the provider builds JWKS from this set, not from keys.getJwks()). const getSigningPrivateJwks = async () => { const active = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE }); const retiring = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.RETIRING }); const jwks = []; for (const row of active.concat(retiring)) { const pem = crypto.openText({ ciphertext: row.private_ciphertext, iv: row.private_iv, tag: row.private_tag }).toString("utf8"); jwks.push(crypto.privateKeyToJwk(crypto.importPrivatePem(pem), row.kid, row.alg)); } return jwks; }; // Flip any RETIRING keys whose grace window has elapsed to RETIRED, dropping // them from JWKS. Idempotent and cheap; called opportunistically on the admin // dashboard GET and at the end of a rotation. NULL retire_after never matches // (SQL "<=" of NULL is NULL, not true), so a key without a deadline is safe. const retireExpiredKeys = async () => { const now = new Date().toISOString(); await db.updateWhere( constants.TABLE_KEYS, { status: constants.KEY_STATUS.RETIRED }, { status: constants.KEY_STATUS.RETIRING, retire_after: { lt: now, equal: true } } ); }; // Admin-triggered rotation. Generates+seals 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 (it stays in JWKS) // until the window elapses. The OIDC Provider is built per-issuer with the // active private JWK baked in, so its cache is cleared here to force a rebuild // against the new key. Also sweeps any already-expired RETIRING keys to RETIRED. const rotateActiveKey = async () => { const prior = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE }); if (prior) { const retireAfter = new Date(Date.now() + constants.KEY_RETIRE_GRACE_MS).toISOString(); await db.updateWhere( constants.TABLE_KEYS, { status: constants.KEY_STATUS.RETIRING, retire_after: retireAfter }, { kid: prior.kid } ); } const created = await insertActiveKey(); await retireExpiredKeys(); // Lazy-require to avoid a require cycle (provider.js requires this module). const { clearProviderCache } = require("./oidc/provider"); clearProviderCache(); return created; }; module.exports = { ensureActiveKey, getJwks, getActiveKeyMeta, getActiveSigningKey, getActivePrivateJwk, getSigningPrivateJwks, rotateActiveKey, retireExpiredKeys };