636fe0df8f
- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf apps/web+packages, noUselessEscapeInRegex, useConst, useExponentiationOperator) sowie fuenf ungesicherte Regeln (useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate, useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen (ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei fehlender Sitzung geprueft) - noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60, die Fallmarke dokumentiert Absicht) - Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts), fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten) - Sechs weitere, im Plan nicht namentlich gelistete aber gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich bereinigt (groups.service.spec.ts, cert-manager.test.tsx, ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/ noUnusedImports/noUnusedFunctionParameters zu erreichen Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...). Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten 621/542 — eine Differenz von 1, weil das Streichen des Namens aus `catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint --force 5/5. Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben): - force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad, nicht die HTTP-Methode - change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf der Seite stehen (keine Weiterleitung, keine Aktualisierung der Benutzerablage) - VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst sich doppelt ausloesen - SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
106 lines
3.7 KiB
TypeScript
106 lines
3.7 KiB
TypeScript
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<string>(ENCRYPTION_KEY_ENV);
|
|
const legacy = this.configService.get<string>(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;
|
|
}
|
|
}
|