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:
Ho Ngoc Hai
2026-04-11 23:43:20 +07:00
parent 9e2bf9a4b5
commit 1fbe2f4e73
131 changed files with 11436 additions and 2595 deletions

View File

@@ -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);
});
});
});

View File

@@ -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();
});

View File

@@ -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();
});
});
});

View File

@@ -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;
},
},
},
});
}

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -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';