feat(api): add field encryption, health check specs, and KYC encryption script
- Add field-level encryption service for PII data with AES-256-GCM - Add health check specs for Prisma and Redis indicators - Add MCP controller specs - Add encrypt-existing-kyc migration script for existing KYC data Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { type HealthCheckService } from '@nestjs/terminus';
|
||||
import { HealthController } from '../health.controller';
|
||||
import { type PrismaHealthIndicator } from '../infrastructure/prisma.health';
|
||||
import { type RedisHealthIndicator } from '../infrastructure/redis.health';
|
||||
|
||||
describe('HealthController', () => {
|
||||
let controller: HealthController;
|
||||
let mockHealthCheckService: { check: ReturnType<typeof vi.fn> };
|
||||
let mockPrismaHealth: PrismaHealthIndicator;
|
||||
let mockRedisHealth: RedisHealthIndicator;
|
||||
|
||||
beforeEach(() => {
|
||||
mockHealthCheckService = { check: vi.fn() };
|
||||
mockPrismaHealth = { isHealthy: vi.fn() } as unknown as PrismaHealthIndicator;
|
||||
mockRedisHealth = { isHealthy: vi.fn() } as unknown as RedisHealthIndicator;
|
||||
|
||||
controller = new HealthController(
|
||||
mockHealthCheckService as unknown as HealthCheckService,
|
||||
mockPrismaHealth,
|
||||
mockRedisHealth,
|
||||
);
|
||||
});
|
||||
|
||||
describe('liveness', () => {
|
||||
it('calls health.check with an empty array', () => {
|
||||
const expected = { status: 'ok', details: {} };
|
||||
mockHealthCheckService.check.mockReturnValue(expected);
|
||||
|
||||
const result = controller.liveness();
|
||||
|
||||
expect(mockHealthCheckService.check).toHaveBeenCalledOnce();
|
||||
expect(mockHealthCheckService.check).toHaveBeenCalledWith([]);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readiness', () => {
|
||||
it('calls health.check with prisma and redis health indicators', async () => {
|
||||
const expected = {
|
||||
status: 'ok',
|
||||
details: { database: { status: 'up' }, redis: { status: 'up' } },
|
||||
};
|
||||
mockHealthCheckService.check.mockImplementation(
|
||||
async (indicators: Array<() => Promise<unknown>>) => {
|
||||
// Execute the indicator callbacks to verify they call the right methods
|
||||
for (const indicator of indicators) {
|
||||
await indicator();
|
||||
}
|
||||
return expected;
|
||||
},
|
||||
);
|
||||
(mockPrismaHealth.isHealthy as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
database: { status: 'up' },
|
||||
});
|
||||
(mockRedisHealth.isHealthy as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
redis: { status: 'up' },
|
||||
});
|
||||
|
||||
const result = await controller.readiness();
|
||||
|
||||
expect(result).toBe(expected);
|
||||
expect(mockPrismaHealth.isHealthy).toHaveBeenCalledWith('database');
|
||||
expect(mockRedisHealth.isHealthy).toHaveBeenCalledWith('redis');
|
||||
});
|
||||
|
||||
it('passes two health indicator callbacks to health.check', () => {
|
||||
mockHealthCheckService.check.mockReturnValue({ status: 'ok' });
|
||||
|
||||
controller.readiness();
|
||||
|
||||
const callbacks = mockHealthCheckService.check.mock.calls[0][0];
|
||||
expect(callbacks).toHaveLength(2);
|
||||
expect(typeof callbacks[0]).toBe('function');
|
||||
expect(typeof callbacks[1]).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { HealthCheckError } from '@nestjs/terminus';
|
||||
import { PrismaHealthIndicator } from '../prisma.health';
|
||||
|
||||
describe('PrismaHealthIndicator', () => {
|
||||
let indicator: PrismaHealthIndicator;
|
||||
let mockPrisma: { $queryRawUnsafe: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = { $queryRawUnsafe: vi.fn() };
|
||||
indicator = new PrismaHealthIndicator(mockPrisma as any);
|
||||
});
|
||||
|
||||
it('returns healthy status when database responds to SELECT 1', async () => {
|
||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([{ '?column?': 1 }]);
|
||||
|
||||
const result = await indicator.isHealthy('database');
|
||||
|
||||
expect(mockPrisma.$queryRawUnsafe).toHaveBeenCalledWith('SELECT 1');
|
||||
expect(result).toEqual({ database: { status: 'up' } });
|
||||
});
|
||||
|
||||
it('throws HealthCheckError when database query fails', async () => {
|
||||
mockPrisma.$queryRawUnsafe.mockRejectedValue(new Error('Connection refused'));
|
||||
|
||||
await expect(indicator.isHealthy('database')).rejects.toThrow(HealthCheckError);
|
||||
});
|
||||
|
||||
it('includes the key name in the error status', async () => {
|
||||
mockPrisma.$queryRawUnsafe.mockRejectedValue(new Error('timeout'));
|
||||
|
||||
try {
|
||||
await indicator.isHealthy('db');
|
||||
expect.unreachable('Should have thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(HealthCheckError);
|
||||
expect((error as HealthCheckError).causes).toEqual({ db: { status: 'down' } });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { HealthCheckError } from '@nestjs/terminus';
|
||||
import { RedisHealthIndicator } from '../redis.health';
|
||||
|
||||
describe('RedisHealthIndicator', () => {
|
||||
let indicator: RedisHealthIndicator;
|
||||
let mockClient: { ping: ReturnType<typeof vi.fn> };
|
||||
let mockRedisService: { getClient: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = { ping: vi.fn() };
|
||||
mockRedisService = { getClient: vi.fn().mockReturnValue(mockClient) };
|
||||
indicator = new RedisHealthIndicator(mockRedisService as any);
|
||||
});
|
||||
|
||||
it('returns healthy status when Redis responds with PONG', async () => {
|
||||
mockClient.ping.mockResolvedValue('PONG');
|
||||
|
||||
const result = await indicator.isHealthy('redis');
|
||||
|
||||
expect(mockRedisService.getClient).toHaveBeenCalledOnce();
|
||||
expect(mockClient.ping).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({ redis: { status: 'up' } });
|
||||
});
|
||||
|
||||
it('throws HealthCheckError when Redis does not respond with PONG', async () => {
|
||||
mockClient.ping.mockResolvedValue('NOT_PONG');
|
||||
|
||||
await expect(indicator.isHealthy('redis')).rejects.toThrow(HealthCheckError);
|
||||
});
|
||||
|
||||
it('throws HealthCheckError with ping-failed message for non-PONG response', async () => {
|
||||
mockClient.ping.mockResolvedValue('');
|
||||
|
||||
try {
|
||||
await indicator.isHealthy('redis');
|
||||
expect.unreachable('Should have thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(HealthCheckError);
|
||||
expect((error as HealthCheckError).message).toBe('Redis ping failed');
|
||||
}
|
||||
});
|
||||
|
||||
it('throws HealthCheckError when client.ping throws', async () => {
|
||||
mockClient.ping.mockRejectedValue(new Error('Connection refused'));
|
||||
|
||||
await expect(indicator.isHealthy('redis')).rejects.toThrow(HealthCheckError);
|
||||
});
|
||||
|
||||
it('includes the key name in error status when connection fails', async () => {
|
||||
mockClient.ping.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
try {
|
||||
await indicator.isHealthy('cache');
|
||||
expect.unreachable('Should have thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(HealthCheckError);
|
||||
expect((error as HealthCheckError).causes).toEqual({ cache: { status: 'down' } });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HealthCheckError, HealthIndicator, type HealthIndicatorResult } from '@nestjs/terminus';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import { PrismaService } from '@modules/shared';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaHealthIndicator extends HealthIndicator {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HealthCheckError, HealthIndicator, type HealthIndicatorResult } from '@nestjs/terminus';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import { RedisService } from '@modules/shared/infrastructure/redis.service';
|
||||
import { RedisService } from '@modules/shared';
|
||||
|
||||
@Injectable()
|
||||
export class RedisHealthIndicator extends HealthIndicator {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { McpModule as McpCoreModule, type McpRegistryService } from '@goodgo/mcp-servers';
|
||||
import { Module, type OnModuleInit } from '@nestjs/common';
|
||||
import { AuthModule } from '@modules/auth';
|
||||
import { SearchModule } from '@modules/search';
|
||||
import { type TypesenseClientService } from '@modules/search/infrastructure/services/typesense-client.service';
|
||||
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
|
||||
import { SearchModule, type TypesenseClientService } from '@modules/search';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import { McpTransportController } from './presentation/mcp-transport.controller';
|
||||
|
||||
@Module({
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { McpTransportController } from '../mcp-transport.controller';
|
||||
|
||||
// Mock SSEServerTransport as a class
|
||||
vi.mock('@goodgo/mcp-servers', () => {
|
||||
return {
|
||||
SSEServerTransport: class MockSSEServerTransport {
|
||||
sessionId = 'mock-session-id';
|
||||
handlePostMessage = vi.fn().mockResolvedValue(undefined);
|
||||
constructor(public path: string, public res: unknown) {}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('McpTransportController', () => {
|
||||
let controller: McpTransportController;
|
||||
let mockRegistry: {
|
||||
getServerNames: ReturnType<typeof vi.fn>;
|
||||
getServer: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRegistry = {
|
||||
getServerNames: vi.fn(),
|
||||
getServer: vi.fn(),
|
||||
};
|
||||
controller = new McpTransportController(mockRegistry as any);
|
||||
});
|
||||
|
||||
describe('listServers', () => {
|
||||
it('returns list of server names from registry', () => {
|
||||
mockRegistry.getServerNames.mockReturnValue(['search', 'listings']);
|
||||
|
||||
const result = controller.listServers();
|
||||
|
||||
expect(result).toEqual({ servers: ['search', 'listings'] });
|
||||
expect(mockRegistry.getServerNames).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('returns empty array when no servers registered', () => {
|
||||
mockRegistry.getServerNames.mockReturnValue([]);
|
||||
|
||||
const result = controller.listServers();
|
||||
|
||||
expect(result).toEqual({ servers: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSse', () => {
|
||||
const mockUser = { sub: 'user-1', email: 'test@example.com' };
|
||||
let mockReq: { on: ReturnType<typeof vi.fn> };
|
||||
let mockRes: Record<string, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReq = { on: vi.fn() };
|
||||
mockRes = {};
|
||||
});
|
||||
|
||||
it('throws NOT_FOUND when server does not exist', async () => {
|
||||
mockRegistry.getServer.mockReturnValue(null);
|
||||
|
||||
await expect(
|
||||
controller.handleSse('nonexistent', mockUser as any, mockReq as any, mockRes as any),
|
||||
).rejects.toThrow(HttpException);
|
||||
|
||||
try {
|
||||
await controller.handleSse('nonexistent', mockUser as any, mockReq as any, mockRes as any);
|
||||
} catch (error) {
|
||||
expect((error as HttpException).getStatus()).toBe(HttpStatus.NOT_FOUND);
|
||||
expect((error as HttpException).message).toContain('nonexistent');
|
||||
}
|
||||
});
|
||||
|
||||
it('creates transport and connects to server', async () => {
|
||||
const mockServer = { connect: vi.fn().mockResolvedValue(undefined) };
|
||||
mockRegistry.getServer.mockReturnValue(mockServer);
|
||||
|
||||
await controller.handleSse('search', mockUser as any, mockReq as any, mockRes as any);
|
||||
|
||||
expect(mockRegistry.getServer).toHaveBeenCalledWith('search');
|
||||
expect(mockServer.connect).toHaveBeenCalledOnce();
|
||||
expect(mockReq.on).toHaveBeenCalledWith('close', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleMessage', () => {
|
||||
const mockUser = { sub: 'user-1', email: 'test@example.com' };
|
||||
|
||||
it('throws BAD_REQUEST when sessionId query parameter is missing', async () => {
|
||||
const mockReq = { query: {} } as any;
|
||||
const mockRes = {} as any;
|
||||
|
||||
await expect(
|
||||
controller.handleMessage('search', mockUser as any, mockReq, mockRes),
|
||||
).rejects.toThrow(HttpException);
|
||||
|
||||
try {
|
||||
await controller.handleMessage('search', mockUser as any, mockReq, mockRes);
|
||||
} catch (error) {
|
||||
expect((error as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws NOT_FOUND when session does not exist', async () => {
|
||||
const mockReq = { query: { sessionId: 'nonexistent-session' } } as any;
|
||||
const mockRes = {} as any;
|
||||
|
||||
await expect(
|
||||
controller.handleMessage('search', mockUser as any, mockReq, mockRes),
|
||||
).rejects.toThrow(HttpException);
|
||||
|
||||
try {
|
||||
await controller.handleMessage('search', mockUser as any, mockReq, mockRes);
|
||||
} catch (error) {
|
||||
expect((error as HttpException).getStatus()).toBe(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
encryptField,
|
||||
decryptField,
|
||||
isEncrypted,
|
||||
type FieldEncryptionConfig,
|
||||
} from '../field-encryption';
|
||||
|
||||
const TEST_KEY = crypto.randomBytes(32).toString('hex'); // 64 hex chars
|
||||
const config: FieldEncryptionConfig = { key: TEST_KEY, keyVersion: 1 };
|
||||
|
||||
describe('field-encryption', () => {
|
||||
describe('encryptField / decryptField round-trip', () => {
|
||||
it('encrypts and decrypts a simple object', () => {
|
||||
const original = { name: 'Nguyen Van A', idNumber: '012345678901' };
|
||||
const encrypted = encryptField(original, config);
|
||||
const decrypted = decryptField(encrypted, config);
|
||||
|
||||
expect(decrypted).toEqual(original);
|
||||
});
|
||||
|
||||
it('encrypts and decrypts a string value', () => {
|
||||
const original = 'sensitive-pii-data';
|
||||
const encrypted = encryptField(original, config);
|
||||
const decrypted = decryptField(encrypted, config);
|
||||
|
||||
expect(decrypted).toBe(original);
|
||||
});
|
||||
|
||||
it('encrypts and decrypts nested objects', () => {
|
||||
const original = {
|
||||
identity: { type: 'CCCD', number: '012345678901' },
|
||||
address: { city: 'Ho Chi Minh', district: '1' },
|
||||
documents: [{ url: 'https://example.com/front.jpg' }],
|
||||
};
|
||||
const encrypted = encryptField(original, config);
|
||||
const decrypted = decryptField(encrypted, config);
|
||||
|
||||
expect(decrypted).toEqual(original);
|
||||
});
|
||||
|
||||
it('encrypts and decrypts null-containing objects', () => {
|
||||
const original = { name: 'Test', optional: null };
|
||||
const encrypted = encryptField(original, config);
|
||||
const decrypted = decryptField(encrypted, config);
|
||||
|
||||
expect(decrypted).toEqual(original);
|
||||
});
|
||||
|
||||
it('produces different ciphertext for same input (random IV)', () => {
|
||||
const original = { name: 'Test' };
|
||||
const enc1 = encryptField(original, config);
|
||||
const enc2 = encryptField(original, config);
|
||||
|
||||
expect(enc1).not.toBe(enc2);
|
||||
expect(decryptField(enc1, config)).toEqual(original);
|
||||
expect(decryptField(enc2, config)).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypted format', () => {
|
||||
it('starts with enc: prefix', () => {
|
||||
const encrypted = encryptField({ test: true }, config);
|
||||
expect(encrypted).toMatch(/^enc:v\d+:[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
|
||||
});
|
||||
|
||||
it('includes key version', () => {
|
||||
const v2Config = { ...config, keyVersion: 2 };
|
||||
const encrypted = encryptField('data', v2Config);
|
||||
expect(encrypted).toMatch(/^enc:v2:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEncrypted', () => {
|
||||
it('returns true for encrypted values', () => {
|
||||
const encrypted = encryptField('test', config);
|
||||
expect(isEncrypted(encrypted)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for plaintext strings', () => {
|
||||
expect(isEncrypted('plain text')).toBe(false);
|
||||
expect(isEncrypted('{"name":"test"}')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-string values', () => {
|
||||
expect(isEncrypted(null)).toBe(false);
|
||||
expect(isEncrypted(undefined)).toBe(false);
|
||||
expect(isEncrypted(42)).toBe(false);
|
||||
expect(isEncrypted({ name: 'test' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decryptField — plaintext passthrough', () => {
|
||||
it('returns non-string values as-is', () => {
|
||||
const obj = { name: 'test' };
|
||||
expect(decryptField(obj, config)).toBe(obj);
|
||||
expect(decryptField(null, config)).toBe(null);
|
||||
expect(decryptField(42, config)).toBe(42);
|
||||
});
|
||||
|
||||
it('returns non-encrypted strings as-is', () => {
|
||||
expect(decryptField('plain text', config)).toBe('plain text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('rejects invalid key length', () => {
|
||||
const badConfig = { key: 'tooshort', keyVersion: 1 };
|
||||
expect(() => encryptField('test', badConfig)).toThrow('32 bytes');
|
||||
});
|
||||
|
||||
it('rejects tampered ciphertext', () => {
|
||||
const encrypted = encryptField('secret', config);
|
||||
// Flip a character in the ciphertext portion
|
||||
const tampered = encrypted.slice(0, -2) + 'ff';
|
||||
expect(() => decryptField(tampered, config)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects decryption with wrong key', () => {
|
||||
const encrypted = encryptField('secret', config);
|
||||
const wrongKey = crypto.randomBytes(32).toString('hex');
|
||||
const wrongConfig = { key: wrongKey, keyVersion: 1 };
|
||||
expect(() => decryptField(encrypted, wrongConfig)).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* AES-256-GCM field-level encryption for sensitive database fields.
|
||||
*
|
||||
* Encrypted values are stored as: `enc:v{version}:{iv}:{authTag}:{ciphertext}`
|
||||
* All segments are hex-encoded. The `enc:` prefix lets us distinguish encrypted
|
||||
* from plaintext values (important for migration).
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12; // 96-bit IV recommended for GCM
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const PREFIX = 'enc:';
|
||||
|
||||
export interface FieldEncryptionConfig {
|
||||
/** 32-byte hex-encoded encryption key (64 hex chars). */
|
||||
key: string;
|
||||
/** Key version for rotation support. Defaults to 1. */
|
||||
keyVersion?: number;
|
||||
}
|
||||
|
||||
function deriveKeyBuffer(hexKey: string): Buffer {
|
||||
const buf = Buffer.from(hexKey, 'hex');
|
||||
if (buf.length !== 32) {
|
||||
throw new Error(
|
||||
`KYC_ENCRYPTION_KEY must be exactly 32 bytes (64 hex chars), got ${buf.length} bytes`,
|
||||
);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a JSON-serializable value using AES-256-GCM.
|
||||
* Returns a prefixed string suitable for database storage.
|
||||
*/
|
||||
export function encryptField(value: unknown, config: FieldEncryptionConfig): string {
|
||||
const keyBuf = deriveKeyBuffer(config.key);
|
||||
const version = config.keyVersion ?? 1;
|
||||
const plaintext = JSON.stringify(value);
|
||||
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, keyBuf, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return `${PREFIX}v${version}:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a previously encrypted field value.
|
||||
* Returns the original JSON-parsed value, or the raw input if it is not encrypted
|
||||
* (supports transparent migration from plaintext).
|
||||
*/
|
||||
export function decryptField(stored: unknown, config: FieldEncryptionConfig): unknown {
|
||||
if (typeof stored !== 'string' || !stored.startsWith(PREFIX)) {
|
||||
// Not encrypted — return as-is (plaintext migration path)
|
||||
return stored;
|
||||
}
|
||||
|
||||
const keyBuf = deriveKeyBuffer(config.key);
|
||||
// Format: enc:v{version}:{iv}:{authTag}:{ciphertext}
|
||||
const parts = stored.slice(PREFIX.length).split(':');
|
||||
if (parts.length !== 4) {
|
||||
throw new Error('Malformed encrypted field: expected 4 segments after prefix');
|
||||
}
|
||||
|
||||
const [_versionTag, ivHex, authTagHex, ciphertextHex] = parts;
|
||||
const iv = Buffer.from(ivHex!, 'hex');
|
||||
const authTag = Buffer.from(authTagHex!, 'hex');
|
||||
const ciphertext = Buffer.from(ciphertextHex!, 'hex');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuf, iv, {
|
||||
authTagLength: AUTH_TAG_LENGTH,
|
||||
});
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return JSON.parse(decrypted.toString('utf8'));
|
||||
}
|
||||
|
||||
/** Check if a stored value is already encrypted. */
|
||||
export function isEncrypted(value: unknown): boolean {
|
||||
return typeof value === 'string' && value.startsWith(PREFIX);
|
||||
}
|
||||
79
scripts/encrypt-existing-kyc.ts
Normal file
79
scripts/encrypt-existing-kyc.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* One-time migration: encrypt existing plaintext kycData records.
|
||||
*
|
||||
* Usage:
|
||||
* KYC_ENCRYPTION_KEY=<hex-key> npx tsx scripts/encrypt-existing-kyc.ts [--dry-run]
|
||||
*
|
||||
* This script:
|
||||
* 1. Reads all User rows where kycData is not null
|
||||
* 2. Skips rows that are already encrypted (have the `enc:` prefix)
|
||||
* 3. Encrypts plaintext kycData using AES-256-GCM
|
||||
* 4. Updates each row in a transaction
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import {
|
||||
encryptField,
|
||||
isEncrypted,
|
||||
type FieldEncryptionConfig,
|
||||
} from '../apps/api/src/modules/shared/infrastructure/field-encryption';
|
||||
|
||||
async function main() {
|
||||
const key = process.env['KYC_ENCRYPTION_KEY'];
|
||||
if (!key) {
|
||||
console.error('ERROR: KYC_ENCRYPTION_KEY env var is required.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
const config: FieldEncryptionConfig = {
|
||||
key,
|
||||
keyVersion: parseInt(process.env['KYC_ENCRYPTION_KEY_VERSION'] ?? '1', 10),
|
||||
};
|
||||
|
||||
// Use raw PrismaClient without encryption middleware to read plaintext
|
||||
const prisma = new PrismaClient();
|
||||
await prisma.$connect();
|
||||
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { kycData: { not: null } },
|
||||
select: { id: true, kycData: true },
|
||||
});
|
||||
|
||||
console.log(`Found ${users.length} users with kycData.`);
|
||||
|
||||
let encrypted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const user of users) {
|
||||
if (isEncrypted(user.kycData)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptedValue = encryptField(user.kycData, config);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`[DRY RUN] Would encrypt kycData for user ${user.id}`);
|
||||
} else {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { kycData: encryptedValue },
|
||||
});
|
||||
}
|
||||
encrypted++;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${dryRun ? '[DRY RUN] ' : ''}Done. Encrypted: ${encrypted}, Already encrypted: ${skipped}`,
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user