175 lines
6.1 KiB
JavaScript
175 lines
6.1 KiB
JavaScript
// 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
|
|
};
|