From 4f687eaea969561ff10f4d8bfbfacae641bdd373 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 11 Aug 2026 14:10:52 +0200 Subject: [PATCH] feat(ldap): encrypt the bind password at rest The LDAP bind password was the only credential still stored in clear text. CalendarSource, SmtpConfig, DkvModuleConfig and TenderEmailConfig have been AES-256-GCM encrypted for a while; LDAP simply predated the encryption service and was never brought along. Hashing is not an option here: Tessera has to replay this password to bind against the directory, so it must stay recoverable. Encryption at rest covers the case a hash cannot help with either way -- a database dump or backup leaving the host without the key, which lives in the application environment. It does not protect against a compromised host, and does not pretend to. Reuses CalendarCryptoService, the same provider SettingsModule, DkvModule and TendersModule already inject, rather than introducing a second crypto path. The name is a historical accident and is noted as such in LdapModule; renaming it touches five modules and belongs in its own change. Decryption sits in getConfig()/getAllActiveConfigs(), the two methods every consumer already goes through, so callers keep reading a plain `bindPassword` and the controller keeps masking it to '********' in responses. The migration only renames the column -- SQL cannot encrypt, since the key is not in the database. An idempotent bootstrap backfill encrypts rows written before this change, and until it has run the read path passes a legacy plaintext value through unchanged so the sync does not break in that window. A failed decrypt throws rather than returning null: a wrong key must not read as "no password configured" and silently turn an authenticated bind into an anonymous one. Co-Authored-By: Claude Opus 5 (1M context) --- .../migration.sql | 22 +++ apps/api/prisma/schema.prisma | 2 +- apps/api/src/ldap/ldap-config.service.spec.ts | 178 ++++++++++++++++++ apps/api/src/ldap/ldap-config.service.ts | 130 ++++++++++++- apps/api/src/ldap/ldap.module.ts | 9 +- 5 files changed, 330 insertions(+), 11 deletions(-) create mode 100644 apps/api/prisma/migrations/20260811140000_encrypt_ldap_bind_password/migration.sql create mode 100644 apps/api/src/ldap/ldap-config.service.spec.ts diff --git a/apps/api/prisma/migrations/20260811140000_encrypt_ldap_bind_password/migration.sql b/apps/api/prisma/migrations/20260811140000_encrypt_ldap_bind_password/migration.sql new file mode 100644 index 0000000..86261ae --- /dev/null +++ b/apps/api/prisma/migrations/20260811140000_encrypt_ldap_bind_password/migration.sql @@ -0,0 +1,22 @@ +-- Das LDAP-Bind-Passwort lag als einziges Zugangsdatum im Klartext in der +-- Datenbank. CalendarSource, SmtpConfig, DkvModuleConfig und TenderEmailConfig +-- speichern ihre Zugangsdaten laengst AES-256-GCM-verschluesselt; LDAP war +-- schlicht frueher da als der Verschluesselungsdienst und wurde nie nachgezogen. +-- +-- Hashen scheidet hier aus: Tessera muss sich mit genau diesem Passwort am +-- Domain Controller anmelden, braucht es also im Original. Deshalb symmetrische +-- Verschluesselung mit einem Schluessel ausserhalb der Datenbank. +-- +-- Diese Migration benennt nur die Spalte um, damit am Schema ablesbar ist, was +-- drinsteht -- gleiche Namensform wie bei den vier anderen Feldern. Die +-- eigentliche Verschluesselung des vorhandenen Werts kann SQL nicht leisten +-- (der Schluessel liegt in der Anwendungsumgebung, nicht in der Datenbank): +-- die uebernimmt ein einmaliger, idempotenter Backfill beim naechsten Start +-- der API (LdapConfigService.encryptLegacyBindPasswordsOnBootstrap). +-- +-- Bis dieser Backfill gelaufen ist, steht in der umbenannten Spalte weiterhin +-- Klartext. Der Lesepfad erkennt das an der fehlenden iv:authTag:ciphertext-Form +-- und reicht den Wert unveraendert durch, damit der LDAP-Sync in dem Zeitfenster +-- nicht bricht. + +ALTER TABLE "LdapConfig" RENAME COLUMN "bindPassword" TO "encryptedBindPassword"; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index fe2be76..d47f1f2 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -69,7 +69,7 @@ model LdapConfig { serverUrl String baseDn String bindDn String? - bindPassword String? + encryptedBindPassword String? // AES-256-GCM ciphertext (iv:authTag:ciphertext hex), wie CalendarSource/SmtpConfig searchFilter String @default("(objectClass=person)") syncIntervalMin Int @default(0) isActive Boolean @default(true) diff --git a/apps/api/src/ldap/ldap-config.service.spec.ts b/apps/api/src/ldap/ldap-config.service.spec.ts new file mode 100644 index 0000000..379f0f7 --- /dev/null +++ b/apps/api/src/ldap/ldap-config.service.spec.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LdapConfigService } from './ldap-config.service'; + +/** + * Das Bind-Passwort ist das einzige Zugangsdatum, das nicht gehasht werden + * kann — Tessera muss sich damit am Domain Controller anmelden, braucht es + * also im Original. Deshalb Verschluesselung, und deshalb diese Tests: sie + * halten fest, dass der Klartext nie in die Datenbank geschrieben wird, dass + * Aufrufer trotzdem weiterhin ein entschluesseltes `bindPassword` sehen, und + * dass ein Wert aus der Zeit vor der Verschluesselung den Sync nicht bricht. + */ + +// Ein durchschaubarer Ersatz fuer AES-256-GCM, der die gespeicherte Form +// iv:authTag:ciphertext nachbildet — die Tests pruefen das Zusammenspiel, +// nicht die Krypto-Bibliothek. +const crypto = { + encrypt: vi.fn((plaintext: string) => + ['aa11', 'bb22', Buffer.from(plaintext, 'utf8').toString('hex')].join(':'), + ), + decrypt: vi.fn((stored: string) => { + const [, , ciphertext] = stored.split(':'); + return Buffer.from(ciphertext, 'hex').toString('utf8'); + }), +}; + +const CONFIG_ROW = { + id: 'cfg1', + tenantId: 't1', + serverUrl: 'ldap://example', + baseDn: 'dc=example,dc=com', + bindDn: 'svc@example', + encryptedBindPassword: null as string | null, + searchFilter: '(objectClass=person)', + syncIntervalMin: 0, + isActive: true, + tlsRejectUnauthorized: true, + groupFilterDns: [] as string[], + userExcludeList: [] as string[], + fieldMappings: [], +}; + +describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => { + let prisma: any; + let service: LdapConfigService; + + beforeEach(() => { + vi.clearAllMocks(); + prisma = { + ldapConfig: { + findUnique: vi.fn(), + findMany: vi.fn().mockResolvedValue([]), + create: vi.fn((args: any) => + Promise.resolve({ ...CONFIG_ROW, ...args.data }), + ), + update: vi.fn((args: any) => + Promise.resolve({ ...CONFIG_ROW, ...args.data }), + ), + }, + }; + service = new LdapConfigService(prisma, crypto as any); + }); + + it('schreibt beim Anlegen den Chiffretext, nie den Klartext', async () => { + await service.createConfig('t1', { + serverUrl: 'ldap://example', + baseDn: 'dc=example,dc=com', + bindDn: 'svc@example', + bindPassword: 'geheim', + } as any); + + const written = prisma.ldapConfig.create.mock.calls[0][0].data; + expect(written.encryptedBindPassword).toBe( + 'aa11:bb22:' + Buffer.from('geheim').toString('hex'), + ); + expect(JSON.stringify(written)).not.toContain('geheim'); + // Die alte Klartext-Spalte darf nicht wieder auftauchen. + expect(written).not.toHaveProperty('bindPassword'); + }); + + it('verschluesselt auch beim Aktualisieren und laesst das Feld sonst unberuehrt', async () => { + await service.updateConfig('t1', { bindPassword: 'neu' } as any); + const first = prisma.ldapConfig.update.mock.calls[0][0].data; + expect(first.encryptedBindPassword).toBe( + 'aa11:bb22:' + Buffer.from('neu').toString('hex'), + ); + + await service.updateConfig('t1', { serverUrl: 'ldap://anders' } as any); + const second = prisma.ldapConfig.update.mock.calls[1][0].data; + expect(second).not.toHaveProperty('encryptedBindPassword'); + }); + + it('leeres Passwort loescht den Wert, statt Leerstring zu verschluesseln', async () => { + await service.updateConfig('t1', { bindPassword: '' } as any); + const written = prisma.ldapConfig.update.mock.calls[0][0].data; + expect(written.encryptedBindPassword).toBeNull(); + expect(crypto.encrypt).not.toHaveBeenCalled(); + }); + + it('gibt Aufrufern weiterhin ein entschluesseltes bindPassword', async () => { + prisma.ldapConfig.findUnique.mockResolvedValue({ + ...CONFIG_ROW, + encryptedBindPassword: 'aa11:bb22:' + Buffer.from('geheim').toString('hex'), + }); + + const config: any = await service.getConfig('t1'); + + expect(config.bindPassword).toBe('geheim'); + expect(config).not.toHaveProperty('encryptedBindPassword'); + }); + + it('reicht einen Altbestand-Klartext unveraendert durch, statt den Sync zu brechen', async () => { + // Zeitfenster zwischen Spalten-Umbenennung und Bootstrap-Backfill. + prisma.ldapConfig.findUnique.mockResolvedValue({ + ...CONFIG_ROW, + encryptedBindPassword: 'nochKlartext', + }); + + const config: any = await service.getConfig('t1'); + + expect(config.bindPassword).toBe('nochKlartext'); + expect(crypto.decrypt).not.toHaveBeenCalled(); + }); + + it('meldet einen fehlgeschlagenen Entschluesselungsversuch, statt still kein Passwort zu liefern', async () => { + // Ein falscher Schluessel darf nicht wie "kein Passwort konfiguriert" + // aussehen — das wuerde einen authentifizierten Bind unbemerkt in einen + // anonymen verwandeln. + crypto.decrypt.mockImplementationOnce(() => { + throw new Error('Unsupported state or unable to authenticate data'); + }); + prisma.ldapConfig.findUnique.mockResolvedValue({ + ...CONFIG_ROW, + encryptedBindPassword: 'aa11:bb22:ffff', + }); + + await expect(service.getConfig('t1')).rejects.toThrow(); + }); + + it('verschluesselt beim Start genau die Zeilen, die noch Klartext tragen', async () => { + prisma.ldapConfig.findMany.mockResolvedValue([ + { id: 'a', tenantId: 't1', encryptedBindPassword: 'klartext' }, + { + id: 'b', + tenantId: 't2', + encryptedBindPassword: 'aa11:bb22:' + Buffer.from('schon').toString('hex'), + }, + { id: 'c', tenantId: 't3', encryptedBindPassword: null }, + ]); + + await service.onApplicationBootstrap(); + + expect(prisma.ldapConfig.update).toHaveBeenCalledTimes(1); + const call = prisma.ldapConfig.update.mock.calls[0][0]; + expect(call.where).toEqual({ id: 'a' }); + expect(call.data.encryptedBindPassword).toBe( + 'aa11:bb22:' + Buffer.from('klartext').toString('hex'), + ); + }); + + it('ist beim zweiten Start ein No-Op', async () => { + prisma.ldapConfig.findMany.mockResolvedValue([ + { + id: 'a', + tenantId: 't1', + encryptedBindPassword: 'aa11:bb22:' + Buffer.from('x').toString('hex'), + }, + ]); + + await service.onApplicationBootstrap(); + + expect(prisma.ldapConfig.update).not.toHaveBeenCalled(); + }); + + it('laesst einen fehlgeschlagenen Backfill den Start nicht verhindern', async () => { + prisma.ldapConfig.findMany.mockRejectedValue(new Error('db weg')); + await expect(service.onApplicationBootstrap()).resolves.toBeUndefined(); + }); +}); diff --git a/apps/api/src/ldap/ldap-config.service.ts b/apps/api/src/ldap/ldap-config.service.ts index 1b0e5b7..8481722 100644 --- a/apps/api/src/ldap/ldap-config.service.ts +++ b/apps/api/src/ldap/ldap-config.service.ts @@ -1,4 +1,5 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { CalendarCryptoService } from '../calendar/crypto.service'; import { PrismaService } from '../prisma/prisma.service'; import { CreateFieldMappingDto, @@ -6,22 +7,125 @@ import { UpdateLdapConfigDto, } from './dto/ldap-config.dto'; +/** + * Shape of a stored AES-256-GCM value as CalendarCryptoService writes it: + * `iv:authTag:ciphertext`, all hex. Used to tell an encrypted value apart from + * a legacy plaintext one that predates the encryption of this column. + */ +const ENCRYPTED_VALUE_SHAPE = /^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i; + /** * Per-tenant LDAP configuration CRUD (D-18). * Manages LDAP connection settings and field mappings. + * + * The bind password is stored AES-256-GCM-encrypted in + * `LdapConfig.encryptedBindPassword`, the same way CalendarSource, SmtpConfig, + * DkvModuleConfig and TenderEmailConfig store theirs. It cannot be hashed: + * Tessera has to replay this password to bind against the directory, so it + * needs to be recoverable. Encryption at rest protects the one case a hash + * cannot help with anyway — a database dump or backup leaving the host without + * the key that lives in the application environment. + * + * Every consumer reads the config through `getConfig()` or + * `getAllActiveConfigs()`, so decryption happens in exactly those two places + * and callers keep seeing a plain `bindPassword` field. The controller still + * masks it to '********' in API responses (T-02-17). */ @Injectable() -export class LdapConfigService { - constructor(private prisma: PrismaService) {} +export class LdapConfigService implements OnApplicationBootstrap { + private readonly logger = new Logger(LdapConfigService.name); + + constructor( + private prisma: PrismaService, + private readonly crypto: CalendarCryptoService, + ) {} + + /** + * One-time, idempotent backfill of rows written before this column was + * encrypted. SQL cannot do this — the key lives in the application + * environment, not in the database — so the migration only renames the + * column and the actual encryption happens here on the next start. + * + * Runs on every boot and is a no-op once every row is encrypted. A failure + * is logged and swallowed: a tenant whose bind password could not be + * re-encrypted still authenticates, because the read path below tolerates a + * legacy plaintext value. + */ + async onApplicationBootstrap(): Promise { + try { + const configs = await this.prisma.ldapConfig.findMany({ + select: { id: true, tenantId: true, encryptedBindPassword: true }, + }); + + const legacy = configs.filter( + (config) => + config.encryptedBindPassword && + !ENCRYPTED_VALUE_SHAPE.test(config.encryptedBindPassword), + ); + if (legacy.length === 0) return; + + for (const config of legacy) { + await this.prisma.ldapConfig.update({ + where: { id: config.id }, + data: { + encryptedBindPassword: this.crypto.encrypt( + config.encryptedBindPassword as string, + ), + }, + }); + } + + this.logger.log( + `LDAP-Bind-Passwort verschluesselt: ${legacy.length} Konfiguration(en) nachgezogen`, + ); + } catch (err) { + this.logger.error( + `Backfill der LDAP-Bind-Passwoerter fehlgeschlagen: ${(err as Error).message}`, + ); + } + } + + /** + * Decrypt for internal use. A value that is not in `iv:authTag:ciphertext` + * form predates the encryption and is returned unchanged — that window + * exists between the column rename and the bootstrap backfill above, and + * must not break the sync. + */ + private decryptBindPassword(stored: string | null): string | null { + if (!stored) return null; + if (!ENCRYPTED_VALUE_SHAPE.test(stored)) return stored; + try { + return this.crypto.decrypt(stored); + } catch (err) { + // A wrong or rotated key must not read as "no password configured" — + // that would silently turn an authenticated bind into an anonymous one. + this.logger.error( + `LDAP-Bind-Passwort konnte nicht entschluesselt werden (falscher CALENDAR_ENCRYPTION_KEY?): ${(err as Error).message}`, + ); + throw err; + } + } + + /** Map a stored row to what callers expect: a plain `bindPassword` field. */ + private withDecryptedPassword< + T extends { encryptedBindPassword: string | null }, + >(config: T): Omit & { bindPassword: string | null } { + const { encryptedBindPassword, ...rest } = config; + return { + ...rest, + bindPassword: this.decryptBindPassword(encryptedBindPassword), + }; + } /** * Get LDAP config for a tenant, including field mappings. */ async getConfig(tenantId: string) { - return this.prisma.ldapConfig.findUnique({ + const config = await this.prisma.ldapConfig.findUnique({ where: { tenantId }, include: { fieldMappings: true }, }); + return config ? this.withDecryptedPassword(config) : null; } /** @@ -29,13 +133,15 @@ export class LdapConfigService { * Defaults: displayName -> displayName, mail -> email, sAMAccountName -> username */ async createConfig(tenantId: string, dto: CreateLdapConfigDto) { - return this.prisma.ldapConfig.create({ + const created = await this.prisma.ldapConfig.create({ data: { tenantId, serverUrl: dto.serverUrl, baseDn: dto.baseDn, bindDn: dto.bindDn, - bindPassword: dto.bindPassword, + encryptedBindPassword: dto.bindPassword + ? this.crypto.encrypt(dto.bindPassword) + : null, searchFilter: dto.searchFilter ?? '(objectClass=person)', syncIntervalMin: dto.syncIntervalMin ?? 60, isActive: dto.isActive ?? true, @@ -60,20 +166,24 @@ export class LdapConfigService { }, include: { fieldMappings: true }, }); + return this.withDecryptedPassword(created); } /** * Update LDAP config for a tenant. */ async updateConfig(tenantId: string, dto: UpdateLdapConfigDto) { - return this.prisma.ldapConfig.update({ + const updated = await this.prisma.ldapConfig.update({ where: { tenantId }, data: { ...(dto.serverUrl !== undefined && { serverUrl: dto.serverUrl }), ...(dto.baseDn !== undefined && { baseDn: dto.baseDn }), ...(dto.bindDn !== undefined && { bindDn: dto.bindDn }), + // An empty string means "clear the password", not "encrypt nothing". ...(dto.bindPassword !== undefined && { - bindPassword: dto.bindPassword, + encryptedBindPassword: dto.bindPassword + ? this.crypto.encrypt(dto.bindPassword) + : null, }), ...(dto.searchFilter !== undefined && { searchFilter: dto.searchFilter, @@ -94,6 +204,7 @@ export class LdapConfigService { }, include: { fieldMappings: true }, }); + return this.withDecryptedPassword(updated); } /** @@ -137,9 +248,10 @@ export class LdapConfigService { * tenants need auto-sync. */ async getAllActiveConfigs() { - return this.prisma.ldapConfig.findMany({ + const configs = await this.prisma.ldapConfig.findMany({ where: { isActive: true }, include: { tenant: true, fieldMappings: true }, }); + return configs.map((config) => this.withDecryptedPassword(config)); } } diff --git a/apps/api/src/ldap/ldap.module.ts b/apps/api/src/ldap/ldap.module.ts index 92319ef..10f6836 100644 --- a/apps/api/src/ldap/ldap.module.ts +++ b/apps/api/src/ldap/ldap.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; +import { CalendarModule } from '../calendar/calendar.module'; import { GroupsModule } from '../groups/groups.module'; import { UserModule } from '../user/user.module'; import { LdapConfigService } from './ldap-config.service'; @@ -17,9 +18,15 @@ import { LdapService } from './ldap.service'; * GroupsService.reassignDefaultBeforeDelete()/ensureDefaultGroup() from * syncBoundGroupsForTenant() (Plan 16-03, D-06) — no cycle: GroupsModule * imports neither LdapModule nor UserModule. + * + * CalendarModule is imported for CalendarCryptoService, which encrypts the + * bind password at rest — the same provider SettingsModule, DkvModule and + * TendersModule already use for their own credentials. The name is a + * historical accident (the calendar module happened to need encryption + * first), not a statement about ownership. */ @Module({ - imports: [ScheduleModule.forRoot(), UserModule, GroupsModule], + imports: [ScheduleModule.forRoot(), UserModule, GroupsModule, CalendarModule], controllers: [LdapController], providers: [LdapService, LdapConfigService, LdapSyncScheduler], exports: [LdapService, LdapConfigService],