diff --git a/app/backend/.env.example b/app/backend/.env.example index bc68ad9..ff7454e 100644 --- a/app/backend/.env.example +++ b/app/backend/.env.example @@ -136,6 +136,12 @@ QUEUE_MAX_RETRIES="3" # Security & Authentication +# Master encryption key for field-level PII encryption (AES-256) +# Production: Use a strong random 32+ character secret +# Generate with: openssl rand -hex 32 +# WARNING: Changing this key will make previously encrypted data unreadable +ENCRYPTION_MASTER_KEY="change-me-in-production-use-openssl-rand-hex-32" + # Secret key for JWT token signing (auto-generated if not set) # Production: Use a strong, random secret (min 32 characters) # Generate with: openssl rand -base64 32 diff --git a/app/backend/src/claims/claims.module.ts b/app/backend/src/claims/claims.module.ts index 72d708f..f2de0da 100644 --- a/app/backend/src/claims/claims.module.ts +++ b/app/backend/src/claims/claims.module.ts @@ -6,6 +6,7 @@ import { OnchainModule } from '../onchain/onchain.module'; import { MetricsModule } from '../observability/metrics/metrics.module'; import { LoggerModule } from '../logger/logger.module'; import { AuditModule } from '../audit/audit.module'; +import { EncryptionModule } from '../common/encryption/encryption.module'; @Module({ imports: [ @@ -14,6 +15,7 @@ import { AuditModule } from '../audit/audit.module'; MetricsModule, LoggerModule, AuditModule, + EncryptionModule, ], controllers: [ClaimsController], providers: [ClaimsService], diff --git a/app/backend/src/claims/claims.service.spec.ts b/app/backend/src/claims/claims.service.spec.ts index 91a0e3c..8b7d364 100644 --- a/app/backend/src/claims/claims.service.spec.ts +++ b/app/backend/src/claims/claims.service.spec.ts @@ -11,6 +11,7 @@ import type { DisburseParams } from '../onchain/onchain.adapter'; import { LoggerService } from '../logger/logger.service'; import { MetricsService } from '../observability/metrics/metrics.service'; import { AuditService } from '../audit/audit.service'; +import { EncryptionService } from '../common/encryption/encryption.service'; import { ClaimStatus, Prisma } from '@prisma/client'; describe('ClaimsService', () => { @@ -111,6 +112,15 @@ describe('ClaimsService', () => { provide: AuditService, useValue: mockAuditService, }, + { + provide: EncryptionService, + useValue: { + encrypt: jest.fn((v: string) => v), + decrypt: jest.fn((v: string) => v), + encryptDeterministic: jest.fn((v: string) => v), + decryptDeterministic: jest.fn((v: string) => v), + }, + }, ], }).compile(); @@ -299,6 +309,15 @@ describe('ClaimsService', () => { provide: AuditService, useValue: mockAuditService, }, + { + provide: EncryptionService, + useValue: { + encrypt: jest.fn((v: string) => v), + decrypt: jest.fn((v: string) => v), + encryptDeterministic: jest.fn((v: string) => v), + decryptDeterministic: jest.fn((v: string) => v), + }, + }, ], }).compile(); diff --git a/app/backend/src/claims/claims.service.ts b/app/backend/src/claims/claims.service.ts index 80374c1..e8f4709 100644 --- a/app/backend/src/claims/claims.service.ts +++ b/app/backend/src/claims/claims.service.ts @@ -19,6 +19,7 @@ import { import { LoggerService } from '../logger/logger.service'; import { MetricsService } from '../observability/metrics/metrics.service'; import { AuditService } from '../audit/audit.service'; +import { EncryptionService } from '../common/encryption/encryption.service'; @Injectable() export class ClaimsService { @@ -34,6 +35,7 @@ export class ClaimsService { private readonly loggerService: LoggerService, private readonly metricsService: MetricsService, private readonly auditService: AuditService, + private readonly encryptionService: EncryptionService, ) { this.onchainEnabled = this.configService.get('ONCHAIN_ENABLED') === 'true'; @@ -52,7 +54,9 @@ export class ClaimsService { data: { campaignId: createClaimDto.campaignId, amount: createClaimDto.amount, - recipientRef: createClaimDto.recipientRef, + recipientRef: this.encryptionService.encrypt( + createClaimDto.recipientRef, + ), evidenceRef: createClaimDto.evidenceRef, }, include: { @@ -60,6 +64,8 @@ export class ClaimsService { }, }); + claim.recipientRef = this.encryptionService.decrypt(claim.recipientRef); + // Stub audit hook void this.auditLog('claim', claim.id, 'created', { status: claim.status }); @@ -67,11 +73,15 @@ export class ClaimsService { } async findAll() { - return this.prisma.claim.findMany({ + const claims = await this.prisma.claim.findMany({ include: { campaign: true, }, }); + return claims.map(claim => ({ + ...claim, + recipientRef: this.encryptionService.decrypt(claim.recipientRef), + })); } async findOne(id: string) { @@ -84,7 +94,10 @@ export class ClaimsService { if (!claim) { throw new NotFoundException('Claim not found'); } - return claim; + return { + ...claim, + recipientRef: this.encryptionService.decrypt(claim.recipientRef), + }; } async verify(id: string) { @@ -140,7 +153,7 @@ export class ClaimsService { onchainResult = await this.onchainAdapter.disburse({ claimId: id, packageId, - recipientAddress: claim.recipientRef, // Using recipientRef as address placeholder + recipientAddress: this.encryptionService.decrypt(claim.recipientRef), amount: claim.amount.toString(), }); diff --git a/app/backend/src/common/encryption/encryption.module.ts b/app/backend/src/common/encryption/encryption.module.ts new file mode 100644 index 0000000..2225bab --- /dev/null +++ b/app/backend/src/common/encryption/encryption.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { EncryptionService } from './encryption.service'; + +@Module({ + imports: [ConfigModule], + providers: [EncryptionService], + exports: [EncryptionService], +}) +export class EncryptionModule {} diff --git a/app/backend/src/common/encryption/encryption.service.spec.ts b/app/backend/src/common/encryption/encryption.service.spec.ts new file mode 100644 index 0000000..5b89c64 --- /dev/null +++ b/app/backend/src/common/encryption/encryption.service.spec.ts @@ -0,0 +1,94 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { EncryptionService } from './encryption.service'; + +describe('EncryptionService', () => { + let service: EncryptionService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EncryptionService, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string): string | undefined => { + if (key === 'ENCRYPTION_MASTER_KEY') + return 'test-master-key-that-is-long-enough-32b!'; + return undefined; + }), + }, + }, + ], + }).compile(); + + service = module.get(EncryptionService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('encrypt / decrypt', () => { + it('should encrypt and decrypt a string correctly', () => { + const plaintext = 'recipient@example.com'; + const encrypted = service.encrypt(plaintext); + expect(encrypted).not.toBe(plaintext); + expect(service.decrypt(encrypted)).toBe(plaintext); + }); + + it('should produce different ciphertext on each call (non-deterministic)', () => { + const plaintext = 'same-input'; + const enc1 = service.encrypt(plaintext); + const enc2 = service.encrypt(plaintext); + expect(enc1).not.toBe(enc2); + }); + + it('should encrypt and decrypt an empty string', () => { + expect(service.decrypt(service.encrypt(''))).toBe(''); + }); + + it('should throw on invalid ciphertext format', () => { + expect(() => service.decrypt('notavalidformat')).toThrow( + 'Invalid encrypted value', + ); + }); + + it('should throw on tampered ciphertext (GCM auth tag check)', () => { + const encrypted = service.encrypt('sensitive-data'); + const parts = encrypted.split(':'); + // flip last byte of ciphertext + const tampered = parts[0] + ':' + parts[1] + ':' + 'ff' + parts[2]; + expect(() => service.decrypt(tampered)).toThrow(); + }); + }); + + describe('encryptDeterministic / decryptDeterministic', () => { + it('should encrypt and decrypt a string correctly', () => { + const plaintext = '+15551234567'; + const encrypted = service.encryptDeterministic(plaintext); + expect(encrypted).not.toBe(plaintext); + expect(service.decryptDeterministic(encrypted)).toBe(plaintext); + }); + + it('should produce the same ciphertext for the same input', () => { + const plaintext = 'user@example.com'; + expect(service.encryptDeterministic(plaintext)).toBe( + service.encryptDeterministic(plaintext), + ); + }); + + it('should produce different ciphertexts for different inputs', () => { + expect(service.encryptDeterministic('foo')).not.toBe( + service.encryptDeterministic('bar'), + ); + }); + + it('should encrypt and decrypt special characters', () => { + const plaintext = 'user+tag@example.co.uk'; + expect( + service.decryptDeterministic(service.encryptDeterministic(plaintext)), + ).toBe(plaintext); + }); + }); +}); diff --git a/app/backend/src/common/encryption/encryption.service.ts b/app/backend/src/common/encryption/encryption.service.ts new file mode 100644 index 0000000..08cc5bc --- /dev/null +++ b/app/backend/src/common/encryption/encryption.service.ts @@ -0,0 +1,103 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as crypto from 'crypto'; + +const ALGORITHM_GCM = 'aes-256-gcm'; +const ALGORITHM_CBC = 'aes-256-cbc'; +const IV_GCM_BYTES = 12; +const IV_CBC_BYTES = 16; + +@Injectable() +export class EncryptionService { + private readonly logger = new Logger(EncryptionService.name); + private readonly key: Buffer; + private readonly deterministicIv: Buffer; + + constructor(private readonly configService: ConfigService) { + const masterKey = this.configService.get('ENCRYPTION_MASTER_KEY'); + if (!masterKey) { + this.logger.warn( + 'ENCRYPTION_MASTER_KEY is not set. Using insecure fallback. Set this before production deployment.', + ); + } + const keyMaterial = + masterKey ?? 'insecure-default-change-in-production!!!!!'; + this.key = crypto.createHash('sha256').update(keyMaterial).digest(); + this.deterministicIv = crypto + .createHmac('sha256', this.key) + .update('deterministic-iv-v1') + .digest() + .subarray(0, IV_CBC_BYTES); + } + + /** + * Encrypts plaintext using AES-256-GCM with a random IV. + * Non-deterministic: ciphertext differs on every call. + * Format: :: + */ + encrypt(plaintext: string): string { + const iv = crypto.randomBytes(IV_GCM_BYTES); + const cipher = crypto.createCipheriv(ALGORITHM_GCM, this.key, iv); + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`; + } + + /** + * Decrypts a value produced by encrypt(). + */ + decrypt(encryptedValue: string): string { + const parts = encryptedValue.split(':'); + if (parts.length !== 3) { + throw new Error('Invalid encrypted value: unexpected format'); + } + const [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_GCM, this.key, iv); + decipher.setAuthTag(authTag); + return Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]).toString('utf8'); + } + + /** + * Encrypts plaintext using AES-256-CBC with a fixed deterministic IV. + * Same plaintext + same key always produces the same ciphertext. + * Use for fields that must remain queryable via equality lookups. + * Format: + */ + encryptDeterministic(plaintext: string): string { + const cipher = crypto.createCipheriv( + ALGORITHM_CBC, + this.key, + this.deterministicIv, + ); + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + return encrypted.toString('hex'); + } + + /** + * Decrypts a value produced by encryptDeterministic(). + */ + decryptDeterministic(encryptedValue: string): string { + const ciphertext = Buffer.from(encryptedValue, 'hex'); + const decipher = crypto.createDecipheriv( + ALGORITHM_CBC, + this.key, + this.deterministicIv, + ); + return Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]).toString('utf8'); + } +} diff --git a/app/backend/src/verification/verification-flow.service.spec.ts b/app/backend/src/verification/verification-flow.service.spec.ts index 3cd2455..a847788 100644 --- a/app/backend/src/verification/verification-flow.service.spec.ts +++ b/app/backend/src/verification/verification-flow.service.spec.ts @@ -7,6 +7,7 @@ import { StartVerificationDto } from './dto/start-verification.dto'; import { ResendVerificationDto } from './dto/resend-verification.dto'; import { CompleteVerificationDto } from './dto/complete-verification.dto'; import { NotificationsService } from '../notifications/notifications.service'; +import { EncryptionService } from '../common/encryption/encryption.service'; describe('VerificationFlowService', () => { let service: VerificationFlowService; @@ -75,6 +76,15 @@ describe('VerificationFlowService', () => { sendSms: jest.fn().mockResolvedValue({ id: 'job-sms' }), }, }, + { + provide: EncryptionService, + useValue: { + encrypt: jest.fn((v: string) => v), + decrypt: jest.fn((v: string) => v), + encryptDeterministic: jest.fn((v: string) => v), + decryptDeterministic: jest.fn((v: string) => v), + }, + }, ], }).compile(); diff --git a/app/backend/src/verification/verification-flow.service.ts b/app/backend/src/verification/verification-flow.service.ts index bc4364d..fd57adf 100644 --- a/app/backend/src/verification/verification-flow.service.ts +++ b/app/backend/src/verification/verification-flow.service.ts @@ -14,6 +14,7 @@ import { import { ResendVerificationDto } from './dto/resend-verification.dto'; import { CompleteVerificationDto } from './dto/complete-verification.dto'; import { NotificationsService } from '../notifications/notifications.service'; +import { EncryptionService } from '../common/encryption/encryption.service'; const DEFAULT_CODE_LENGTH = 6; const DEFAULT_TTL_MINUTES = 10; @@ -34,6 +35,7 @@ export class VerificationFlowService { private readonly prisma: PrismaService, private readonly configService: ConfigService, private readonly notificationsService: NotificationsService, + private readonly encryptionService: EncryptionService, ) { this.codeLength = this.configService.get('VERIFICATION_OTP_LENGTH') ?? @@ -67,9 +69,11 @@ export class VerificationFlowService { } const since = new Date(Date.now() - 60 * 60 * 1000); + const encryptedIdentifier = + this.encryptionService.encryptDeterministic(identifier); const recentCount = await this.prisma.verificationSession.count({ where: { - identifier, + identifier: encryptedIdentifier, createdAt: { gte: since }, }, }); @@ -88,8 +92,8 @@ export class VerificationFlowService { const session = await this.prisma.verificationSession.create({ data: { channel: dto.channel as VerificationChannel, - identifier, - code, + identifier: encryptedIdentifier, + code: this.encryptionService.encrypt(code), expiresAt, }, }); @@ -146,15 +150,19 @@ export class VerificationFlowService { await this.prisma.verificationSession.update({ where: { id: session.id }, data: { - code, + code: this.encryptionService.encrypt(code), resendCount: session.resendCount + 1, expiresAt, }, }); + const decryptedIdentifier = this.encryptionService.decryptDeterministic( + session.identifier, + ); + await this.sendCode( session.channel as unknown as VerificationChannelDto, - session.identifier, + decryptedIdentifier, code, ); @@ -199,7 +207,8 @@ export class VerificationFlowService { ); } - if (session.code !== dto.code) { + const storedCode = this.encryptionService.decrypt(session.code); + if (storedCode !== dto.code) { await this.prisma.verificationSession.update({ where: { id: session.id }, data: { attempts: session.attempts + 1 }, diff --git a/app/backend/src/verification/verification.module.ts b/app/backend/src/verification/verification.module.ts index ad83973..101ef99 100644 --- a/app/backend/src/verification/verification.module.ts +++ b/app/backend/src/verification/verification.module.ts @@ -9,6 +9,7 @@ import { VerificationProcessor } from './verification.processor'; import { PrismaModule } from '../prisma/prisma.module'; import { AuditModule } from '../audit/audit.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { EncryptionModule } from '../common/encryption/encryption.module'; @Module({ imports: [ @@ -17,6 +18,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; PrismaModule, AuditModule, NotificationsModule, + EncryptionModule, BullModule.registerQueueAsync({ name: 'verification', imports: [ConfigModule],