Security Implementation Guide
This guide covers the security architecture, encryption utilities, and best practices for developers working on Conducky.
๐ Database Encryption Architectureโ
Overviewโ
Conducky implements field-level database encryption for all sensitive user data using AES-256-GCM encryption with authenticated encryption to prevent tampering.
Encryption Utilitiesโ
All encryption functionality is centralized in backend/src/utils/encryption.ts
:
import { encryptField, decryptField, isEncrypted } from '../utils/encryption';
// Encrypt sensitive data before storing
const encryptedDescription = encryptField(incident.description);
// Decrypt data when retrieving
const decryptedDescription = decryptField(encryptedDescription);
// Check if data is encrypted
if (isEncrypted(storedData)) {
const plaintext = decryptField(storedData);
}
Encrypted Fieldsโ
Current Implementation:
Incident.description
- Incident details and narrativesIncident.parties
- Names/identifiers of involved partiesIncident.location
- Incident location informationIncidentComment.body
- All comment contentEvent.contactEmail
- Event organizer contact information- System settings (OAuth credentials, SMTP passwords)
Format Specificationโ
Encryption Format: salt:iv:encrypted:authTag
- salt: 32-byte random salt (hex-encoded)
- iv: 16-byte initialization vector (hex-encoded)
- encrypted: Encrypted data (hex-encoded, may be empty for empty strings)
- authTag: 16-byte authentication tag (hex-encoded)
Legacy Format: salt:iv:encrypted
(supported for backward compatibility)
๐ ๏ธ Development Guidelinesโ
Using Encryption in Servicesโ
Pattern for Service Methods:
// In service classes (e.g., IncidentService)
class IncidentService {
// Helper methods for encryption/decryption
private encryptIncidentData(incident: any) {
return {
...incident,
description: incident.description ? encryptField(incident.description) : null,
parties: incident.parties ? encryptField(incident.parties) : null,
location: incident.location ? encryptField(incident.location) : null,
};
}
private decryptIncidentData(incident: any) {
return {
...incident,
description: incident.description ? decryptField(incident.description) : null,
parties: incident.parties ? decryptField(incident.parties) : null,
location: incident.location ? decryptField(incident.location) : null,
};
}
// Create method - encrypt before storing
async createIncident(data: IncidentCreateData) {
const encryptedData = this.encryptIncidentData(data);
const incident = await this.prisma.incident.create({
data: encryptedData
});
// Return decrypted data to API
return this.decryptIncidentData(incident);
}
// Read method - decrypt before returning
async getIncidentById(id: string) {
const incident = await this.prisma.incident.findUnique({
where: { id }
});
if (!incident) return null;
return this.decryptIncidentData(incident);
}
// Update method - encrypt new values
async updateIncident(id: string, data: IncidentUpdateData) {
const encryptedData = this.encryptIncidentData(data);
const incident = await this.prisma.incident.update({
where: { id },
data: encryptedData
});
return this.decryptIncidentData(incident);
}
}
Error Handlingโ
Graceful Degradation:
// The decryptField function handles errors gracefully
const decryptedValue = decryptField(encryptedValue);
// If decryption fails, returns the original value
// This prevents data loss during format changes or key rotation
Manual Error Handling:
try {
const decrypted = decryptField(encryptedData);
return decrypted;
} catch (error) {
logger.error('Decryption failed for sensitive data', { error: error.message });
// Handle appropriately - may need to return error to user
throw new Error('Unable to decrypt sensitive data');
}
Testing Encrypted Fieldsโ
Unit Test Pattern:
describe('IncidentService Encryption', () => {
it('should encrypt incident data before storing', async () => {
const incidentData = {
description: 'Sensitive incident details',
parties: 'John Doe',
location: 'Conference Room A'
};
const result = await incidentService.createIncident(incidentData);
// Verify data is returned decrypted
expect(result.description).toBe(incidentData.description);
// Verify data is stored encrypted (check database directly)
const storedIncident = await prisma.incident.findUnique({
where: { id: result.id }
});
expect(isEncrypted(storedIncident.description)).toBe(true);
expect(storedIncident.description).not.toBe(incidentData.description);
});
});
Migration Patternsโ
For New Encrypted Fields:
-
Add Migration Script:
// backend/scripts/migrate-new-field-encryption.js
const { encryptField, isEncrypted } = require('../dist/src/utils/encryption');
async function migrateNewFieldEncryption(dryRun = false) {
const records = await prisma.modelName.findMany({
where: {
newField: { not: null }
}
});
for (const record of records) {
if (!isEncrypted(record.newField)) {
const encrypted = encryptField(record.newField);
if (!dryRun) {
await prisma.modelName.update({
where: { id: record.id },
data: { newField: encrypted }
});
}
}
}
} -
Add NPM Scripts:
{
"scripts": {
"migrate:new-field:dry-run": "node scripts/migrate-new-field-encryption.js --dry-run",
"migrate:new-field": "node scripts/migrate-new-field-encryption.js"
}
}
๐ Security Best Practicesโ
Environment Configurationโ
Development:
# Use a long, unique key for development
ENCRYPTION_KEY=development-encryption-key-that-is-long-enough-for-testing
Production:
# Generate cryptographically secure key
openssl rand -base64 48
# Result: Set as ENCRYPTION_KEY environment variable
Data Handling Rulesโ
DO:
- Always encrypt sensitive user data before database storage
- Use helper methods for consistent encryption/decryption
- Return decrypted data from service methods to API layers
- Test both encrypted storage and decrypted retrieval
- Use
isEncrypted()
to check format before decryption - Handle encryption errors gracefully
DON'T:
- Return encrypted data to API responses
- Store plaintext sensitive data in database
- Hard-code encryption keys
- Skip validation of encryption format
- Ignore decryption errors
- Encrypt data multiple times
Performance Considerationsโ
Client-Side Filtering:
// Since encrypted data can't be filtered in SQL, handle client-side
const incidents = await prisma.incident.findMany({
where: { eventId } // Filter on non-encrypted fields only
});
// Decrypt and filter client-side
const filtered = incidents
.map(incident => ({
...incident,
description: decryptField(incident.description)
}))
.filter(incident =>
incident.description.toLowerCase().includes(searchTerm.toLowerCase())
);
Pagination Strategy:
// Get more records than needed to account for client-side filtering
const rawIncidents = await prisma.incident.findMany({
where: { eventId },
take: limit * 2, // Get extra records
skip: offset
});
const decryptedIncidents = rawIncidents.map(decryptIncidentData);
const filtered = applyClientFilters(decryptedIncidents);
return filtered.slice(0, limit); // Return requested amount
๐ Debugging Encrypted Dataโ
Identifying Encryption Issuesโ
Check Encryption Format:
import { isEncrypted } from '../utils/encryption';
// Verify data format
console.log('Is encrypted:', isEncrypted(data));
console.log('Format parts:', data.split(':').length); // Should be 3 or 4
Manual Decryption Testing:
// Test decryption in development
try {
const decrypted = decryptField(encryptedData);
console.log('Decryption successful:', decrypted);
} catch (error) {
console.error('Decryption failed:', error.message);
}
Common Issuesโ
Empty String Handling:
- Empty strings (
""
) are encrypted and return encrypted format null
andundefined
values are passed through unchanged- Use
isEncrypted()
to verify format before decryption
Legacy Format Support:
- Old 3-part format:
salt:iv:encrypted
- New 4-part format:
salt:iv:encrypted:authTag
- Both formats are supported during transition
Key Management:
- Encryption key must be consistent across all application instances
- Key changes require data re-encryption
- Missing or wrong key causes decryption failures
๐ Monitoring and Auditingโ
Audit Log Integrationโ
Encryption operations are automatically audited through existing audit logging:
import { logAudit } from '../utils/audit';
// Audit sensitive data access
await logAudit({
eventId,
userId,
action: 'view_incident_details',
targetType: 'Incident',
targetId: incidentId
});
Performance Monitoringโ
Monitor encryption impact on performance:
// Track encryption overhead
const start = Date.now();
const encrypted = encryptField(sensitiveData);
const encryptionTime = Date.now() - start;
if (encryptionTime > 10) { // Log slow encryption
logger.warn('Slow encryption detected', {
encryptionTime,
dataLength: sensitiveData.length
});
}
๐งช Testing Strategyโ
Unit Testsโ
Test encryption utilities independently:
describe('Encryption Utilities', () => {
it('should encrypt and decrypt data correctly', () => {
const original = 'sensitive data';
const encrypted = encryptField(original);
const decrypted = decryptField(encrypted);
expect(encrypted).not.toBe(original);
expect(isEncrypted(encrypted)).toBe(true);
expect(decrypted).toBe(original);
});
});
Integration Testsโ
Test service-level encryption:
describe('IncidentService Integration', () => {
it('should handle end-to-end encryption workflow', async () => {
// Create with sensitive data
const incident = await incidentService.createIncident({
description: 'Sensitive incident details'
});
// Verify returned data is decrypted
expect(incident.description).toBe('Sensitive incident details');
// Verify stored data is encrypted
const stored = await prisma.incident.findUnique({
where: { id: incident.id }
});
expect(isEncrypted(stored.description)).toBe(true);
// Verify retrieval returns decrypted data
const retrieved = await incidentService.getIncidentById(incident.id);
expect(retrieved.description).toBe('Sensitive incident details');
});
});
For production deployment and key management, see the Admin Security Guide.