Files
vendoo/lib/secret-store.mjs
Masterluke77andGitHub 6f48827f4d Vendoo 1.41.2 – Git Ignore Boundary & Module Tracking Hotfix (#18)
* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix

* fix: track native source modules with root-anchored runtime ignores
2026-07-09 17:09:00 +02:00

178 lines
6.9 KiB
JavaScript

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,
};
}