feat: add MFA/TOTP auth, PII encryption, agents/leads/inquiries modules, and comprehensive tests
- Add TOTP-based MFA with setup, verify, disable, backup codes, and challenge flow - Add PII field encryption middleware with AES-256-GCM and deterministic search hashes - Add agents, inquiries, and leads domain modules with entities, events, value objects - Add web dashboard pages for inquiries and leads with detail dialogs - Add 30+ component tests (valuation, charts, listings, search, providers, UI) - Add Prisma migrations for encryption hash columns and MFA TOTP support - Fix all ESLint errors (unused imports, duplicate imports, lint auto-fixes) - Update dependencies and lock file - Clean up obsolete exploration/QA docs, add audit documentation Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createEncryptionExtension } from '../encryption-middleware';
|
||||
import { isEncrypted } from '../field-encryption';
|
||||
import { FieldEncryptionService } from '../field-encryption.service';
|
||||
|
||||
const TEST_KEY = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const mockLogger = {
|
||||
debug: vi.fn(),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
verbose: vi.fn(),
|
||||
child: vi.fn(),
|
||||
} as any;
|
||||
|
||||
describe('encryption-middleware', () => {
|
||||
let service: FieldEncryptionService;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['FIELD_ENCRYPTION_KEY'] = TEST_KEY;
|
||||
process.env['FIELD_ENCRYPTION_KEY_VERSION'] = '1';
|
||||
service = new FieldEncryptionService(mockLogger);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['FIELD_ENCRYPTION_KEY'];
|
||||
delete process.env['FIELD_ENCRYPTION_KEY_VERSION'];
|
||||
});
|
||||
|
||||
describe('createEncryptionExtension', () => {
|
||||
it('returns a Prisma extension object', () => {
|
||||
const ext = createEncryptionExtension(service);
|
||||
expect(ext).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns a no-op extension when encryption is disabled', () => {
|
||||
delete process.env['FIELD_ENCRYPTION_KEY'];
|
||||
const disabledService = new FieldEncryptionService(mockLogger);
|
||||
const ext = createEncryptionExtension(disabledService);
|
||||
expect(ext).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FieldEncryptionService integration with extension', () => {
|
||||
// Since we can't easily invoke the Prisma $extends handler in isolation
|
||||
// (it requires a real PrismaClient), we test the encrypt/decrypt behavior
|
||||
// that the extension uses via the service.
|
||||
|
||||
it('encrypt + decrypt round-trip for User.email', () => {
|
||||
const original = 'user@example.com';
|
||||
const encrypted = service.encrypt(original) as string;
|
||||
expect(isEncrypted(encrypted)).toBe(true);
|
||||
expect(service.decrypt(encrypted)).toBe(original);
|
||||
});
|
||||
|
||||
it('encrypt + decrypt round-trip for User.kycData', () => {
|
||||
const original = { idNumber: '012345678901', name: 'Nguyen Van A' };
|
||||
const encrypted = service.encrypt(original) as string;
|
||||
expect(isEncrypted(encrypted)).toBe(true);
|
||||
expect(service.decrypt(encrypted)).toEqual(original);
|
||||
});
|
||||
|
||||
it('encrypt + decrypt round-trip for Payment.callbackData', () => {
|
||||
const original = { vnp_ResponseCode: '00', vnp_Amount: 1000000 };
|
||||
const encrypted = service.encrypt(original) as string;
|
||||
expect(isEncrypted(encrypted)).toBe(true);
|
||||
expect(service.decrypt(encrypted)).toEqual(original);
|
||||
});
|
||||
|
||||
it('encrypt + decrypt round-trip for simple strings (phone, providerTxId)', () => {
|
||||
const phone = '+84912345678';
|
||||
const txId = 'txn_12345';
|
||||
expect(service.decrypt(service.encrypt(phone) as string)).toBe(phone);
|
||||
expect(service.decrypt(service.encrypt(txId) as string)).toBe(txId);
|
||||
});
|
||||
|
||||
it('computes deterministic hashes for searchable fields', () => {
|
||||
const hash1 = service.computeHash('user@example.com');
|
||||
const hash2 = service.computeHash('user@example.com');
|
||||
expect(hash1).toBe(hash2);
|
||||
expect(hash1).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('hash is case-insensitive', () => {
|
||||
expect(service.computeHash('User@Example.COM')).toBe(
|
||||
service.computeHash('user@example.com'),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not double-encrypt already encrypted values', () => {
|
||||
const encrypted = service.encrypt('test') as string;
|
||||
expect(service.isAlreadyEncrypted(encrypted)).toBe(true);
|
||||
// If the middleware encounters this, it should skip
|
||||
});
|
||||
|
||||
it('decrypts plaintext (non-encrypted) values unchanged', () => {
|
||||
expect(service.decrypt('plain text')).toBe('plain text');
|
||||
expect(service.decrypt(null)).toBeNull();
|
||||
expect(service.decrypt(42)).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PII field map coverage', () => {
|
||||
it('covers User model with email, phone, kycData', () => {
|
||||
const userConfig = service.getModelConfig('User');
|
||||
expect(userConfig).toBeDefined();
|
||||
const fields = userConfig!.fields.map((f) => f.field);
|
||||
expect(fields).toContain('email');
|
||||
expect(fields).toContain('phone');
|
||||
expect(fields).toContain('kycData');
|
||||
});
|
||||
|
||||
it('covers Agent model with licenseNumber', () => {
|
||||
const agentConfig = service.getModelConfig('Agent');
|
||||
expect(agentConfig).toBeDefined();
|
||||
expect(agentConfig!.fields.map((f) => f.field)).toContain('licenseNumber');
|
||||
});
|
||||
|
||||
it('covers Payment model with providerTxId, callbackData', () => {
|
||||
const paymentConfig = service.getModelConfig('Payment');
|
||||
expect(paymentConfig).toBeDefined();
|
||||
const fields = paymentConfig!.fields.map((f) => f.field);
|
||||
expect(fields).toContain('providerTxId');
|
||||
expect(fields).toContain('callbackData');
|
||||
});
|
||||
|
||||
it('covers Lead model with phone, email', () => {
|
||||
const leadConfig = service.getModelConfig('Lead');
|
||||
expect(leadConfig).toBeDefined();
|
||||
const fields = leadConfig!.fields.map((f) => f.field);
|
||||
expect(fields).toContain('phone');
|
||||
expect(fields).toContain('email');
|
||||
});
|
||||
|
||||
it('covers Inquiry model with phone', () => {
|
||||
const inquiryConfig = service.getModelConfig('Inquiry');
|
||||
expect(inquiryConfig).toBeDefined();
|
||||
expect(inquiryConfig!.fields.map((f) => f.field)).toContain('phone');
|
||||
});
|
||||
|
||||
it('marks User.email and User.phone as searchable', () => {
|
||||
const userConfig = service.getModelConfig('User')!;
|
||||
expect(userConfig.fields.find((f) => f.field === 'email')?.searchable).toBe(true);
|
||||
expect(userConfig.fields.find((f) => f.field === 'phone')?.searchable).toBe(true);
|
||||
expect(userConfig.fields.find((f) => f.field === 'kycData')?.searchable).toBeFalsy();
|
||||
});
|
||||
|
||||
it('marks Lead.email and Lead.phone as searchable', () => {
|
||||
const leadConfig = service.getModelConfig('Lead')!;
|
||||
expect(leadConfig.fields.find((f) => f.field === 'email')?.searchable).toBe(true);
|
||||
expect(leadConfig.fields.find((f) => f.field === 'phone')?.searchable).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -141,7 +141,7 @@ describe('validateEnv', () => {
|
||||
process.env['DATABASE_URL'] = 'postgresql://localhost/goodgo';
|
||||
process.env['CORS_ORIGINS'] = 'https://goodgo.vn';
|
||||
process.env['REDIS_HOST'] = 'redis.internal';
|
||||
process.env['KYC_ENCRYPTION_KEY'] = 'a'.repeat(64); // 32 bytes hex
|
||||
process.env['FIELD_ENCRYPTION_KEY'] = 'a'.repeat(64); // 32 bytes hex
|
||||
|
||||
expect(() => validateEnv()).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { isEncrypted } from '../field-encryption';
|
||||
import { FieldEncryptionService, PII_FIELD_MAP } from '../field-encryption.service';
|
||||
|
||||
// Mock LoggerService
|
||||
const mockLogger = {
|
||||
debug: vi.fn(),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
verbose: vi.fn(),
|
||||
child: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const TEST_KEY = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
describe('FieldEncryptionService', () => {
|
||||
describe('when encryption key is configured', () => {
|
||||
let service: FieldEncryptionService;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['FIELD_ENCRYPTION_KEY'] = TEST_KEY;
|
||||
process.env['FIELD_ENCRYPTION_KEY_VERSION'] = '1';
|
||||
service = new FieldEncryptionService(mockLogger);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['FIELD_ENCRYPTION_KEY'];
|
||||
delete process.env['FIELD_ENCRYPTION_KEY_VERSION'];
|
||||
delete process.env['KYC_ENCRYPTION_KEY'];
|
||||
delete process.env['KYC_ENCRYPTION_KEY_VERSION'];
|
||||
});
|
||||
|
||||
it('should be enabled', () => {
|
||||
expect(service.isEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
describe('encrypt/decrypt round-trip', () => {
|
||||
it('encrypts and decrypts a string', () => {
|
||||
const original = 'test@example.com';
|
||||
const encrypted = service.encrypt(original) as string;
|
||||
expect(isEncrypted(encrypted)).toBe(true);
|
||||
|
||||
const decrypted = service.decrypt(encrypted);
|
||||
expect(decrypted).toBe(original);
|
||||
});
|
||||
|
||||
it('encrypts and decrypts an object', () => {
|
||||
const original = { name: 'Nguyen Van A', idNumber: '012345678901' };
|
||||
const encrypted = service.encrypt(original) as string;
|
||||
expect(isEncrypted(encrypted)).toBe(true);
|
||||
|
||||
const decrypted = service.decrypt(encrypted);
|
||||
expect(decrypted).toEqual(original);
|
||||
});
|
||||
|
||||
it('returns null/undefined unchanged', () => {
|
||||
expect(service.encrypt(null)).toBeNull();
|
||||
expect(service.encrypt(undefined)).toBeUndefined();
|
||||
expect(service.decrypt(null)).toBeNull();
|
||||
expect(service.decrypt(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('produces different ciphertext for same input (random IV)', () => {
|
||||
const value = 'same value';
|
||||
const enc1 = service.encrypt(value);
|
||||
const enc2 = service.encrypt(value);
|
||||
expect(enc1).not.toBe(enc2);
|
||||
expect(service.decrypt(enc1)).toBe(value);
|
||||
expect(service.decrypt(enc2)).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plaintext passthrough', () => {
|
||||
it('returns non-encrypted strings unchanged on decrypt', () => {
|
||||
expect(service.decrypt('plain text')).toBe('plain text');
|
||||
});
|
||||
|
||||
it('returns non-string values unchanged on decrypt', () => {
|
||||
const obj = { name: 'test' };
|
||||
expect(service.decrypt(obj)).toBe(obj);
|
||||
expect(service.decrypt(42)).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAlreadyEncrypted', () => {
|
||||
it('detects encrypted values', () => {
|
||||
const encrypted = service.encrypt('test') as string;
|
||||
expect(service.isAlreadyEncrypted(encrypted)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects plaintext values', () => {
|
||||
expect(service.isAlreadyEncrypted('plain')).toBe(false);
|
||||
expect(service.isAlreadyEncrypted(null)).toBe(false);
|
||||
expect(service.isAlreadyEncrypted(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeHash', () => {
|
||||
it('produces a deterministic hex hash', () => {
|
||||
const hash1 = service.computeHash('test@example.com');
|
||||
const hash2 = service.computeHash('test@example.com');
|
||||
expect(hash1).toBe(hash2);
|
||||
expect(hash1).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('normalizes input (case-insensitive, trimmed)', () => {
|
||||
const hash1 = service.computeHash('Test@Example.COM');
|
||||
const hash2 = service.computeHash(' test@example.com ');
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
it('returns null for null/undefined', () => {
|
||||
expect(service.computeHash(null)).toBeNull();
|
||||
expect(service.computeHash(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('produces different hashes for different values', () => {
|
||||
const hash1 = service.computeHash('user1@example.com');
|
||||
const hash2 = service.computeHash('user2@example.com');
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('key rotation support', () => {
|
||||
it('decrypts with version from encrypted string', () => {
|
||||
const encrypted = service.encrypt('secret data') as string;
|
||||
expect(encrypted).toMatch(/^enc:v1:/);
|
||||
expect(service.decrypt(encrypted)).toBe('secret data');
|
||||
});
|
||||
|
||||
it('uses versioned key config', () => {
|
||||
process.env['FIELD_ENCRYPTION_KEY_VERSION'] = '3';
|
||||
const v3Service = new FieldEncryptionService(mockLogger);
|
||||
const encrypted = v3Service.encrypt('test') as string;
|
||||
expect(encrypted).toMatch(/^enc:v3:/);
|
||||
expect(v3Service.decrypt(encrypted)).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logAccess', () => {
|
||||
it('logs encrypt operations', () => {
|
||||
service.logAccess('encrypt', 'User', ['email', 'phone'], 'user-123');
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('encrypt User.{email,phone}'),
|
||||
'FieldEncryptionService',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs decrypt operations', () => {
|
||||
service.logAccess('decrypt', 'Payment', ['callbackData']);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('decrypt Payment.{callbackData}'),
|
||||
'FieldEncryptionService',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFieldMap', () => {
|
||||
it('returns PII_FIELD_MAP', () => {
|
||||
expect(service.getFieldMap()).toBe(PII_FIELD_MAP);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModelConfig', () => {
|
||||
it('finds config for known models', () => {
|
||||
const userConfig = service.getModelConfig('User');
|
||||
expect(userConfig).toBeDefined();
|
||||
expect(userConfig!.fields).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('returns undefined for unknown models', () => {
|
||||
expect(service.getModelConfig('Unknown')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when encryption key is NOT configured', () => {
|
||||
let service: FieldEncryptionService;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env['FIELD_ENCRYPTION_KEY'];
|
||||
delete process.env['KYC_ENCRYPTION_KEY'];
|
||||
service = new FieldEncryptionService(mockLogger);
|
||||
});
|
||||
|
||||
it('should not be enabled', () => {
|
||||
expect(service.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('encrypt returns value unchanged', () => {
|
||||
expect(service.encrypt('test')).toBe('test');
|
||||
expect(service.encrypt({ data: 1 })).toEqual({ data: 1 });
|
||||
});
|
||||
|
||||
it('decrypt returns value unchanged', () => {
|
||||
expect(service.decrypt('test')).toBe('test');
|
||||
});
|
||||
|
||||
it('computeHash returns null', () => {
|
||||
expect(service.computeHash('test@example.com')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KYC_ENCRYPTION_KEY fallback', () => {
|
||||
it('uses KYC_ENCRYPTION_KEY when FIELD_ENCRYPTION_KEY is not set', () => {
|
||||
delete process.env['FIELD_ENCRYPTION_KEY'];
|
||||
process.env['KYC_ENCRYPTION_KEY'] = TEST_KEY;
|
||||
const service = new FieldEncryptionService(mockLogger);
|
||||
expect(service.isEnabled()).toBe(true);
|
||||
|
||||
const encrypted = service.encrypt('fallback test') as string;
|
||||
expect(isEncrypted(encrypted)).toBe(true);
|
||||
expect(service.decrypt(encrypted)).toBe('fallback test');
|
||||
|
||||
delete process.env['KYC_ENCRYPTION_KEY'];
|
||||
});
|
||||
});
|
||||
|
||||
describe('PII_FIELD_MAP correctness', () => {
|
||||
it('covers all required models', () => {
|
||||
const models = PII_FIELD_MAP.map((c) => c.model);
|
||||
expect(models).toContain('User');
|
||||
expect(models).toContain('Agent');
|
||||
expect(models).toContain('Payment');
|
||||
expect(models).toContain('Lead');
|
||||
expect(models).toContain('Inquiry');
|
||||
});
|
||||
|
||||
it('User model has correct fields', () => {
|
||||
const userConfig = PII_FIELD_MAP.find((c) => c.model === 'User')!;
|
||||
const fieldNames = userConfig.fields.map((f) => f.field);
|
||||
expect(fieldNames).toContain('email');
|
||||
expect(fieldNames).toContain('phone');
|
||||
expect(fieldNames).toContain('kycData');
|
||||
});
|
||||
|
||||
it('searchable fields are marked correctly', () => {
|
||||
const userConfig = PII_FIELD_MAP.find((c) => c.model === 'User')!;
|
||||
const emailField = userConfig.fields.find((f) => f.field === 'email');
|
||||
const phoneField = userConfig.fields.find((f) => f.field === 'phone');
|
||||
const kycField = userConfig.fields.find((f) => f.field === 'kycData');
|
||||
|
||||
expect(emailField?.searchable).toBe(true);
|
||||
expect(phoneField?.searchable).toBe(true);
|
||||
expect(kycField?.searchable).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Prisma query-extension that transparently encrypts PII fields on write and
|
||||
* decrypts them on read. Works via Prisma `$extends({ query: … })`.
|
||||
*
|
||||
* Design principles:
|
||||
* - Zero changes required in business logic / repositories
|
||||
* - Searchable fields also get a `{field}Hash` written on create/update
|
||||
* - Decryption is applied to all query results automatically
|
||||
* - Non-encrypted (plaintext) values pass through unchanged — safe for
|
||||
* incremental migration
|
||||
*/
|
||||
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
type FieldEncryptionService,
|
||||
type ModelEncryptionConfig,
|
||||
type ModelEncryptionFieldConfig,
|
||||
} from './field-encryption.service';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function encryptDataObject(
|
||||
data: Record<string, unknown>,
|
||||
fields: ModelEncryptionFieldConfig[],
|
||||
service: FieldEncryptionService,
|
||||
): void {
|
||||
const encryptedFields: string[] = [];
|
||||
|
||||
for (const fieldConfig of fields) {
|
||||
const value = data[fieldConfig.field];
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
// Skip if already encrypted (idempotent)
|
||||
if (service.isAlreadyEncrypted(value)) continue;
|
||||
|
||||
// Compute deterministic hash for searchable fields BEFORE encryption
|
||||
if (fieldConfig.searchable && typeof value === 'string') {
|
||||
data[`${fieldConfig.field}Hash`] = service.computeHash(value);
|
||||
}
|
||||
|
||||
data[fieldConfig.field] = service.encrypt(value);
|
||||
encryptedFields.push(fieldConfig.field);
|
||||
}
|
||||
|
||||
if (encryptedFields.length > 0) {
|
||||
service.logAccess('encrypt', 'write', encryptedFields);
|
||||
}
|
||||
}
|
||||
|
||||
function decryptRow(
|
||||
row: Record<string, unknown>,
|
||||
fields: ModelEncryptionFieldConfig[],
|
||||
service: FieldEncryptionService,
|
||||
): void {
|
||||
const decryptedFields: string[] = [];
|
||||
|
||||
for (const fieldConfig of fields) {
|
||||
const value = row[fieldConfig.field];
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
if (service.isAlreadyEncrypted(value)) {
|
||||
row[fieldConfig.field] = service.decrypt(value);
|
||||
decryptedFields.push(fieldConfig.field);
|
||||
}
|
||||
}
|
||||
|
||||
if (decryptedFields.length > 0) {
|
||||
service.logAccess('decrypt', 'read', decryptedFields);
|
||||
}
|
||||
}
|
||||
|
||||
function decryptResult(
|
||||
result: unknown,
|
||||
config: ModelEncryptionConfig,
|
||||
service: FieldEncryptionService,
|
||||
): void {
|
||||
if (Array.isArray(result)) {
|
||||
for (const item of result) {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
decryptRow(item as Record<string, unknown>, config.fields, service);
|
||||
}
|
||||
}
|
||||
} else if (typeof result === 'object' && result !== null) {
|
||||
decryptRow(result as Record<string, unknown>, config.fields, service);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write-args encryption
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function encryptWriteArgs(
|
||||
args: Record<string, unknown>,
|
||||
action: string,
|
||||
config: ModelEncryptionConfig,
|
||||
service: FieldEncryptionService,
|
||||
): void {
|
||||
if (action === 'createMany' || action === 'createManyAndReturn') {
|
||||
const data = args['data'];
|
||||
if (Array.isArray(data)) {
|
||||
for (const row of data) {
|
||||
encryptDataObject(row as Record<string, unknown>, config.fields, service);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'upsert') {
|
||||
const create = args['create'] as Record<string, unknown> | undefined;
|
||||
const update = args['update'] as Record<string, unknown> | undefined;
|
||||
if (create) encryptDataObject(create, config.fields, service);
|
||||
if (update) encryptDataObject(update, config.fields, service);
|
||||
return;
|
||||
}
|
||||
|
||||
// create, update, updateMany — args.data
|
||||
const data = args['data'] as Record<string, unknown> | undefined;
|
||||
if (data) {
|
||||
encryptDataObject(data, config.fields, service);
|
||||
}
|
||||
}
|
||||
|
||||
// Prisma actions that write data
|
||||
const WRITE_ACTIONS = new Set([
|
||||
'create',
|
||||
'createMany',
|
||||
'createManyAndReturn',
|
||||
'update',
|
||||
'updateMany',
|
||||
'upsert',
|
||||
]);
|
||||
|
||||
// Prisma actions whose results we should decrypt
|
||||
const READ_ACTIONS = new Set([
|
||||
'findUnique',
|
||||
'findUniqueOrThrow',
|
||||
'findFirst',
|
||||
'findFirstOrThrow',
|
||||
'findMany',
|
||||
'create',
|
||||
'createManyAndReturn',
|
||||
'update',
|
||||
'upsert',
|
||||
'delete',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: create the Prisma extension
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates a Prisma query extension for field-level encryption.
|
||||
*
|
||||
* Usage inside PrismaService:
|
||||
* ```ts
|
||||
* const extended = prisma.$extends(createEncryptionExtension(service));
|
||||
* ```
|
||||
*/
|
||||
export function createEncryptionExtension(service: FieldEncryptionService) {
|
||||
// Build a fast lookup: lowercase model name → config
|
||||
const modelLookup = new Map<string, ModelEncryptionConfig>();
|
||||
for (const config of service.getFieldMap()) {
|
||||
modelLookup.set(config.model.toLowerCase(), config);
|
||||
}
|
||||
|
||||
return Prisma.defineExtension({
|
||||
name: 'field-encryption',
|
||||
query: {
|
||||
$allModels: {
|
||||
async $allOperations({ model, operation, args, query }) {
|
||||
// Look up encryption config for this model
|
||||
const config = model ? modelLookup.get(model.toLowerCase()) : undefined;
|
||||
|
||||
if (!config || !service.isEnabled()) {
|
||||
return query(args);
|
||||
}
|
||||
|
||||
// Encrypt on write
|
||||
if (WRITE_ACTIONS.has(operation) && args) {
|
||||
encryptWriteArgs(args as Record<string, unknown>, operation, config, service);
|
||||
}
|
||||
|
||||
const result = await query(args);
|
||||
|
||||
// Decrypt on read
|
||||
if (READ_ACTIONS.has(operation) && result !== null && result !== undefined) {
|
||||
decryptResult(result, config, service);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* NestJS-injectable field encryption service.
|
||||
*
|
||||
* Wraps the low-level AES-256-GCM encrypt/decrypt functions with:
|
||||
* - Multi-key support for key rotation
|
||||
* - Deterministic hashing for indexed lookups (email, phone)
|
||||
* - Per-model/field configuration
|
||||
* - Access audit logging
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
encryptField,
|
||||
decryptField,
|
||||
isEncrypted,
|
||||
type FieldEncryptionConfig,
|
||||
} from './field-encryption';
|
||||
import { type LoggerService } from './logger.service';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EncryptionKeyConfig {
|
||||
/** 32-byte hex-encoded encryption key (64 hex chars). */
|
||||
key: string;
|
||||
/** Key version — newer is higher. */
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** Describes which fields on a Prisma model are encrypted. */
|
||||
export interface ModelEncryptionFieldConfig {
|
||||
/** The database field name. */
|
||||
field: string;
|
||||
/**
|
||||
* If true, a deterministic HMAC-SHA256 hash is also maintained in a
|
||||
* `{field}Hash` column, enabling indexed lookups on encrypted data.
|
||||
*/
|
||||
searchable?: boolean;
|
||||
}
|
||||
|
||||
export interface ModelEncryptionConfig {
|
||||
/** Prisma model name (PascalCase, e.g. "User"). */
|
||||
model: string;
|
||||
/** Fields to encrypt within this model. */
|
||||
fields: ModelEncryptionFieldConfig[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encrypted-field map — the single source of truth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Master configuration of all PII fields that require encryption.
|
||||
*
|
||||
* - `searchable: true` means a deterministic hash column (`{field}Hash`)
|
||||
* exists to support `WHERE` / unique-index lookups.
|
||||
* - JSON/blob fields are never searchable (their data is opaque).
|
||||
*/
|
||||
export const PII_FIELD_MAP: ModelEncryptionConfig[] = [
|
||||
{
|
||||
model: 'User',
|
||||
fields: [
|
||||
{ field: 'email', searchable: true },
|
||||
{ field: 'phone', searchable: true },
|
||||
{ field: 'kycData' },
|
||||
],
|
||||
},
|
||||
{
|
||||
model: 'Agent',
|
||||
fields: [{ field: 'licenseNumber' }],
|
||||
},
|
||||
{
|
||||
model: 'Payment',
|
||||
fields: [
|
||||
{ field: 'providerTxId' },
|
||||
{ field: 'callbackData' },
|
||||
],
|
||||
},
|
||||
{
|
||||
model: 'Lead',
|
||||
fields: [
|
||||
{ field: 'phone', searchable: true },
|
||||
{ field: 'email', searchable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
model: 'Inquiry',
|
||||
fields: [{ field: 'phone' }],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Injectable()
|
||||
export class FieldEncryptionService {
|
||||
private readonly activeConfig: FieldEncryptionConfig | null;
|
||||
/** All known key configs, indexed by version — used for decryption. */
|
||||
private readonly keysByVersion: Map<number, FieldEncryptionConfig>;
|
||||
/** HMAC key derived from the active encryption key (for deterministic hashes). */
|
||||
private readonly hmacKey: Buffer | null;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(private readonly logger: LoggerService) {
|
||||
const primaryKey = process.env['FIELD_ENCRYPTION_KEY'] ?? process.env['KYC_ENCRYPTION_KEY'];
|
||||
const keyVersion = Number(
|
||||
process.env['FIELD_ENCRYPTION_KEY_VERSION'] ?? process.env['KYC_ENCRYPTION_KEY_VERSION'] ?? '1',
|
||||
);
|
||||
|
||||
if (!primaryKey) {
|
||||
this.activeConfig = null;
|
||||
this.keysByVersion = new Map();
|
||||
this.hmacKey = null;
|
||||
this.enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeConfig = { key: primaryKey, keyVersion: keyVersion };
|
||||
this.keysByVersion = new Map([[keyVersion, this.activeConfig]]);
|
||||
|
||||
// Load previous key versions for decryption (FIELD_ENCRYPTION_KEY_PREV_1, _PREV_2, ...)
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const prevKey = process.env[`FIELD_ENCRYPTION_KEY_PREV_${i}`];
|
||||
const prevVer = Number(process.env[`FIELD_ENCRYPTION_KEY_PREV_${i}_VERSION`] ?? `${keyVersion - i}`);
|
||||
if (prevKey) {
|
||||
this.keysByVersion.set(prevVer, { key: prevKey, keyVersion: prevVer });
|
||||
}
|
||||
}
|
||||
|
||||
// Derive a stable HMAC key from the primary encryption key for deterministic hashing.
|
||||
// We use HKDF to derive a separate key so the HMAC key is distinct from the encryption key.
|
||||
this.hmacKey = crypto.hkdfSync(
|
||||
'sha256',
|
||||
Buffer.from(primaryKey, 'hex'),
|
||||
Buffer.alloc(0), // no salt — deterministic derivation
|
||||
Buffer.from('goodgo-field-hash', 'utf8'),
|
||||
32,
|
||||
) as unknown as Buffer;
|
||||
|
||||
this.enabled = true;
|
||||
}
|
||||
|
||||
/** Whether encryption is configured and active. */
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/** Encrypt a value using the active key. Returns the `enc:v…:…` string. */
|
||||
encrypt(value: unknown): unknown {
|
||||
if (!this.activeConfig || value === null || value === undefined) return value;
|
||||
return encryptField(value, this.activeConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value. Automatically selects the correct key version from the
|
||||
* `enc:v{N}:…` prefix. Falls back to the active key if version lookup fails.
|
||||
* Non-encrypted values pass through unchanged (migration-safe).
|
||||
*/
|
||||
decrypt(stored: unknown): unknown {
|
||||
if (!this.enabled || stored === null || stored === undefined) return stored;
|
||||
if (!isEncrypted(stored)) return stored;
|
||||
|
||||
// Parse version from the stored value
|
||||
const version = this.parseVersion(stored as string);
|
||||
const config = (version !== null ? this.keysByVersion.get(version) : null) ?? this.activeConfig!;
|
||||
return decryptField(stored, config);
|
||||
}
|
||||
|
||||
/** Check whether a stored value is already encrypted. */
|
||||
isAlreadyEncrypted(value: unknown): boolean {
|
||||
return isEncrypted(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a deterministic HMAC-SHA256 hash for indexed lookups.
|
||||
* The value is normalized (lowercased, trimmed) before hashing.
|
||||
*/
|
||||
computeHash(value: string | null | undefined): string | null {
|
||||
if (!this.hmacKey || value === null || value === undefined) return null;
|
||||
const normalized = value.toLowerCase().trim();
|
||||
return crypto.createHmac('sha256', this.hmacKey).update(normalized).digest('hex');
|
||||
}
|
||||
|
||||
/** Log an audit entry for access to encrypted fields. */
|
||||
logAccess(
|
||||
operation: 'encrypt' | 'decrypt',
|
||||
model: string,
|
||||
fields: string[],
|
||||
recordId?: string,
|
||||
): void {
|
||||
this.logger.debug(
|
||||
`[field-encryption] ${operation} ${model}.{${fields.join(',')}}${recordId ? ` id=${recordId}` : ''}`,
|
||||
'FieldEncryptionService',
|
||||
);
|
||||
}
|
||||
|
||||
/** Get the full PII field configuration. */
|
||||
getFieldMap(): ModelEncryptionConfig[] {
|
||||
return PII_FIELD_MAP;
|
||||
}
|
||||
|
||||
/** Find encryption config for a specific model. */
|
||||
getModelConfig(modelName: string): ModelEncryptionConfig | undefined {
|
||||
return PII_FIELD_MAP.find((c) => c.model === modelName);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private parseVersion(encrypted: string): number | null {
|
||||
// Format: enc:v{N}:{iv}:{authTag}:{ciphertext}
|
||||
const match = encrypted.match(/^enc:v(\d+):/);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { Injectable, type OnModuleInit, type OnModuleDestroy } from '@nestjs/com
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import pg from 'pg';
|
||||
import { FieldEncryptionService } from './field-encryption.service';
|
||||
import { createEncryptionExtension } from './encryption-middleware';
|
||||
import { LoggerService } from './logger.service';
|
||||
import { FieldEncryptionService } from './field-encryption.service';
|
||||
import { type LoggerService } from './logger.service';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
CACHE_DEGRADATION_TOTAL,
|
||||
} from './infrastructure/cache.service';
|
||||
import { EventBusService } from './infrastructure/event-bus.service';
|
||||
import { FieldEncryptionService } from './infrastructure/field-encryption.service';
|
||||
import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter';
|
||||
import { LoggerService } from './infrastructure/logger.service';
|
||||
import { CorrelationIdMiddleware } from './infrastructure/middleware/correlation-id.middleware';
|
||||
import { CsrfMiddleware } from './infrastructure/middleware/csrf.middleware';
|
||||
import { RequestLoggingMiddleware } from './infrastructure/middleware/request-logging.middleware';
|
||||
import { SanitizeInputMiddleware } from './infrastructure/middleware/sanitize-input.middleware';
|
||||
import { FieldEncryptionService } from './infrastructure/field-encryption.service';
|
||||
import { PrismaService } from './infrastructure/prisma.service';
|
||||
import { RedisService } from './infrastructure/redis.service';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user