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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,6 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import { CryptoService, ENCRYPTION_KEY_ENV, LEGACY_ENCRYPTION_KEY_ENV } from './crypto.service';
|
||||||
CryptoService,
|
|
||||||
ENCRYPTION_KEY_ENV,
|
|
||||||
LEGACY_ENCRYPTION_KEY_ENV,
|
|
||||||
} from './crypto.service';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Schluessel hiess frueher CALENDAR_ENCRYPTION_KEY, weil das Kalender-Modul
|
* Der Schluessel hiess frueher CALENDAR_ENCRYPTION_KEY, weil das Kalender-Modul
|
||||||
@@ -22,16 +18,12 @@ function makeConfig(values: Record<string, string | undefined>) {
|
|||||||
|
|
||||||
describe('CryptoService — Schluesselherkunft', () => {
|
describe('CryptoService — Schluesselherkunft', () => {
|
||||||
it('nimmt den neuen Namen', () => {
|
it('nimmt den neuen Namen', () => {
|
||||||
const service = new CryptoService(
|
const service = new CryptoService(makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_A }));
|
||||||
makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_A }),
|
|
||||||
);
|
|
||||||
expect(service.decrypt(service.encrypt('geheim'))).toBe('geheim');
|
expect(service.decrypt(service.encrypt('geheim'))).toBe('geheim');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('faellt auf den alten Namen zurueck, damit bestehende Installationen starten', () => {
|
it('faellt auf den alten Namen zurueck, damit bestehende Installationen starten', () => {
|
||||||
const service = new CryptoService(
|
const service = new CryptoService(makeConfig({ [LEGACY_ENCRYPTION_KEY_ENV]: KEY_A }));
|
||||||
makeConfig({ [LEGACY_ENCRYPTION_KEY_ENV]: KEY_A }),
|
|
||||||
);
|
|
||||||
expect(service.decrypt(service.encrypt('geheim'))).toBe('geheim');
|
expect(service.decrypt(service.encrypt('geheim'))).toBe('geheim');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,9 +57,7 @@ describe('CryptoService — Schluesselherkunft', () => {
|
|||||||
[LEGACY_ENCRYPTION_KEY_ENV]: KEY_B,
|
[LEGACY_ENCRYPTION_KEY_ENV]: KEY_B,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const withNewOnly = new CryptoService(
|
const withNewOnly = new CryptoService(makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_A }));
|
||||||
makeConfig({ [ENCRYPTION_KEY_ENV]: KEY_A }),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Was mit dem neuen Schluessel allein verschluesselt wurde, muss die
|
// Was mit dem neuen Schluessel allein verschluesselt wurde, muss die
|
||||||
// Instanz mit beiden Namen lesen koennen.
|
// Instanz mit beiden Namen lesen koennen.
|
||||||
@@ -75,20 +65,17 @@ describe('CryptoService — Schluesselherkunft', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('startet ohne Schluessel gar nicht', () => {
|
it('startet ohne Schluessel gar nicht', () => {
|
||||||
expect(() => new CryptoService(makeConfig({}))).toThrow(
|
expect(() => new CryptoService(makeConfig({}))).toThrow(/TESSERA_ENCRYPTION_KEY is not set/);
|
||||||
/TESSERA_ENCRYPTION_KEY is not set/,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('weist einen Schluessel falscher Laenge ab und nennt den verwendeten Namen', () => {
|
it('weist einen Schluessel falscher Laenge ab und nennt den verwendeten Namen', () => {
|
||||||
expect(
|
expect(() => new CryptoService(makeConfig({ [ENCRYPTION_KEY_ENV]: 'zu-kurz' }))).toThrow(
|
||||||
() => new CryptoService(makeConfig({ [ENCRYPTION_KEY_ENV]: 'zu-kurz' })),
|
/TESSERA_ENCRYPTION_KEY must be a 64-character hex string/,
|
||||||
).toThrow(/TESSERA_ENCRYPTION_KEY must be a 64-character hex string/);
|
);
|
||||||
|
|
||||||
expect(
|
expect(() => new CryptoService(makeConfig({ [LEGACY_ENCRYPTION_KEY_ENV]: 'zu-kurz' }))).toThrow(
|
||||||
() =>
|
/CALENDAR_ENCRYPTION_KEY must be a 64-character hex string/,
|
||||||
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', () => {
|
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();
|
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 — äöüß');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
||||||
|
|
||||||
/** Current name of the platform-wide encryption key. */
|
/** Current name of the platform-wide encryption key. */
|
||||||
export const ENCRYPTION_KEY_ENV = 'TESSERA_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';
|
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.
|
* Platform-wide encryption for stored credentials.
|
||||||
*
|
*
|
||||||
* AES-256-GCM with a 32-byte hex key, values stored as `iv:authTag:ciphertext`
|
* 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
|
* third party and therefore cannot hash: calendar sources, SMTP, the DKV and
|
||||||
* tender mailboxes, and the LDAP bind password.
|
* tender mailboxes, and the LDAP bind password.
|
||||||
*
|
*
|
||||||
@@ -41,9 +49,7 @@ export class CryptoService {
|
|||||||
const hexKey = current || legacy;
|
const hexKey = current || legacy;
|
||||||
|
|
||||||
if (!hexKey) {
|
if (!hexKey) {
|
||||||
throw new Error(
|
throw new Error(`${ENCRYPTION_KEY_ENV} is not set. Generate one with: openssl rand -hex 32`);
|
||||||
`${ENCRYPTION_KEY_ENV} is not set. Generate one with: openssl rand -hex 32`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hexKey.length !== 64) {
|
if (hexKey.length !== 64) {
|
||||||
@@ -70,7 +76,9 @@ export class CryptoService {
|
|||||||
*/
|
*/
|
||||||
encrypt(plaintext: string): string {
|
encrypt(plaintext: string): string {
|
||||||
const iv = randomBytes(12); // 96-bit IV for GCM
|
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');
|
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
||||||
encrypted += cipher.final('hex');
|
encrypted += cipher.final('hex');
|
||||||
@@ -93,8 +101,15 @@ export class CryptoService {
|
|||||||
const [ivHex, authTagHex, ciphertext] = parts;
|
const [ivHex, authTagHex, ciphertext] = parts;
|
||||||
const iv = Buffer.from(ivHex, 'hex');
|
const iv = Buffer.from(ivHex, 'hex');
|
||||||
const authTag = Buffer.from(authTagHex, '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);
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
|
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
|
||||||
|
|||||||
Reference in New Issue
Block a user