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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user