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:
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>('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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user