refactor: rename the encryption key to what it actually protects
CALENDAR_ENCRYPTION_KEY was named after the calendar module because that module needed encryption first, in Phase 5. Every feature since has shared the same key -- SMTP, the DKV and tender mailboxes, and as of today the LDAP bind password -- so the name has been describing one of five users rather than the thing itself, and each new feature inherited the confusion. TESSERA_ENCRYPTION_KEY is the name now. The old one is still read, because renaming outright would stop every existing installation at the next start: their .env carries the old name, and compose was just made to fail hard on a missing key. When only the old name is present the API logs a deprecation warning naming both, and when both are set the new one wins -- otherwise a half-migrated .env would encrypt with one key and decrypt with the other. CalendarCryptoService becomes CryptoService in its own global CryptoModule. Four modules used to import CalendarModule purely to reach the provider, which read as a dependency on calendars where there was none; that import is gone. Compose keeps the hard failure: without either name the stack refuses to start. Verified in both files for all three cases -- neither name set (abort), only the old name (starts), only the new name (starts). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {}
|
||||
@@ -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<string, string | undefined>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user