const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); function normalizeSecret(value) { const normalized = String(value || '').trim(); return normalized || ''; } function firstNonEmpty(...values) { for (const value of values) { const normalized = normalizeSecret(value); if (normalized) return normalized; } return ''; } function generateSecret() { return crypto.randomBytes(48).toString('base64url'); } function resolveRuntimeSecretsPath({ env = process.env, dataDir }) { if (env.RUNTIME_SECRETS_FILE) { return path.resolve(env.RUNTIME_SECRETS_FILE); } return path.join(path.resolve(dataDir), 'k-vault.runtime-secrets.json'); } function readRuntimeSecretsFile(filePath) { const resolvedPath = path.resolve(filePath); if (!fs.existsSync(resolvedPath)) { return { exists: false, configEncryptionKey: '', sessionSecret: '', generatedAt: null, updatedAt: null, }; } let parsed; try { parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')); } catch (error) { throw new Error(`Failed to parse runtime secrets file: ${resolvedPath}`); } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(`Runtime secrets file must contain a JSON object: ${resolvedPath}`); } return { exists: true, configEncryptionKey: firstNonEmpty(parsed.configEncryptionKey, parsed.CONFIG_ENCRYPTION_KEY), sessionSecret: firstNonEmpty(parsed.sessionSecret, parsed.SESSION_SECRET), generatedAt: Number(parsed.generatedAt || 0) || null, updatedAt: Number(parsed.updatedAt || 0) || null, }; } function writeRuntimeSecretsFile(filePath, payload) { const resolvedPath = path.resolve(filePath); const directory = path.dirname(resolvedPath); const document = { version: 1, generatedAt: payload.generatedAt, updatedAt: payload.updatedAt, configEncryptionKey: payload.configEncryptionKey, sessionSecret: payload.sessionSecret, }; fs.mkdirSync(directory, { recursive: true }); const tempPath = `${resolvedPath}.tmp`; fs.writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8'); fs.renameSync(tempPath, resolvedPath); } function ensureRuntimeSecrets({ env = process.env, dataDir, autoGenerate = true, }) { const runtimeSecretsPath = resolveRuntimeSecretsPath({ env, dataDir }); const fileSecrets = readRuntimeSecretsFile(runtimeSecretsPath); let configEncryptionKey = firstNonEmpty( env.CONFIG_ENCRYPTION_KEY, env.FILE_URL_SECRET, env.SESSION_SECRET, fileSecrets.configEncryptionKey, fileSecrets.sessionSecret ); let sessionSecret = firstNonEmpty( env.SESSION_SECRET, env.FILE_URL_SECRET, env.CONFIG_ENCRYPTION_KEY, fileSecrets.sessionSecret, fileSecrets.configEncryptionKey ); const generatedKeys = []; if (!configEncryptionKey) { if (!autoGenerate) { return { configEncryptionKey: '', sessionSecret, runtimeSecretsPath, generatedKeys, source: fileSecrets.exists ? 'file' : 'missing', }; } configEncryptionKey = generateSecret(); generatedKeys.push('CONFIG_ENCRYPTION_KEY'); } if (!sessionSecret) { if (!autoGenerate) { return { configEncryptionKey, sessionSecret: '', runtimeSecretsPath, generatedKeys, source: fileSecrets.exists ? 'file' : 'missing', }; } sessionSecret = generateSecret(); generatedKeys.push('SESSION_SECRET'); } if (generatedKeys.length > 0) { const now = Date.now(); writeRuntimeSecretsFile(runtimeSecretsPath, { generatedAt: fileSecrets.generatedAt || now, updatedAt: now, configEncryptionKey, sessionSecret, }); } const source = generatedKeys.length > 0 ? 'generated' : fileSecrets.exists ? 'file' : 'env'; return { configEncryptionKey, sessionSecret, runtimeSecretsPath, generatedKeys, source, fileExists: fileSecrets.exists || generatedKeys.length > 0, }; } module.exports = { ensureRuntimeSecrets, generateSecret, readRuntimeSecretsFile, resolveRuntimeSecretsPath, };