mirror of
https://github.com/docmost/docmost.git
synced 2026-08-24 06:22:18 +10:00
feat(server): add global encryption module (AES-256-GCM) (#2400)
Provides an injectable EncryptionService that encrypts/decrypts strings with AES-256-GCM using a key derived from APP_SECRET with domain separation.
This commit is contained in:
@@ -27,6 +27,7 @@ import { LoggerModule } from './common/logger/logger.module';
|
|||||||
import { ClsModule } from 'nestjs-cls';
|
import { ClsModule } from 'nestjs-cls';
|
||||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||||
|
import { EncryptionModule } from './integrations/encryption/encryption.module';
|
||||||
|
|
||||||
const enterpriseModules = [];
|
const enterpriseModules = [];
|
||||||
try {
|
try {
|
||||||
@@ -53,6 +54,7 @@ try {
|
|||||||
CoreModule,
|
CoreModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
|
EncryptionModule,
|
||||||
RedisModule.forRootAsync({
|
RedisModule.forRootAsync({
|
||||||
useClass: RedisConfigService,
|
useClass: RedisConfigService,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { EncryptionService } from './encryption.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [EncryptionService],
|
||||||
|
exports: [EncryptionService],
|
||||||
|
})
|
||||||
|
export class EncryptionModule {}
|
||||||
@@ -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>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<TFormat = string | Buffer> = {
|
||||||
|
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<string> = {
|
||||||
|
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<Buffer> {
|
||||||
|
const payload = Buffer.from(encodedPayload, 'base64');
|
||||||
|
|
||||||
|
let deserializedPkg: Record<string, unknown>;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user