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:
Ho Ngoc Hai
2026-04-09 09:44:00 +07:00
parent e927385ed5
commit 2250e17a09
10 changed files with 592 additions and 5 deletions

View File

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

View File

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