Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/backend/src/claims/claims.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -14,6 +15,7 @@ import { AuditModule } from '../audit/audit.module';
MetricsModule,
LoggerModule,
AuditModule,
EncryptionModule,
],
controllers: [ClaimsController],
providers: [ClaimsService],
Expand Down
19 changes: 19 additions & 0 deletions app/backend/src/claims/claims.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();

Expand Down
21 changes: 17 additions & 4 deletions app/backend/src/claims/claims.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string>('ONCHAIN_ENABLED') === 'true';
Expand All @@ -52,26 +54,34 @@ export class ClaimsService {
data: {
campaignId: createClaimDto.campaignId,
amount: createClaimDto.amount,
recipientRef: createClaimDto.recipientRef,
recipientRef: this.encryptionService.encrypt(
createClaimDto.recipientRef,
),
evidenceRef: createClaimDto.evidenceRef,
},
include: {
campaign: true,
},
});

claim.recipientRef = this.encryptionService.decrypt(claim.recipientRef);

// Stub audit hook
void this.auditLog('claim', claim.id, 'created', { status: claim.status });

return claim;
}

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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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(),
});

Expand Down
10 changes: 10 additions & 0 deletions app/backend/src/common/encryption/encryption.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
94 changes: 94 additions & 0 deletions app/backend/src/common/encryption/encryption.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(EncryptionService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});

describe('encrypt / decrypt', () => {
it('should encrypt and decrypt a string correctly', () => {
const plaintext = '[email protected]';
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 = '[email protected]';
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 = '[email protected]';
expect(
service.decryptDeterministic(service.encryptDeterministic(plaintext)),
).toBe(plaintext);
});
});
});
103 changes: 103 additions & 0 deletions app/backend/src/common/encryption/encryption.service.ts
Original file line number Diff line number Diff line change
@@ -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<string>('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: <ivHex>:<authTagHex>:<ciphertextHex>
*/
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: <ciphertextHex>
*/
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');
}
}
10 changes: 10 additions & 0 deletions app/backend/src/verification/verification-flow.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down
Loading
Loading