diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 5ff5e96..8d3b0f0 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -18,6 +18,7 @@ import { DomaincheckModule } from './domaincheck/domaincheck.module'; import { GroupsModule } from './groups/groups.module'; import { ModuleRegistryModule } from './module-registry/module-registry.module'; import { PrismaModule } from './prisma/prisma.module'; +import { CryptoModule } from './crypto/crypto.module'; import { SettingsModule } from './settings/settings.module'; import { TenantGuard } from './tenant/tenant.guard'; import { TenantModule } from './tenant/tenant.module'; @@ -29,6 +30,7 @@ import { UserModule } from './user/user.module'; ConfigModule.forRoot({ isGlobal: true }), ScheduleModule.forRoot(), PrismaModule, + CryptoModule, AuthModule, UserModule, TenantModule, diff --git a/apps/api/src/calendar/calendar.module.ts b/apps/api/src/calendar/calendar.module.ts index ff2e0b5..b2f78f5 100644 --- a/apps/api/src/calendar/calendar.module.ts +++ b/apps/api/src/calendar/calendar.module.ts @@ -1,7 +1,6 @@ import { Module } from '@nestjs/common'; import { CalendarController } from './calendar.controller'; import { CalendarService } from './calendar.service'; -import { CalendarCryptoService } from './crypto.service'; import { CalDAVProvider } from './providers/caldav.provider'; import { ICSProvider } from './providers/ics.provider'; import { ExchangeProvider } from './providers/exchange.provider'; @@ -10,7 +9,6 @@ import { ExchangeProvider } from './providers/exchange.provider'; * NestJS module for calendar source management and event aggregation. * * Provides: - * - CalendarCryptoService: AES-256-GCM encryption for calendar credentials * - CalendarService: Source CRUD + event aggregation across providers * - CalDAVProvider: CalDAV protocol integration via tsdav * - ICSProvider: ICS file fetch + parse via node-ical @@ -23,11 +21,10 @@ import { ExchangeProvider } from './providers/exchange.provider'; controllers: [CalendarController], providers: [ CalendarService, - CalendarCryptoService, CalDAVProvider, ICSProvider, ExchangeProvider, ], - exports: [CalendarService, CalendarCryptoService], + exports: [CalendarService], }) export class CalendarModule {} diff --git a/apps/api/src/calendar/calendar.service.ts b/apps/api/src/calendar/calendar.service.ts index 4e38a3c..1a96968 100644 --- a/apps/api/src/calendar/calendar.service.ts +++ b/apps/api/src/calendar/calendar.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import { CalendarCryptoService } from './crypto.service'; +import { CryptoService } from '../crypto/crypto.service'; import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto'; import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto'; import { TestCalendarSourceConfigDto } from './dto/test-calendar-source-config.dto'; @@ -99,7 +99,7 @@ const CACHE_TTL_MS = 5 * 60 * 1000; * Service for calendar source CRUD and event aggregation. * * Source config is per-user (D-09), not per-tenant. - * Credentials encrypted at rest via CalendarCryptoService (T-05-10). + * Credentials encrypted at rest via CryptoService (T-05-10). */ @Injectable() export class CalendarService { @@ -110,7 +110,7 @@ export class CalendarService { constructor( private readonly prisma: PrismaService, - private readonly crypto: CalendarCryptoService, + private readonly crypto: CryptoService, private readonly icsProvider: ICSProvider, private readonly caldavProvider: CalDAVProvider, private readonly exchangeProvider: ExchangeProvider, diff --git a/apps/api/src/calendar/crypto.service.ts b/apps/api/src/calendar/crypto.service.ts deleted file mode 100644 index 7347f5d..0000000 --- a/apps/api/src/calendar/crypto.service.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; - -/** - * Encryption service for calendar source credentials. - * - * Uses AES-256-GCM with a 32-byte hex key from CALENDAR_ENCRYPTION_KEY env var. - * Encrypted values are stored as `iv:authTag:ciphertext` (hex-joined). - * - * Security: T-05-10 — credentials encrypted at rest, never returned in GET responses. - * - * Key is initialised in the constructor (not onModuleInit) so that async factory - * functions in other modules (e.g. MailModule.forRootAsync) can call decrypt() - * before NestJS lifecycle hooks run. - */ -@Injectable() -export class CalendarCryptoService { - private readonly key: Buffer; - - constructor(private readonly configService: ConfigService) { - const hexKey = this.configService.get('CALENDAR_ENCRYPTION_KEY'); - - if (!hexKey) { - throw new Error( - 'CALENDAR_ENCRYPTION_KEY is not set. Generate one with: openssl rand -hex 32', - ); - } - - if (hexKey.length !== 64) { - throw new Error( - `CALENDAR_ENCRYPTION_KEY must be a 64-character hex string (32 bytes). Got ${hexKey.length} characters.`, - ); - } - - 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; - } -} diff --git a/apps/api/src/crypto/crypto.module.ts b/apps/api/src/crypto/crypto.module.ts new file mode 100644 index 0000000..2e1ec31 --- /dev/null +++ b/apps/api/src/crypto/crypto.module.ts @@ -0,0 +1,20 @@ +import { Global, Module } from '@nestjs/common'; +import { CryptoService } from './crypto.service'; + +/** + * CryptoModule — the platform's single AES-256-GCM provider for credentials + * that have to be replayed against a third party and therefore cannot be + * hashed. + * + * Global on purpose. Before this module existed the provider lived in + * CalendarModule, so SettingsModule, DkvModule, TendersModule and LdapModule + * each imported CalendarModule just to reach it — an import that suggested a + * dependency on calendars where there was none. Making it global removes that + * false coupling instead of moving it to a different host module. + */ +@Global() +@Module({ + providers: [CryptoService], + exports: [CryptoService], +}) +export class CryptoModule {} diff --git a/apps/api/src/crypto/crypto.service.spec.ts b/apps/api/src/crypto/crypto.service.spec.ts new file mode 100644 index 0000000..dab6a95 --- /dev/null +++ b/apps/api/src/crypto/crypto.service.spec.ts @@ -0,0 +1,99 @@ +import { Logger } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; +import { + CryptoService, + ENCRYPTION_KEY_ENV, + LEGACY_ENCRYPTION_KEY_ENV, +} from './crypto.service'; + +/** + * Der Schluessel hiess frueher CALENDAR_ENCRYPTION_KEY, weil das Kalender-Modul + * die Verschluesselung zuerst brauchte. Er gilt laengst fuer alle gespeicherten + * Zugangsdaten. Diese Tests halten fest, dass die Umbenennung bestehende + * Installationen nicht stehen laesst: der alte Name wird weiter gelesen. + */ + +const KEY_A = 'a'.repeat(64); +const KEY_B = 'b'.repeat(64); + +function makeConfig(values: Record) { + return { get: (name: string) => values[name] } as any; +} + +describe('CryptoService — Schluesselherkunft', () => { + it('nimmt den neuen Namen', () => { + const service = new CryptoService( + makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_A }), + ); + expect(service.decrypt(service.encrypt('geheim'))).toBe('geheim'); + }); + + it('faellt auf den alten Namen zurueck, damit bestehende Installationen starten', () => { + const service = new CryptoService( + makeConfig({ [LEGACY_ENCRYPTION_KEY_ENV]: KEY_A }), + ); + expect(service.decrypt(service.encrypt('geheim'))).toBe('geheim'); + }); + + it('warnt beim Rueckfall auf den alten Namen, statt still weiterzulaufen', () => { + const spy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + + new CryptoService(makeConfig({ [LEGACY_ENCRYPTION_KEY_ENV]: KEY_A })); + + expect(spy).toHaveBeenCalledOnce(); + const message = String(spy.mock.calls[0][0]); + expect(message).toContain(LEGACY_ENCRYPTION_KEY_ENV); + expect(message).toContain(ENCRYPTION_KEY_ENV); + spy.mockRestore(); + }); + + it('warnt NICHT, wenn der neue Name gesetzt ist', () => { + const spy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + new CryptoService(makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_A })); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('bevorzugt den neuen Namen, wenn beide gesetzt sind', () => { + // Entscheidend fuer die Uebergangszeit: steht in der .env noch der alte + // Wert und daneben schon der neue, muss der neue gewinnen — sonst + // verschluesselt die Anwendung mit dem einen und entschluesselt mit dem + // anderen Schluessel. + const withBoth = new CryptoService( + makeConfig({ + [ENCRYPTION_KEY_ENV]: KEY_A, + [LEGACY_ENCRYPTION_KEY_ENV]: KEY_B, + }), + ); + const withNewOnly = new CryptoService( + makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_A }), + ); + + // Was mit dem neuen Schluessel allein verschluesselt wurde, muss die + // Instanz mit beiden Namen lesen koennen. + expect(withBoth.decrypt(withNewOnly.encrypt('geheim'))).toBe('geheim'); + }); + + it('startet ohne Schluessel gar nicht', () => { + expect(() => new CryptoService(makeConfig({}))).toThrow( + /TESSERA_ENCRYPTION_KEY is not set/, + ); + }); + + it('weist einen Schluessel falscher Laenge ab und nennt den verwendeten Namen', () => { + expect( + () => new CryptoService(makeConfig({ [ENCRYPTION_KEY_ENV]: 'zu-kurz' })), + ).toThrow(/TESSERA_ENCRYPTION_KEY must be a 64-character hex string/); + + expect( + () => + new CryptoService(makeConfig({ [LEGACY_ENCRYPTION_KEY_ENV]: 'zu-kurz' })), + ).toThrow(/CALENDAR_ENCRYPTION_KEY must be a 64-character hex string/); + }); + + it('kann nicht entschluesseln, was mit einem anderen Schluessel verschluesselt wurde', () => { + const a = new CryptoService(makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_A })); + const b = new CryptoService(makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_B })); + expect(() => b.decrypt(a.encrypt('geheim'))).toThrow(); + }); +}); diff --git a/apps/api/src/crypto/crypto.service.ts b/apps/api/src/crypto/crypto.service.ts new file mode 100644 index 0000000..7b3319c --- /dev/null +++ b/apps/api/src/crypto/crypto.service.ts @@ -0,0 +1,105 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createCipheriv, createDecipheriv, randomBytes } from '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; + } +} diff --git a/apps/api/src/dkv/dkv.module.ts b/apps/api/src/dkv/dkv.module.ts index 666e292..48a1f23 100644 --- a/apps/api/src/dkv/dkv.module.ts +++ b/apps/api/src/dkv/dkv.module.ts @@ -1,7 +1,6 @@ import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { ModuleRegistryModule } from '../module-registry/module-registry.module'; import { ModuleRegistryService } from '../module-registry/module-registry.service'; -import { CalendarModule } from '../calendar/calendar.module'; import { InboxModule } from '../inbox/inbox.module'; import { SettingsModule } from '../settings/settings.module'; import { DkvController } from './dkv.controller'; @@ -26,8 +25,7 @@ import { seedDkvModule } from './dkv.seed'; * * Imports: * - ModuleRegistryModule: for registry self-seed on init - * - CalendarModule: provides CalendarCryptoService (AES-256-GCM encryption) - * re-exported from CalendarModule.exports — no re-declaration needed here. + * - CryptoService comes from the global CryptoModule — no import needed. * - InboxModule: exports ImapProvider / ExchangeInboxProvider (shared connection * mechanics — DKV's own mailbox config stays independent, D-03) * @@ -41,7 +39,6 @@ import { seedDkvModule } from './dkv.seed'; @Module({ imports: [ ModuleRegistryModule, - CalendarModule, InboxModule, SettingsModule, ], diff --git a/apps/api/src/dkv/dkv.service.ts b/apps/api/src/dkv/dkv.service.ts index d9e18e4..ea4f691 100644 --- a/apps/api/src/dkv/dkv.service.ts +++ b/apps/api/src/dkv/dkv.service.ts @@ -4,9 +4,10 @@ import { Logger, NotFoundException, } from '@nestjs/common'; +import { CryptoService } from '../crypto/crypto.service'; import * as fs from 'fs'; import * as path from 'path'; -import { CalendarCryptoService } from '../calendar/crypto.service'; + import { PrismaService } from '../prisma/prisma.service'; import { DkvExportService } from './dkv-export.service'; import { DkvMailService } from './dkv-mail.service'; @@ -75,7 +76,7 @@ export class DkvService { constructor( private readonly prisma: PrismaService, - private readonly crypto: CalendarCryptoService, + private readonly crypto: CryptoService, private readonly parser: DkvParserService, private readonly exporter: DkvExportService, private readonly mailer: DkvMailService, diff --git a/apps/api/src/ldap/ldap-config.service.ts b/apps/api/src/ldap/ldap-config.service.ts index 8481722..a17b864 100644 --- a/apps/api/src/ldap/ldap-config.service.ts +++ b/apps/api/src/ldap/ldap-config.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; -import { CalendarCryptoService } from '../calendar/crypto.service'; +import { CryptoService } from '../crypto/crypto.service'; + import { PrismaService } from '../prisma/prisma.service'; import { CreateFieldMappingDto, @@ -8,7 +9,7 @@ import { } from './dto/ldap-config.dto'; /** - * Shape of a stored AES-256-GCM value as CalendarCryptoService writes it: + * Shape of a stored AES-256-GCM value as CryptoService 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. */ @@ -37,7 +38,7 @@ export class LdapConfigService implements OnApplicationBootstrap { constructor( private prisma: PrismaService, - private readonly crypto: CalendarCryptoService, + private readonly crypto: CryptoService, ) {} /** @@ -100,7 +101,7 @@ export class LdapConfigService implements OnApplicationBootstrap { // 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}`, + `LDAP-Bind-Passwort konnte nicht entschluesselt werden (falscher TESSERA_ENCRYPTION_KEY?): ${(err as Error).message}`, ); throw err; } diff --git a/apps/api/src/ldap/ldap.module.ts b/apps/api/src/ldap/ldap.module.ts index 10f6836..71ac403 100644 --- a/apps/api/src/ldap/ldap.module.ts +++ b/apps/api/src/ldap/ldap.module.ts @@ -1,6 +1,5 @@ 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'; @@ -19,14 +18,11 @@ import { LdapService } from './ldap.service'; * 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. + * The bind password is encrypted at rest via CryptoService from the global + * CryptoModule — the same provider every other stored credential uses. */ @Module({ - imports: [ScheduleModule.forRoot(), UserModule, GroupsModule, CalendarModule], + imports: [ScheduleModule.forRoot(), UserModule, GroupsModule], controllers: [LdapController], providers: [LdapService, LdapConfigService, LdapSyncScheduler], exports: [LdapService, LdapConfigService], diff --git a/apps/api/src/settings/settings.module.ts b/apps/api/src/settings/settings.module.ts index 25c15d5..296b77f 100644 --- a/apps/api/src/settings/settings.module.ts +++ b/apps/api/src/settings/settings.module.ts @@ -1,5 +1,4 @@ import { Module } from '@nestjs/common'; -import { CalendarModule } from '../calendar/calendar.module'; import { SettingsController } from './settings.controller'; import { SettingsService } from './settings.service'; @@ -10,12 +9,11 @@ import { SettingsService } from './settings.service'; * - SettingsService: SmtpConfig CRUD (encrypted), connection test, startup accessor * - SettingsController: REST endpoints GET/PUT /settings/smtp, POST /settings/smtp/test * - * Imports CalendarModule to get CalendarCryptoService for AES-256-GCM encryption. + * CryptoModule is global — CryptoService is injectable without an import. * PrismaModule is global — no explicit import needed. * Exports SettingsService so other modules (e.g. MailModule, DkvModule) can inject it. */ @Module({ - imports: [CalendarModule], controllers: [SettingsController], providers: [SettingsService], exports: [SettingsService], diff --git a/apps/api/src/settings/settings.service.ts b/apps/api/src/settings/settings.service.ts index db8edd4..1b3a9d4 100644 --- a/apps/api/src/settings/settings.service.ts +++ b/apps/api/src/settings/settings.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; -import { CalendarCryptoService } from '../calendar/crypto.service'; +import { CryptoService } from '../crypto/crypto.service'; + import { PrismaService } from '../prisma/prisma.service'; import { SmtpConfigDto } from './dto/smtp-config.dto'; import * as nodemailer from 'nodemailer'; @@ -27,7 +28,7 @@ export class SettingsService { constructor( private readonly prisma: PrismaService, - private readonly crypto: CalendarCryptoService, + private readonly crypto: CryptoService, ) {} /** @@ -50,7 +51,7 @@ export class SettingsService { * Encrypts the password with AES-256-GCM when a new password is provided. * When `dto.password` is empty or absent, the existing encrypted password is preserved. * - * T-07-08: Encryption via CalendarCryptoService. Never logs the plaintext password. + * T-07-08: Encryption via CryptoService. Never logs the plaintext password. */ async saveSmtpConfig(tenantId: string, dto: SmtpConfigDto) { // Determine the encrypted password to store diff --git a/apps/api/src/tenders/adapters/email-alert.adapter.spec.ts b/apps/api/src/tenders/adapters/email-alert.adapter.spec.ts index fa76eff..67f076e 100644 --- a/apps/api/src/tenders/adapters/email-alert.adapter.spec.ts +++ b/apps/api/src/tenders/adapters/email-alert.adapter.spec.ts @@ -12,11 +12,11 @@ import { * email-alert.adapter.spec — Task 1 (test-first, TDD) proof for the generic * link/subject extraction (D-04): pure functions only, no I/O, mirroring * cosinex.adapter.spec.ts's pure-function spec style. Task 2 adds - * fetchTenders() fan-out coverage (mocked PrismaService/CalendarCryptoService/ + * fetchTenders() fan-out coverage (mocked PrismaService/CryptoService/ * inbox providers — no live DB/network I/O). */ -/** Fake CalendarCryptoService — deterministic reversible encode, not real AES. */ +/** Fake CryptoService — deterministic reversible encode, not real AES. */ function makeFakeCrypto() { return { encrypt: vi.fn((plaintext: string) => `enc:${Buffer.from(plaintext).toString('base64')}`), diff --git a/apps/api/src/tenders/adapters/email-alert.adapter.ts b/apps/api/src/tenders/adapters/email-alert.adapter.ts index e0a30ab..390e0b9 100644 --- a/apps/api/src/tenders/adapters/email-alert.adapter.ts +++ b/apps/api/src/tenders/adapters/email-alert.adapter.ts @@ -1,7 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; +import { CryptoService } from '../../crypto/crypto.service'; import * as cheerio from 'cheerio'; import { createHash } from 'crypto'; -import { CalendarCryptoService } from '../../calendar/crypto.service'; + import { ExchangeInboxProvider } from '../../inbox/exchange-inbox.provider'; import { ImapProvider } from '../../inbox/imap.provider'; import type { InboxConfig, InboxMessage } from '../../inbox/inbox-provider.interface'; @@ -123,7 +124,7 @@ export class EmailAlertAdapter implements TenderSourceAdapter { constructor( private readonly prisma: PrismaService, - private readonly crypto: CalendarCryptoService, + private readonly crypto: CryptoService, private readonly imapProvider: ImapProvider, private readonly exchangeProvider: ExchangeInboxProvider, ) {} diff --git a/apps/api/src/tenders/tender-email-config.service.spec.ts b/apps/api/src/tenders/tender-email-config.service.spec.ts index be49874..3828117 100644 --- a/apps/api/src/tenders/tender-email-config.service.spec.ts +++ b/apps/api/src/tenders/tender-email-config.service.spec.ts @@ -3,7 +3,7 @@ import { TenderEmailConfigService } from './tender-email-config.service'; /** * TenderEmailConfigService.spec — Phase 14, Plan 03 (CONFIG-02, D-06/D-07). - * Hand-rolled fake PrismaService (Map) + a fake CalendarCryptoService + * Hand-rolled fake PrismaService (Map) + a fake CryptoService * (deterministic reversible encode, NOT real AES) — same convention as * tender-dedup.service.spec.ts: no live DB/crypto dependency, just proving * this service's own encrypt-preserve-empty / safe-select contract. diff --git a/apps/api/src/tenders/tender-email-config.service.ts b/apps/api/src/tenders/tender-email-config.service.ts index 376e984..8d63c91 100644 --- a/apps/api/src/tenders/tender-email-config.service.ts +++ b/apps/api/src/tenders/tender-email-config.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { CalendarCryptoService } from '../calendar/crypto.service'; +import { CryptoService } from '../crypto/crypto.service'; + import { PrismaService } from '../prisma/prisma.service'; import type { TenderEmailConfigDto } from './dto/tender-email-config.dto'; @@ -38,7 +39,7 @@ const EMAIL_CONFIG_SAFE_SELECT = { * * This service is used ONLY by the admin GET/PUT /email-config routes * (TendersController). EmailAlertAdapter's own per-tenant poll-time fan-out - * decrypts credentials independently via a direct CalendarCryptoService + * decrypts credentials independently via a direct CryptoService * injection (RESEARCH.md Pattern 1) — it does NOT go through this service, * since the adapter's cross-tenant `findMany({where:{isActive:true}})` read * is a deliberate platform-scheduler exception (see EmailAlertAdapter's @@ -48,7 +49,7 @@ const EMAIL_CONFIG_SAFE_SELECT = { export class TenderEmailConfigService { constructor( private readonly prisma: PrismaService, - private readonly crypto: CalendarCryptoService, + private readonly crypto: CryptoService, ) {} /** diff --git a/apps/api/src/tenders/tenders.module.ts b/apps/api/src/tenders/tenders.module.ts index 9e7c9ea..1318ddf 100644 --- a/apps/api/src/tenders/tenders.module.ts +++ b/apps/api/src/tenders/tenders.module.ts @@ -1,5 +1,4 @@ import { Logger, Module, OnModuleInit } from '@nestjs/common'; -import { CalendarModule } from '../calendar/calendar.module'; import { InboxModule } from '../inbox/inbox.module'; import { ModuleRegistryModule } from '../module-registry/module-registry.module'; import { ModuleRegistryService } from '../module-registry/module-registry.service'; @@ -112,8 +111,9 @@ import { TendersController } from './tenders.controller'; * Phase 14, Plan 03 (INGEST-05): adds `EmailAlertAdapter` (registered * alongside the existing adapters — `email-alert` is not denylisted) and * `TenderEmailConfigService` (per-tenant admin CRUD for the alert mailbox - * config, D-06/D-07). `CalendarModule`/`InboxModule` are imported so the - * adapter/service can inject `CalendarCryptoService` (credential encryption) + * config, D-06/D-07). `InboxModule` is imported so the + * adapter/service can reach the mailbox providers (credential encryption comes + * from the global CryptoModule) * and `ImapProvider`/`ExchangeInboxProvider` (shared connection mechanics, * D-01) — the same imports DkvModule already uses for its own, separate * mailbox config (D-03). Unlike `rss`, the `email-alert` @@ -123,7 +123,7 @@ import { TendersController } from './tenders.controller'; * safe default mailbox to seed (unlike RSS's service.bund.de default). */ @Module({ - imports: [ModuleRegistryModule, SettingsModule, CalendarModule, InboxModule], + imports: [ModuleRegistryModule, SettingsModule, InboxModule], controllers: [TendersController], providers: [ DoeOpenDataAdapter, diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f676436..27457ff 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -44,7 +44,12 @@ services: # Unset used to resolve to an empty value and only fail later, inside the # API, with a stack trace. Fail at compose level with a usable message # instead. Generate with: openssl rand -hex 32 - CALENDAR_ENCRYPTION_KEY: "${CALENDAR_ENCRYPTION_KEY:?set CALENDAR_ENCRYPTION_KEY in .env, generate one with openssl rand -hex 32}" + # + # CALENDAR_ENCRYPTION_KEY is the previous name and is still accepted, so + # an existing .env keeps working; the API logs a deprecation warning when + # it falls back to it. + TESSERA_ENCRYPTION_KEY: "${TESSERA_ENCRYPTION_KEY:-${CALENDAR_ENCRYPTION_KEY:?set TESSERA_ENCRYPTION_KEY in .env, generate one with openssl rand -hex 32}}" + CALENDAR_ENCRYPTION_KEY: "${CALENDAR_ENCRYPTION_KEY:-}" healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"] interval: 10s diff --git a/docker-compose.yml b/docker-compose.yml index 49a7932..0c819d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,7 +51,12 @@ services: # Generate one with: openssl rand -hex 32 # Keep it with your backups but stored separately from the database dump; # losing it means re-entering every stored credential by hand. - CALENDAR_ENCRYPTION_KEY: "${CALENDAR_ENCRYPTION_KEY:?set CALENDAR_ENCRYPTION_KEY in .env, generate one with openssl rand -hex 32}" + # + # CALENDAR_ENCRYPTION_KEY is the previous name and is still accepted, so + # an existing .env keeps working; the API logs a deprecation warning when + # it falls back to it. + TESSERA_ENCRYPTION_KEY: "${TESSERA_ENCRYPTION_KEY:-${CALENDAR_ENCRYPTION_KEY:?set TESSERA_ENCRYPTION_KEY in .env, generate one with openssl rand -hex 32}}" + CALENDAR_ENCRYPTION_KEY: "${CALENDAR_ENCRYPTION_KEY:-}" healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"] interval: 10s