Introduces a dedicated ENCRYPTION_KEY for encrypting stored secrets (API keys, MFA TOTP, SMTP password, OIDC client secret) so that rotating the JWT signing secret no longer invalidates encrypted data, and a compromised JWT_SECRET no longer exposes stored credentials. - server/src/config.ts: add ENCRYPTION_KEY (auto-generated to data/.encryption_key if not set, same pattern as JWT_SECRET); switch JWT_SECRET to `export let` so updateJwtSecret() keeps the CJS module binding live for all importers without restart - apiKeyCrypto.ts, mfaCrypto.ts: derive encryption keys from ENCRYPTION_KEY instead of JWT_SECRET - admin POST /rotate-jwt-secret: generates a new 32-byte hex secret, persists it to data/.jwt_secret, updates the live in-process binding via updateJwtSecret(), and writes an audit log entry - Admin panel (Settings → Danger Zone): "Rotate JWT Secret" button with a confirmation modal warning that all sessions will be invalidated; on success the acting admin is logged out immediately - docker-compose.yml, .env.example, README, Helm chart (values.yaml, secret.yaml, deployment.yaml, NOTES.txt, README): document ENCRYPTION_KEY and its upgrade migration path
44 lines
1.4 KiB
TypeScript
44 lines
1.4 KiB
TypeScript
import * as crypto from 'crypto';
|
|
import { ENCRYPTION_KEY } from '../config';
|
|
|
|
const ENCRYPTED_PREFIX = 'enc:v1:';
|
|
|
|
function get_key() {
|
|
return crypto.createHash('sha256').update(`${ENCRYPTION_KEY}:api_keys:v1`).digest();
|
|
}
|
|
|
|
export function encrypt_api_key(plain: unknown) {
|
|
const iv = crypto.randomBytes(12);
|
|
const cipher = crypto.createCipheriv('aes-256-gcm', get_key(), iv);
|
|
const enc = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
const blob = Buffer.concat([iv, tag, enc]).toString('base64');
|
|
return `${ENCRYPTED_PREFIX}${blob}`;
|
|
}
|
|
|
|
export function decrypt_api_key(value: unknown) {
|
|
if (!value) return null;
|
|
if (typeof value !== 'string') return null;
|
|
if (!value.startsWith(ENCRYPTED_PREFIX)) return value; // legacy plaintext
|
|
const blob = value.slice(ENCRYPTED_PREFIX.length);
|
|
try {
|
|
const buf = Buffer.from(blob, 'base64');
|
|
const iv = buf.subarray(0, 12);
|
|
const tag = buf.subarray(12, 28);
|
|
const enc = buf.subarray(28);
|
|
const decipher = crypto.createDecipheriv('aes-256-gcm', get_key(), iv);
|
|
decipher.setAuthTag(tag);
|
|
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function maybe_encrypt_api_key(value: unknown) {
|
|
const trimmed = String(value || '').trim();
|
|
if (!trimmed) return null;
|
|
if (trimmed.startsWith(ENCRYPTED_PREFIX)) return trimmed;
|
|
return encrypt_api_key(trimmed);
|
|
}
|
|
|