// Verschluesselter Speicher fuer Zugangsdaten. Siehe ADR-0005. // // Bewusst NICHT in der SQLite-Datenbank: die wird per VACUUM INTO auf die // Windows-Seite gesichert, wo sie in einer Cloud-Synchronisation landen kann. // // Der Schluessel liegt im selben Volume wie die Daten. Das schuetzt nicht // gegen jemanden mit Zugriff auf das Volume, und tut auch nicht so. Es // schuetzt gegen versehentliche Anzeige im Stream, gegen verirrte Kopien und // gegen Grep-Unfaelle - und genau darum geht es hier. import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { paths } from '#core/config.ts'; import { logger } from '#core/logger.ts'; const log = logger('secrets'); const FILE = path.join(paths.db, 'secrets.json'); const KEYFILE = path.join(paths.db, '.secret-key'); export type Role = 'broadcaster' | 'bot'; export type TokenSet = { accessToken: string; refreshToken: string; expiresAt: string; // ISO scopes: string[]; userId: string; login: string; }; export type SecretStore = { apps: Partial>; tokens: Partial>; obs?: { password: string }; discord?: { /** Bot-Token der Discord-App. Nur fuers Einrichten und spaeter fuer den Bot. */ botToken?: string; /** Server-ID ("Guild"), damit das Einrichten kein Argument braucht. */ guildId?: string; goLiveWebhook?: string; recapWebhook?: string; clipsWebhook?: string; /** Interner Kanal: Entwuerfe und Fehlermeldungen, nur fuer Mods sichtbar. */ technikWebhook?: string; }; /** * Zugang zur Weboberflaeche. Erst noetig, seit der Dienst nicht mehr auf * 127.0.0.1 haengt - vorher war "wer am Rechner sitzt, darf" die Regel. */ web?: { passwordHash?: string; passwordSalt?: string; /** Steht in der Adresse jeder Browser-Source. Darf nur lesen. */ overlayKey?: string; /** * Signiert die Sitzungs-Cookies. Liegt hier und nicht im Arbeitsspeicher, * damit ein Neustart niemanden abmeldet - der Dienst startet bei jedem * Ausrollen neu. */ sessionKey?: string; }; bluesky?: { handle: string; appPassword: string }; /** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */ update?: { token: string }; }; const EMPTY: SecretStore = { apps: {}, tokens: {} }; function key(): Buffer { fs.mkdirSync(paths.db, { recursive: true }); if (fs.existsSync(KEYFILE)) return Buffer.from(fs.readFileSync(KEYFILE, 'utf8').trim(), 'base64'); const k = crypto.randomBytes(32); fs.writeFileSync(KEYFILE, k.toString('base64'), { mode: 0o600 }); log.info('Neuer Verschluesselungsschluessel erzeugt'); return k; } function encrypt(plain: string): string { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv); const body = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); return [iv.toString('base64'), cipher.getAuthTag().toString('base64'), body.toString('base64')] .join('.'); } function decrypt(blob: string): string { const [ivB64, tagB64, bodyB64] = blob.split('.'); if (!ivB64 || !tagB64 || !bodyB64) throw new Error('secrets.json ist beschaedigt'); const decipher = crypto.createDecipheriv('aes-256-gcm', key(), Buffer.from(ivB64, 'base64')); decipher.setAuthTag(Buffer.from(tagB64, 'base64')); return Buffer.concat([decipher.update(Buffer.from(bodyB64, 'base64')), decipher.final()]) .toString('utf8'); } let cache: SecretStore | null = null; export function readSecrets(): SecretStore { if (cache) return cache; if (!fs.existsSync(FILE)) { cache = structuredClone(EMPTY); return cache; } try { cache = JSON.parse(decrypt(fs.readFileSync(FILE, 'utf8'))) as SecretStore; } catch (err) { log.error(`secrets.json nicht lesbar (${(err as Error).message}) - starte mit leerem Speicher`); cache = structuredClone(EMPTY); } return cache; } export function writeSecrets(next: SecretStore): void { fs.mkdirSync(paths.db, { recursive: true }); fs.writeFileSync(FILE, encrypt(JSON.stringify(next)), { mode: 0o600 }); cache = next; } export function updateSecrets(fn: (s: SecretStore) => void): SecretStore { const s = structuredClone(readSecrets()); fn(s); writeSecrets(s); return s; } /** Zustand fuer die Einrichtungsseite - enthaelt nie einen Geheimwert. */ export type SecretStatus = { apps: Record; tokens: Record; obs: boolean; ready: boolean; }; export function secretStatus(): SecretStatus { const s = readSecrets(); const roles: Role[] = ['broadcaster', 'bot']; const apps = {} as SecretStatus['apps']; const tokens = {} as SecretStatus['tokens']; for (const r of roles) { // Die Client-ID ist kein Geheimnis und wird angezeigt, damit man sieht, // welche App hinterlegt ist. Das Secret nie. apps[r] = { clientId: s.apps[r]?.clientId ?? '', hasSecret: Boolean(s.apps[r]?.clientSecret) }; const t = s.tokens[r]; tokens[r] = t ? { connected: true, login: t.login, expiresAt: t.expiresAt, scopes: t.scopes.length } : { connected: false }; } return { apps, tokens, obs: Boolean(s.obs?.password), ready: Boolean(s.apps.broadcaster?.clientId && s.tokens.broadcaster), }; }