This commit is contained in:
2026-08-28 17:31:02 +02:00
commit 2b30e8bd39
694 changed files with 49243 additions and 0 deletions
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { EncryptionService } from './services/EncryptionService';
@Module({
providers: [EncryptionService],
exports: [EncryptionService]
})
export class EncryptionModule {}
@@ -0,0 +1,113 @@
import { ConfigService } from '@nestjs/config';
import { randomBytes } from 'node:crypto';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { EncryptedField } from '../types/EncryptedField';
import { EncryptionService } from './EncryptionService';
describe('EncryptionService', () => {
const validKeyBase64 = randomBytes(32).toString('base64');
let service: EncryptionService;
let configGet: jest.MockedFunction<ConfigService['get']>;
beforeEach(() => {
configGet = jest.fn().mockReturnValue({ keyBase64: validKeyBase64 }) as jest.MockedFunction<
ConfigService['get']
>;
service = new EncryptionService({ get: configGet } as unknown as ConfigService);
});
const parseSerialized = (serialized: string): EncryptedField => JSON.parse(serialized) as EncryptedField;
it('reads encryption config when encrypting plaintext', () => {
service.encryptPlaintext('hello');
expect(configGet).toHaveBeenCalledWith('encryption');
});
it('round-trips plaintext', () => {
const plaintext = 'Deliver via Simplex: example-handle';
const serialized = service.encryptPlaintext(plaintext);
expect(service.decryptPlaintext(serialized)).toBe(plaintext);
});
it('throws when encryption key is not configured', () => {
configGet.mockReturnValue({ keyBase64: '' });
expect(() => service.encryptPlaintext('x')).toThrow('Encryption key is not configured');
});
it('throws when encryption key is not 32 bytes', () => {
configGet.mockReturnValue({ keyBase64: Buffer.from('short').toString('base64') });
expect(() => service.encryptPlaintext('x')).toThrow('Encryption key must be a base64-encoded 32-byte value');
});
it('throws on invalid serialized payload', () => {
expect(() => service.decryptPlaintext('{"ciphertext":"x"}')).toThrow('Invalid encrypted field payload');
});
it('fails decrypt when ciphertext is tampered', () => {
const serialized = service.encryptPlaintext('secret');
const encrypted = parseSerialized(serialized);
encrypted.ciphertext = Buffer.from('tampered').toString('base64');
expect(() => service.decryptPlaintext(JSON.stringify(encrypted))).toThrow();
});
it('throws when encryption key is not configured on decrypt', () => {
const serialized = service.encryptPlaintext('secret');
configGet.mockReturnValue({ keyBase64: '' });
expect(() => service.decryptPlaintext(serialized)).toThrow('Encryption key is not configured');
});
it('fails decrypt when auth tag is tampered', () => {
const serialized = service.encryptPlaintext('secret');
const encrypted = parseSerialized(serialized);
encrypted.tag = Buffer.from('tampered-tag').toString('base64');
expect(() => service.decryptPlaintext(JSON.stringify(encrypted))).toThrow();
});
it('fails decrypt when encryption key changes', () => {
const serialized = service.encryptPlaintext('secret');
configGet.mockReturnValue({ keyBase64: randomBytes(32).toString('base64') });
expect(() => service.decryptPlaintext(serialized)).toThrow();
});
it('decryptPlaintextFieldInPlace decrypts each item field', () => {
const messages = [{ body: service.encryptPlaintext('Hello') }, { body: service.encryptPlaintext('World') }];
service.decryptPlaintextFieldInPlace(messages, 'body');
expect(messages).toEqual([{ body: 'Hello' }, { body: 'World' }]);
});
it('decryptPlaintextFieldInPlace handles undefined items', () => {
expect(() => service.decryptPlaintextFieldInPlace(undefined, 'body')).not.toThrow();
});
it('round-trips files written as encrypted buffer', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'encryption-service-'));
const filePath = join(tempDir, 'attachment.bin');
const plaintext = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d, 0x00, 0xff]);
try {
await service.writeEncryptedBufferToPath(plaintext, filePath);
const onDisk = await readFile(filePath, 'utf8');
expect(onDisk).not.toEqual(plaintext.toString());
expect(await service.decryptFileAtPath(filePath)).toEqual(plaintext);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}, 15_000);
});
@@ -0,0 +1,137 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { Config } from '../../../types/Config';
import { EncryptedField } from '../types/EncryptedField';
@Injectable()
export class EncryptionService {
private readonly algorithm = 'aes-256-gcm';
private readonly ivLengthBytes = 12;
private readonly encryptionKeyByteLength = 32;
constructor(private readonly configService: ConfigService) {}
encryptPlaintext(plaintext: string): string {
const encrypted = this.encryptBuffer(Buffer.from(plaintext, 'utf8'));
return this.serialize(encrypted);
}
decryptPlaintext(serialized: string): string {
const encrypted = this.deserialize(serialized);
return this.decryptBuffer(encrypted).toString('utf8');
}
decryptPlaintextFieldInPlace<T>(items: T[] | undefined, field: keyof T): void {
this.decryptPlaintextInPlace(
items,
item => item[field] as string,
(item, plaintext) => {
(item as Record<keyof T, unknown>)[field] = plaintext;
}
);
}
async writeEncryptedBufferToPath(plaintext: Buffer, absolutePath: string): Promise<void> {
const serialized = this.encryptBufferToSerialized(plaintext);
await writeFile(absolutePath, serialized, 'utf8');
}
async decryptFileAtPath(absolutePath: string): Promise<Buffer> {
const serialized = await readFile(absolutePath, 'utf8');
return this.decryptBufferToSerialized(serialized);
}
private decryptPlaintextInPlace<T>(
items: T[] | undefined,
read: (item: T) => string,
write: (item: T, plaintext: string) => void
): void {
if (!items) {
return;
}
for (const item of items) {
write(item, this.decryptPlaintext(read(item)));
}
}
private encryptBufferToSerialized(plaintext: Buffer): string {
const encrypted = this.encryptBuffer(plaintext);
return this.serialize(encrypted);
}
private decryptBufferToSerialized(serialized: string): Buffer {
const encrypted = this.deserialize(serialized);
return this.decryptBuffer(encrypted);
}
private encryptBuffer(plaintext: Buffer): EncryptedField {
const key = this.parseKey();
const iv = randomBytes(this.ivLengthBytes);
const cipher = createCipheriv(this.algorithm, key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
return {
ciphertext: ciphertext.toString('base64'),
iv: iv.toString('base64'),
tag: tag.toString('base64')
};
}
private decryptBuffer(payload: EncryptedField): Buffer {
const key = this.parseKey();
const iv = Buffer.from(payload.iv, 'base64');
const tag = Buffer.from(payload.tag, 'base64');
const ciphertext = Buffer.from(payload.ciphertext, 'base64');
const decipher = createDecipheriv(this.algorithm, key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
private serialize(payload: EncryptedField): string {
return JSON.stringify(payload);
}
private deserialize(serialized: string): EncryptedField {
const parsed: unknown = JSON.parse(serialized);
if (
typeof parsed !== 'object' ||
parsed === null ||
typeof (parsed as EncryptedField).ciphertext !== 'string' ||
typeof (parsed as EncryptedField).iv !== 'string' ||
typeof (parsed as EncryptedField).tag !== 'string'
) {
throw new Error('Invalid encrypted field payload');
}
return parsed as EncryptedField;
}
private parseKey(): Buffer {
const { keyBase64 } = this.configService.get('encryption') as Config['encryption'];
if (!keyBase64) {
throw new Error('Encryption key is not configured');
}
const key = Buffer.from(keyBase64, 'base64');
if (key.length !== this.encryptionKeyByteLength) {
throw new Error(`Encryption key must be a base64-encoded ${this.encryptionKeyByteLength}-byte value`);
}
return key;
}
}
@@ -0,0 +1,5 @@
export interface EncryptedField {
ciphertext: string;
iv: string;
tag: string;
}