import crypto from 'crypto'; import { getSettings, setSetting } from './settings.js'; /* * Das Client-Secret der Microsoft-App-Registrierung - hinterlegbar in der * Oberflaeche, mit Ablaufdatum. * * Warum ueberhaupt in der Datenbank: das Secret laeuft in Entra ab (Standard 6 * oder 24 Monate). Bisher hiess das: jemand mit SSH-Zugang muss die .env * aendern und den Dienst neu starten - und niemand sieht vorher, wann es soweit * ist. Beides erledigen jetzt Administratoren in den Einstellungen. * * Damit ein Datenbank-Dump trotzdem nichts hergibt, liegt der Wert * AES-256-GCM-verschluesselt dort. Der Schluessel steht in der .env und kommt * nie in die Datenbank - Dump und Schluessel sind also getrennt. * * Reihenfolge beim Lesen: erst die Datenbank, dann die .env. Der .env-Wert * bleibt damit die Rueckfallebene, und wer nichts umstellt, merkt von der * Neuerung nichts. * * Dasselbe Secret bedient Anmeldung UND Postfachanbindung - es ist dieselbe * App-Registrierung. Eine Aenderung wirkt also auf beides. */ const KEY_CIPHER = 'ms_client_secret'; const KEY_EXPIRES = 'ms_client_secret_expires_at'; const KEY_SAVED = 'ms_client_secret_saved_at'; const DEFAULTS = { [KEY_CIPHER]: '', [KEY_EXPIRES]: '', [KEY_SAVED]: '', }; /** Ab so vielen Tagen vor dem Ablauf warnt das CRM. */ export const WARN_DAYS = 30; /** Kennzeichnet das Format - falls das Verfahren je wechselt. */ const PREFIX = 'v1:'; /** * Schluesselmaterial: bevorzugt CRM_ENCRYPTION_KEY, sonst JWT_SECRET. Der * Rueckfall erspart eine zweite Pflichtvariable; wer JWT_SECRET wechselt, muss * das Secret allerdings neu eingeben (die Oberflaeche sagt das dann auch). */ function cipherKey() { const material = process.env.CRM_ENCRYPTION_KEY || process.env.JWT_SECRET || ''; if (material.length < 16) { throw new Error('Kein Schlüssel zum Verschlüsseln: CRM_ENCRYPTION_KEY oder JWT_SECRET setzen.'); } return crypto.scryptSync(material, 'perinco-crm-ms-secret', 32); } /** Exportiert nur fuer den Test - im Betrieb geht beides ueber saveSecret(). */ export function encrypt(text) { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', cipherKey(), iv); const data = Buffer.concat([cipher.update(String(text), 'utf8'), cipher.final()]); return PREFIX + Buffer.concat([iv, cipher.getAuthTag(), data]).toString('base64'); } /** Entschluesselt; leerer String, wenn der Schluessel nicht mehr passt. */ export function decrypt(value) { if (!value || !value.startsWith(PREFIX)) return ''; try { const raw = Buffer.from(value.slice(PREFIX.length), 'base64'); const decipher = crypto.createDecipheriv('aes-256-gcm', cipherKey(), raw.subarray(0, 12)); decipher.setAuthTag(raw.subarray(12, 28)); return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8'); } catch { return ''; } } const envSecret = () => (process.env.MS_CLIENT_SECRET || '').trim(); /** * Rohwerte aus der Datenbank. * * Ist die Datenbank nicht erreichbar, gilt das als "nichts hinterlegt" - dann * greift der Wert aus der .env. Eine nicht erreichbare Datenbank ist ohnehin * ein groesseres Problem als ein fehlendes Secret, und der Postfachabgleich * soll daran nicht mit einer irrefuehrenden Meldung scheitern. */ async function stored() { let raw; try { raw = await getSettings(DEFAULTS); } catch (err) { console.warn('[Microsoft] Secret konnte nicht gelesen werden, nutze .env:', err.message); raw = { ...DEFAULTS }; } return { cipher: raw[KEY_CIPHER] || '', expiresAt: raw[KEY_EXPIRES] || '', savedAt: raw[KEY_SAVED] || '', }; } /** * Das tatsaechlich benutzte Secret samt Herkunft. Absichtlich ohne * Zwischenspeicher: der Aufruf passiert selten (Tokens werden zwischengespeichert), * und eine Aenderung soll sofort greifen. */ export async function effectiveSecret() { const { cipher } = await stored(); if (cipher) { const value = decrypt(cipher); if (value) return { value, source: 'db' }; // Schluessel passt nicht mehr - nicht stillschweigend auf die .env // zurueckfallen, sonst sucht man den Fehler an der falschen Stelle. return { value: '', source: 'db-unlesbar' }; } const value = envSecret(); return value ? { value, source: 'env' } : { value: '', source: null }; } /** Fuer die Konfigurationspruefung: gibt es ueberhaupt ein brauchbares Secret? */ export async function hasSecret() { const { value } = await effectiveSecret(); return Boolean(value); } /** Wirft mit klarer Meldung, wenn keins da ist - Gegenstueck zum alten clientSecret(). */ export async function requireSecret() { const { value, source } = await effectiveSecret(); if (value) return value; if (source === 'db-unlesbar') { throw new Error( 'Das hinterlegte Client-Secret lässt sich nicht entschlüsseln. Wurde JWT_SECRET oder ' + 'CRM_ENCRYPTION_KEY geändert? Dann das Secret in den Einstellungen neu eintragen.' ); } throw new Error('MS_CLIENT_SECRET ist nicht gesetzt.'); } /** Tage bis zum Ablauf, auf Kalendertage gerechnet. Null ohne Datum. */ export function daysUntil(date, today = new Date()) { if (!date) return null; const target = Date.parse(`${date}T00:00:00Z`); if (!Number.isFinite(target)) return null; const start = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()); return Math.round((target - start) / 86_400_000); } /** `ok`, `warnung`, `abgelaufen` oder `unbekannt` (kein Datum hinterlegt). */ export function expiryLevel(days) { if (days === null) return 'unbekannt'; if (days < 0) return 'abgelaufen'; if (days <= WARN_DAYS) return 'warnung'; return 'ok'; } /** Zustand fuer die Oberflaeche - ohne das Secret selbst. */ export async function secretStatus() { const { expiresAt, savedAt } = await stored(); const { value, source } = await effectiveSecret(); const days = daysUntil(expiresAt); return { configured: Boolean(value), /** 'db', 'env', 'db-unlesbar' oder null */ source, expires_at: expiresAt || null, saved_at: savedAt || null, days_left: days, level: expiryLevel(days), warn_days: WARN_DAYS, }; } /** Leeres Datum loescht den Eintrag; ungueltiges Datum wird abgewiesen. */ export function normalizeExpiry(value) { const text = String(value ?? '').trim(); if (text === '') return { value: '' }; if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) { return { error: 'Das Ablaufdatum muss im Format JJJJ-MM-TT angegeben werden.' }; } const parsed = new Date(`${text}T00:00:00Z`); if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== text) { return { error: 'Dieses Ablaufdatum gibt es nicht.' }; } return { value: text }; } /** * Prueft ein Secret bei Microsoft, bevor es gespeichert wird: ein * Client-Credentials-Token gegen denselben Tenant. Ein falsches Secret faellt so * sofort auf, statt beim naechsten Postfachabgleich. */ export async function verifySecret(candidate) { const tenant = (process.env.MS_TENANT_ID || '').trim(); const client = (process.env.MS_CLIENT_ID || '').trim(); if (!tenant || !client) { return { ok: false, error: 'MS_TENANT_ID oder MS_CLIENT_ID fehlt in der .env.' }; } let response; try { response = await fetch(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: client, client_secret: candidate, scope: 'https://graph.microsoft.com/.default', grant_type: 'client_credentials', }), signal: AbortSignal.timeout(20_000), }); } catch (err) { return { ok: false, error: `Microsoft ist nicht erreichbar: ${err.message}` }; } const data = await response.json().catch(() => null); if (!response.ok || !data?.access_token) { const detail = data?.error_description || data?.error || `HTTP ${response.status}`; return { ok: false, error: String(detail).split('\n')[0] }; } return { ok: true }; } /** * Speichert Secret und/oder Ablaufdatum. `secret` leer bedeutet: nur das Datum * anfassen, das gespeicherte Secret bleibt. */ export async function saveSecret({ secret = '', expiresAt = null } = {}) { if (secret) { await setSetting(KEY_CIPHER, encrypt(secret)); await setSetting(KEY_SAVED, new Date().toISOString()); } if (expiresAt !== null) { await setSetting(KEY_EXPIRES, expiresAt); } return secretStatus(); } /** Nimmt das hinterlegte Secret zurueck - ab dann gilt wieder die .env. */ export async function clearStoredSecret() { await setSetting(KEY_CIPHER, ''); await setSetting(KEY_SAVED, ''); return secretStatus(); }