import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; /** Current name of the platform-wide encryption key. */ export const ENCRYPTION_KEY_ENV = 'TESSERA_ENCRYPTION_KEY'; /** * Previous name, still accepted. It was called after the calendar module * because that module happened to need encryption first (Phase 5); every * feature since — SMTP, the DKV and tender mailboxes, and the LDAP bind * password — has shared the same key. Renaming without keeping this fallback * would stop every existing installation at the next start, since their .env * still carries the old name. */ export const LEGACY_ENCRYPTION_KEY_ENV = 'CALENDAR_ENCRYPTION_KEY'; /** * Platform-wide encryption for stored credentials. * * AES-256-GCM with a 32-byte hex key, values stored as `iv:authTag:ciphertext` * (hex-joined). Used for every credential Tessera has to replay against a * third party and therefore cannot hash: calendar sources, SMTP, the DKV and * tender mailboxes, and the LDAP bind password. * * Security: T-05-10 — credentials encrypted at rest, never returned in GET * responses. * * The key is read in the constructor (not onModuleInit) so async factories in * other modules (e.g. MailModule.forRootAsync) can call decrypt() before * NestJS lifecycle hooks run. */ @Injectable() export class CryptoService { private readonly logger = new Logger(CryptoService.name); private readonly key: Buffer; constructor(private readonly configService: ConfigService) { const current = this.configService.get(ENCRYPTION_KEY_ENV); const legacy = this.configService.get(LEGACY_ENCRYPTION_KEY_ENV); const hexKey = current || legacy; if (!hexKey) { throw new Error( `${ENCRYPTION_KEY_ENV} is not set. Generate one with: openssl rand -hex 32`, ); } if (hexKey.length !== 64) { const usedName = current ? ENCRYPTION_KEY_ENV : LEGACY_ENCRYPTION_KEY_ENV; throw new Error( `${usedName} must be a 64-character hex string (32 bytes). Got ${hexKey.length} characters.`, ); } if (!current && legacy) { this.logger.warn( `${LEGACY_ENCRYPTION_KEY_ENV} ist veraltet und wird nur noch aus Kompatibilitaet gelesen. ` + `Denselben Wert unter ${ENCRYPTION_KEY_ENV} eintragen — der Schluessel gilt fuer alle ` + `gespeicherten Zugangsdaten, nicht nur fuer Kalender.`, ); } this.key = Buffer.from(hexKey, 'hex'); } /** * Encrypts plaintext using AES-256-GCM. * @returns `iv:authTag:ciphertext` (all hex-encoded, colon-separated) */ encrypt(plaintext: string): string { const iv = randomBytes(12); // 96-bit IV for GCM const cipher = createCipheriv('aes-256-gcm', this.key, iv); let encrypted = cipher.update(plaintext, 'utf8', 'hex'); encrypted += cipher.final('hex'); const authTag = cipher.getAuthTag().toString('hex'); return `${iv.toString('hex')}:${authTag}:${encrypted}`; } /** * Decrypts a stored `iv:authTag:ciphertext` value. * @returns The original plaintext */ decrypt(stored: string): string { const parts = stored.split(':'); if (parts.length !== 3) { throw new Error('Invalid encrypted value format. Expected iv:authTag:ciphertext'); } const [ivHex, authTagHex, ciphertext] = parts; const iv = Buffer.from(ivHex, 'hex'); const authTag = Buffer.from(authTagHex, 'hex'); const decipher = createDecipheriv('aes-256-gcm', this.key, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(ciphertext, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } }