168 lines
6.6 KiB
JavaScript
168 lines
6.6 KiB
JavaScript
// 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
|
|
};
|