init
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user