* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix * fix: track native source modules with root-anchored runtime ignores
178 lines
6.9 KiB
JavaScript
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,
|
|
};
|
|
}
|