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) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user