From 072f9551dcb704a593b79854ab986094956d59c3 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 9 Oct 2026 20:13:10 +0200 Subject: [PATCH] fix(quick-261009-p0m): AES-GCM-Entschluesselung verlangt 16-Byte-Tag - decrypt lehnt jeden Echtheitsmarker ab, der nicht genau 16 Byte lang ist - authTagLength 16 auch beim Verschluesseln (Ausgabe unveraendert) - Tests: 4 Byte, 17 Byte, leer, gekipptes Bit; Rundlauf mit Umlauten Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/api/src/crypto/crypto.service.spec.ts | 81 +++++++++++++++------- apps/api/src/crypto/crypto.service.ts | 29 ++++++-- 2 files changed, 79 insertions(+), 31 deletions(-) diff --git a/apps/api/src/crypto/crypto.service.spec.ts b/apps/api/src/crypto/crypto.service.spec.ts index dab6a95..08e19cb 100644 --- a/apps/api/src/crypto/crypto.service.spec.ts +++ b/apps/api/src/crypto/crypto.service.spec.ts @@ -1,10 +1,6 @@ import { Logger } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; -import { - CryptoService, - ENCRYPTION_KEY_ENV, - LEGACY_ENCRYPTION_KEY_ENV, -} from './crypto.service'; +import { CryptoService, ENCRYPTION_KEY_ENV, LEGACY_ENCRYPTION_KEY_ENV } from './crypto.service'; /** * Der Schluessel hiess frueher CALENDAR_ENCRYPTION_KEY, weil das Kalender-Modul @@ -22,16 +18,12 @@ function makeConfig(values: Record) { describe('CryptoService — Schluesselherkunft', () => { it('nimmt den neuen Namen', () => { - const service = new CryptoService( - makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_A }), - ); + 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 }), - ); + const service = new CryptoService(makeConfig({ [LEGACY_ENCRYPTION_KEY_ENV]: KEY_A })); expect(service.decrypt(service.encrypt('geheim'))).toBe('geheim'); }); @@ -65,9 +57,7 @@ describe('CryptoService — Schluesselherkunft', () => { [LEGACY_ENCRYPTION_KEY_ENV]: KEY_B, }), ); - const withNewOnly = new CryptoService( - makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_A }), - ); + 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. @@ -75,20 +65,17 @@ describe('CryptoService — Schluesselherkunft', () => { }); it('startet ohne Schluessel gar nicht', () => { - expect(() => new CryptoService(makeConfig({}))).toThrow( - /TESSERA_ENCRYPTION_KEY is not set/, - ); + 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({ [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/); + 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', () => { @@ -97,3 +84,49 @@ describe('CryptoService — Schluesselherkunft', () => { expect(() => b.decrypt(a.encrypt('geheim'))).toThrow(); }); }); + +/** + * quick-261009-p0m: GCM mit verkuerztem Echtheitsmarker ist faelschbar. Node + * akzeptierte bisher jede Markerlaenge von 4 bis 16 Byte (mit einer + * Veraltungswarnung). Gespeichert wurden immer 16 Byte, also verlangt + * decrypt() genau 16. + */ +describe('CryptoService — Laenge der Echtheitspruefung (AES-GCM-Tag)', () => { + const service = new CryptoService(makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_A })); + + function withTag(stored: string, newTagHex: string): string { + const [iv, , ciphertext] = stored.split(':'); + return `${iv}:${newTagHex}:${ciphertext}`; + } + + it('lehnt einen auf 4 Byte gekuerzten Marker ab', () => { + const stored = service.encrypt('geheim'); + const tag = stored.split(':')[1]; + expect(tag).toHaveLength(32); + expect(() => service.decrypt(withTag(stored, tag.slice(0, 8)))).toThrow(/16 bytes/); + }); + + it('lehnt einen Marker von 17 Byte ab', () => { + const stored = service.encrypt('geheim'); + const tag = stored.split(':')[1]; + expect(() => service.decrypt(withTag(stored, `${tag}00`))).toThrow(/16 bytes/); + }); + + it('lehnt einen 16-Byte-Marker mit einem gekippten Bit ab', () => { + const stored = service.encrypt('geheim'); + const tag = Buffer.from(stored.split(':')[1], 'hex'); + tag[0] ^= 0x01; + expect(() => service.decrypt(withTag(stored, tag.toString('hex')))).toThrow(); + }); + + it('lehnt einen leeren Marker ab', () => { + const stored = service.encrypt('geheim'); + expect(() => service.decrypt(withTag(stored, ''))).toThrow(/16 bytes/); + }); + + it('ver- und entschluesselt weiter, auch leeren Text und Umlaute', () => { + expect(service.decrypt(service.encrypt('geheim'))).toBe('geheim'); + expect(service.decrypt(service.encrypt(''))).toBe(''); + expect(service.decrypt(service.encrypt('Grüße aus Köln — äöüß'))).toBe('Grüße aus Köln — äöüß'); + }); +}); diff --git a/apps/api/src/crypto/crypto.service.ts b/apps/api/src/crypto/crypto.service.ts index 96d1fce..2b9ed12 100644 --- a/apps/api/src/crypto/crypto.service.ts +++ b/apps/api/src/crypto/crypto.service.ts @@ -1,6 +1,6 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; /** Current name of the platform-wide encryption key. */ export const ENCRYPTION_KEY_ENV = 'TESSERA_ENCRYPTION_KEY'; @@ -15,11 +15,19 @@ export const ENCRYPTION_KEY_ENV = 'TESSERA_ENCRYPTION_KEY'; */ export const LEGACY_ENCRYPTION_KEY_ENV = 'CALENDAR_ENCRYPTION_KEY'; +/** + * Length of the GCM authentication tag in bytes. Every value ever written used + * 16 (Node's default); decrypt() insists on exactly this length, because GCM + * with a truncated tag is forgeable (quick-261009-p0m). + */ +const AUTH_TAG_BYTES = 16; + /** * 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 + * (hex-joined; the tag is exactly 16 bytes and decrypt() rejects any other + * length). 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. * @@ -41,9 +49,7 @@ export class CryptoService { const hexKey = current || legacy; if (!hexKey) { - throw new Error( - `${ENCRYPTION_KEY_ENV} is not set. Generate one with: openssl rand -hex 32`, - ); + throw new Error(`${ENCRYPTION_KEY_ENV} is not set. Generate one with: openssl rand -hex 32`); } if (hexKey.length !== 64) { @@ -70,7 +76,9 @@ export class CryptoService { */ encrypt(plaintext: string): string { const iv = randomBytes(12); // 96-bit IV for GCM - const cipher = createCipheriv('aes-256-gcm', this.key, iv); + const cipher = createCipheriv('aes-256-gcm', this.key, iv, { + authTagLength: AUTH_TAG_BYTES, + }); let encrypted = cipher.update(plaintext, 'utf8', 'hex'); encrypted += cipher.final('hex'); @@ -93,8 +101,15 @@ export class CryptoService { const [ivHex, authTagHex, ciphertext] = parts; const iv = Buffer.from(ivHex, 'hex'); const authTag = Buffer.from(authTagHex, 'hex'); + if (authTag.length !== AUTH_TAG_BYTES) { + throw new Error( + `Invalid encrypted value: authentication tag must be ${AUTH_TAG_BYTES} bytes`, + ); + } - const decipher = createDecipheriv('aes-256-gcm', this.key, iv); + const decipher = createDecipheriv('aes-256-gcm', this.key, iv, { + authTagLength: AUTH_TAG_BYTES, + }); decipher.setAuthTag(authTag); let decrypted = decipher.update(ciphertext, 'hex', 'utf8');