From 1982a0ed1e1733a696ae9daa0a56de2691b58f0b Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 16 May 2026 23:40:43 +0100 Subject: [PATCH] feat: encryption module --- apps/server/src/app.module.ts | 2 + .../encryption/encryption.errors.ts | 13 ++ .../encryption/encryption.module.ts | 9 + .../encryption/encryption.service.spec.ts | 184 ++++++++++++++++++ .../encryption/encryption.service.ts | 108 ++++++++++ 5 files changed, 316 insertions(+) create mode 100644 apps/server/src/integrations/encryption/encryption.errors.ts create mode 100644 apps/server/src/integrations/encryption/encryption.module.ts create mode 100644 apps/server/src/integrations/encryption/encryption.service.spec.ts create mode 100644 apps/server/src/integrations/encryption/encryption.service.ts diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index b8cfc5877..4f5411eca 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -27,6 +27,7 @@ import { LoggerModule } from './common/logger/logger.module'; import { ClsModule } from 'nestjs-cls'; import { NoopAuditModule } from './integrations/audit/audit.module'; import { ThrottleModule } from './integrations/throttle/throttle.module'; +import { EncryptionModule } from './integrations/encryption/encryption.module'; const enterpriseModules = []; try { @@ -53,6 +54,7 @@ try { CoreModule, DatabaseModule, EnvironmentModule, + EncryptionModule, RedisModule.forRootAsync({ useClass: RedisConfigService, }), diff --git a/apps/server/src/integrations/encryption/encryption.errors.ts b/apps/server/src/integrations/encryption/encryption.errors.ts new file mode 100644 index 000000000..06b5cca81 --- /dev/null +++ b/apps/server/src/integrations/encryption/encryption.errors.ts @@ -0,0 +1,13 @@ +export class UnableToInitialize extends Error { + constructor(message: string) { + super(`Unable to initialize the encryption service: ${message}`); + this.name = 'UnableToInitialize'; + } +} + +export class UnableToDecrypt extends Error { + constructor(reason: string) { + super(`Unable to decrypt the ciphertext: ${reason}`); + this.name = 'UnableToDecrypt'; + } +} diff --git a/apps/server/src/integrations/encryption/encryption.module.ts b/apps/server/src/integrations/encryption/encryption.module.ts new file mode 100644 index 000000000..75022234e --- /dev/null +++ b/apps/server/src/integrations/encryption/encryption.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { EncryptionService } from './encryption.service'; + +@Global() +@Module({ + providers: [EncryptionService], + exports: [EncryptionService], +}) +export class EncryptionModule {} diff --git a/apps/server/src/integrations/encryption/encryption.service.spec.ts b/apps/server/src/integrations/encryption/encryption.service.spec.ts new file mode 100644 index 000000000..64b1a8e00 --- /dev/null +++ b/apps/server/src/integrations/encryption/encryption.service.spec.ts @@ -0,0 +1,184 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EncryptionService } from './encryption.service'; +import { UnableToDecrypt, UnableToInitialize } from './encryption.errors'; +import { EnvironmentService } from '../environment/environment.service'; + +const APP_SECRET = 'test-app-secret-with-plenty-of-entropy-1234567890'; + +const buildService = (appSecret: string | undefined) => { + const env = { getAppSecret: () => appSecret } as EnvironmentService; + return new EncryptionService(env); +}; + +const decodeEnvelope = (encrypted: string) => + JSON.parse(Buffer.from(encrypted, 'base64').toString()) as { + iv: string; + authTag: string; + cipherText: string; + }; + +const encodeEnvelope = (envelope: { + iv: string; + authTag: string; + cipherText: string; +}) => Buffer.from(JSON.stringify(envelope)).toString('base64'); + +describe('EncryptionService', () => { + let service: EncryptionService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EncryptionService, + { + provide: EnvironmentService, + useValue: { getAppSecret: () => APP_SECRET }, + }, + ], + }).compile(); + + service = module.get(EncryptionService); + }); + + describe('initialization', () => { + it('compiles via Nest DI', () => { + expect(service).toBeDefined(); + }); + + it('throws UnableToInitialize when APP_SECRET is missing', () => { + expect(() => buildService(undefined)).toThrow(UnableToInitialize); + expect(() => buildService('')).toThrow(UnableToInitialize); + }); + }); + + describe('encrypt + decrypt round-trip', () => { + it('decrypts back to the original plaintext', () => { + const plaintext = 'hello world'; + const encrypted = service.encrypt(plaintext); + expect(service.decrypt(encrypted)).toBe(plaintext); + }); + + it('handles empty string', () => { + const encrypted = service.encrypt(''); + expect(service.decrypt(encrypted)).toBe(''); + }); + + it('handles unicode (multi-byte UTF-8)', () => { + const plaintext = 'héllo 🔐 世界'; + const encrypted = service.encrypt(plaintext); + expect(service.decrypt(encrypted)).toBe(plaintext); + }); + + it('handles long plaintext (>1 block)', () => { + const plaintext = 'a'.repeat(10_000); + const encrypted = service.encrypt(plaintext); + expect(service.decrypt(encrypted)).toBe(plaintext); + }); + + it('produces distinct ciphertexts for the same plaintext (random IV)', () => { + const plaintext = 'same input'; + const a = service.encrypt(plaintext); + const b = service.encrypt(plaintext); + expect(a).not.toBe(b); + expect(service.decrypt(a)).toBe(plaintext); + expect(service.decrypt(b)).toBe(plaintext); + }); + }); + + describe('cross-key isolation', () => { + it('cannot decrypt ciphertext produced under a different APP_SECRET', () => { + const other = buildService('totally-different-secret-value-9876543210'); + const encrypted = service.encrypt('secret'); + expect(() => other.decrypt(encrypted)).toThrow(UnableToDecrypt); + }); + }); + + describe('tamper detection', () => { + it('rejects modified ciphertext', () => { + const encrypted = service.encrypt('hello'); + const env = decodeEnvelope(encrypted); + const tamperedCipher = Buffer.from(env.cipherText, 'base64'); + tamperedCipher[0] ^= 0x01; + const tampered = encodeEnvelope({ + ...env, + cipherText: tamperedCipher.toString('base64'), + }); + expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt); + }); + + it('rejects modified auth tag', () => { + const encrypted = service.encrypt('hello'); + const env = decodeEnvelope(encrypted); + const tamperedTag = Buffer.from(env.authTag, 'base64'); + tamperedTag[0] ^= 0x01; + const tampered = encodeEnvelope({ + ...env, + authTag: tamperedTag.toString('base64'), + }); + expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt); + }); + + it('rejects modified IV', () => { + const encrypted = service.encrypt('hello'); + const env = decodeEnvelope(encrypted); + const tamperedIV = Buffer.from(env.iv, 'base64'); + tamperedIV[0] ^= 0x01; + const tampered = encodeEnvelope({ + ...env, + iv: tamperedIV.toString('base64'), + }); + expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt); + }); + }); + + describe('malformed payloads', () => { + it('rejects non-base64 garbage', () => { + expect(() => service.decrypt('!!!not-valid-base64!!!')).toThrow( + UnableToDecrypt, + ); + }); + + it('rejects base64 of non-JSON', () => { + const garbage = Buffer.from('not json at all').toString('base64'); + expect(() => service.decrypt(garbage)).toThrow(UnableToDecrypt); + }); + + it('rejects JSON missing required fields', () => { + const partial = encodeEnvelope({ + iv: Buffer.alloc(12).toString('base64'), + authTag: Buffer.alloc(16).toString('base64'), + } as never); + expect(() => service.decrypt(partial)).toThrow(UnableToDecrypt); + }); + + it('rejects wrong-length IV', () => { + const encrypted = service.encrypt('hello'); + const env = decodeEnvelope(encrypted); + const bad = encodeEnvelope({ + ...env, + iv: Buffer.alloc(8).toString('base64'), + }); + expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt); + }); + + it('rejects wrong-length auth tag', () => { + const encrypted = service.encrypt('hello'); + const env = decodeEnvelope(encrypted); + const bad = encodeEnvelope({ + ...env, + authTag: Buffer.alloc(8).toString('base64'), + }); + expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt); + }); + }); + + describe('envelope format', () => { + it('returns base64 of JSON envelope with iv (12B), authTag (16B), cipherText', () => { + const encrypted = service.encrypt('hello'); + const env = decodeEnvelope(encrypted); + expect(Buffer.from(env.iv, 'base64')).toHaveLength(12); + expect(Buffer.from(env.authTag, 'base64')).toHaveLength(16); + expect(Buffer.from(env.cipherText, 'base64').length).toBeGreaterThan(0); + }); + }); +}); diff --git a/apps/server/src/integrations/encryption/encryption.service.ts b/apps/server/src/integrations/encryption/encryption.service.ts new file mode 100644 index 000000000..63dc4ce99 --- /dev/null +++ b/apps/server/src/integrations/encryption/encryption.service.ts @@ -0,0 +1,108 @@ +// https://github.com/nhedger/nestjs-encryption - MIT +import { Injectable } from '@nestjs/common'; +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from 'node:crypto'; +import { UnableToDecrypt, UnableToInitialize } from './encryption.errors'; +import { EnvironmentService } from '../environment/environment.service'; + +const ALGORITHM = 'aes-256-gcm'; +const KEY_DOMAIN = 'docmost:encryption:v1'; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; + +type AEADPayload = { + iv: TFormat; + authTag: TFormat; + cipherText: TFormat; +}; + +@Injectable() +export class EncryptionService { + private readonly key: Buffer; + + constructor(environmentService: EnvironmentService) { + const appSecret = environmentService.getAppSecret(); + if (!appSecret) { + throw new UnableToInitialize('APP_SECRET is not set.'); + } + this.key = createHash('sha256') + .update(KEY_DOMAIN) + .update(appSecret) + .digest(); + } + + public encrypt(plaintext: string): string { + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, this.key, iv); + const cipherText = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + const aead: AEADPayload = { + iv: iv.toString('base64'), + authTag: authTag.toString('base64'), + cipherText: cipherText.toString('base64'), + }; + + return Buffer.from(JSON.stringify(aead)).toString('base64'); + } + + public decrypt(encrypted: string): string { + try { + const { iv, authTag, cipherText } = this.decodeAEADPayload(encrypted); + const decipher = createDecipheriv(ALGORITHM, this.key, iv); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([ + decipher.update(cipherText), + decipher.final(), + ]); + return decrypted.toString('utf8'); + } catch (e: unknown) { + throw new UnableToDecrypt((e as Error).message); + } + } + + private decodeAEADPayload(encodedPayload: string): AEADPayload { + const payload = Buffer.from(encodedPayload, 'base64'); + + let deserializedPkg: Record; + try { + deserializedPkg = JSON.parse(payload.toString()); + } catch { + throw new Error('The decoded AEAD payload is not a valid JSON string.'); + } + + for (const field of ['iv', 'authTag', 'cipherText']) { + if (!Object.prototype.hasOwnProperty.call(deserializedPkg, field)) { + throw new Error(`The AEAD payload is missing the ${field} field.`); + } + } + + const iv = Buffer.from(deserializedPkg.iv as string, 'base64'); + if (iv.length !== IV_LENGTH) { + throw new Error( + `The decoded IV is not the correct length. Expected ${IV_LENGTH} bytes, got ${iv.length} bytes.`, + ); + } + + const authTag = Buffer.from(deserializedPkg.authTag as string, 'base64'); + if (authTag.length !== AUTH_TAG_LENGTH) { + throw new Error( + `The decoded auth tag is not the correct length. Expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length} bytes.`, + ); + } + + const cipherText = Buffer.from( + deserializedPkg.cipherText as string, + 'base64', + ); + + return { iv, authTag, cipherText }; + } +}