import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { CONFIG_PATH } from './config-store.mjs'; import { SECRET_DEFINITIONS, SECRET_NAMES, maskSecret, validateSecretName, validateSecretValue } from '../app/core/security/secret-contract.mjs'; const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const dataRoot = resolve(String(process.env.VENDOO_DATA_DIR || join(appRoot, 'data'))); const configuredStore = String(process.env.VENDOO_SECRETS_PATH || '').trim(); const configuredKey = String(process.env.VENDOO_MASTER_KEY_FILE || '').trim(); const defaultStorePath = join(dataRoot, 'config', 'secrets.enc.json'); const defaultMasterKeyPath = join(dataRoot, 'config', 'master.key'); export const SECRETS_PATH = resolve(configuredStore ? (isAbsolute(configuredStore) ? configuredStore : join(appRoot, configuredStore)) : defaultStorePath); export const MASTER_KEY_PATH = resolve(configuredKey ? (isAbsolute(configuredKey) ? configuredKey : join(appRoot, configuredKey)) : defaultMasterKeyPath); const ALGORITHM = 'aes-256-gcm'; let cache = null; let masterKey = null; function secureWrite(path, content) { mkdirSync(dirname(path), { recursive: true }); const temp = `${path}.tmp-${process.pid}-${Date.now()}`; writeFileSync(temp, content, { encoding: 'utf8', mode: 0o600 }); try { chmodSync(temp, 0o600); } catch {} renameSync(temp, path); try { chmodSync(path, 0o600); } catch {} } function parseMasterKey(raw) { const text = String(raw || '').trim(); if (!text) return null; const buffer = /^[a-f0-9]{64}$/i.test(text) ? Buffer.from(text, 'hex') : Buffer.from(text, 'base64'); if (buffer.length !== 32) throw new Error('VENDOO_MASTER_KEY muss 32 Bytes als Base64 oder 64 Hex-Zeichen enthalten.'); return buffer; } function loadMasterKey() { if (masterKey) return masterKey; const fromEnv = parseMasterKey(process.env.VENDOO_MASTER_KEY); if (fromEnv) return (masterKey = fromEnv); if (existsSync(MASTER_KEY_PATH)) { masterKey = parseMasterKey(readFileSync(MASTER_KEY_PATH, 'utf8')); if (!masterKey) throw new Error('Master-Key-Datei ist leer.'); return masterKey; } masterKey = randomBytes(32); secureWrite(MASTER_KEY_PATH, masterKey.toString('base64')); return masterKey; } function emptyStore() { return { version: 1, algorithm: ALGORITHM, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), entries: {} }; } function readStore() { if (cache) return cache; if (!existsSync(SECRETS_PATH)) return (cache = emptyStore()); const parsed = JSON.parse(readFileSync(SECRETS_PATH, 'utf8')); if (parsed.version !== 1 || parsed.algorithm !== ALGORITHM || !parsed.entries || typeof parsed.entries !== 'object') { throw new Error('Secret-Speicher besitzt ein unbekanntes Format.'); } return (cache = parsed); } function persist(store) { store.updatedAt = new Date().toISOString(); secureWrite(SECRETS_PATH, `${JSON.stringify(store, null, 2)}\n`); cache = store; } function encrypt(value) { const iv = randomBytes(12); const cipher = createCipheriv(ALGORITHM, loadMasterKey(), iv); const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); return { iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ciphertext: ciphertext.toString('base64') }; } function decrypt(entry) { const decipher = createDecipheriv(ALGORITHM, loadMasterKey(), Buffer.from(entry.iv, 'base64')); decipher.setAuthTag(Buffer.from(entry.tag, 'base64')); return Buffer.concat([decipher.update(Buffer.from(entry.ciphertext, 'base64')), decipher.final()]).toString('utf8'); } export function setSecret(name, value, { source = 'ui' } = {}) { const key = validateSecretName(name); const clean = validateSecretValue(value); const store = readStore(); if (!clean) { delete store.entries[key]; delete process.env[key]; } else { store.entries[key] = { ...encrypt(clean), updatedAt: new Date().toISOString(), source: String(source || 'ui').slice(0, 40) }; process.env[key] = clean; } persist(store); return getSecretMetadata(key); } export function getSecret(name) { const key = validateSecretName(name); const entry = readStore().entries[key]; if (entry) return decrypt(entry); return process.env[key] || ''; } export function hasSecret(name) { try { return Boolean(getSecret(name)); } catch { return false; } } export function deleteSecret(name) { return setSecret(name, '', { source: 'deleted' }); } export function getSecretMetadata(name) { const key = validateSecretName(name); const entry = readStore().entries[key]; const value = entry ? decrypt(entry) : (process.env[key] || ''); return { name: key, label: SECRET_DEFINITIONS[key].label, category: SECRET_DEFINITIONS[key].category, editable: SECRET_DEFINITIONS[key].editable, configured: Boolean(value), masked: maskSecret(value), provider: entry ? 'encrypted-file' : (value ? 'environment' : 'none'), updated_at: entry?.updatedAt || null, }; } export function listSecretMetadata() { return SECRET_NAMES.map(getSecretMetadata); } export function hydrateSecretsToEnvironment() { const store = readStore(); for (const name of SECRET_NAMES) { if (store.entries[name]) process.env[name] = decrypt(store.entries[name]); } return listSecretMetadata(); } function parseEnv(content) { const result = new Map(); for (const line of String(content || '').split(/\r?\n/)) { const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/); if (match) result.set(match[1], match[2]); } return result; } export function migrateRuntimeConfigSecrets() { if (!existsSync(CONFIG_PATH)) return { migrated: [], backup: null }; const original = readFileSync(CONFIG_PATH, 'utf8'); const values = parseEnv(original); const migrated = []; let next = original; for (const name of SECRET_NAMES) { const value = values.get(name); if (!value) continue; setSecret(name, value, { source: 'runtime-config-migration' }); next = next.replace(new RegExp(`^${name}=.*(?:\\r?\\n|$)`, 'm'), ''); migrated.push(name); } if (!migrated.length) return { migrated, backup: null }; const backup = `${CONFIG_PATH}.pre-secrets-1.37.0.bak`; if (!existsSync(backup)) copyFileSync(CONFIG_PATH, backup); secureWrite(CONFIG_PATH, next.replace(/^\s+/, '')); return { migrated, backup }; } export function secretStoreStatus() { const metadata = listSecretMetadata(); return { provider: 'encrypted-file', algorithm: ALGORITHM, store_path: SECRETS_PATH, master_key_path: MASTER_KEY_PATH, configured: metadata.filter(item => item.configured).length, encrypted: metadata.filter(item => item.provider === 'encrypted-file').length, environment: metadata.filter(item => item.provider === 'environment').length, secrets: metadata, }; }