Encryption Configuration Guide for Wellbeing Mapper
This guide provides detailed instructions for configuring the RSA+AES hybrid encryption system used by the Wellbeing Mapper app for secure research data transmission.
Encryption Overview
The Wellbeing Mapper app uses a hybrid encryption approach that combines the security of RSA asymmetric encryption with the performance of AES symmetric encryption:
- AES-256-GCM: Used to encrypt the actual data payload (fast, efficient for large data)
- RSA-4096-OAEP: Used to encrypt the AES key (secure key exchange)
- SHA-256: Used for hashing and OAEP padding
Data Flow
Participant Data → AES-256-GCM Encryption → RSA-4096 Key Encryption → Base64 Encoding → HTTPS Upload
↓ ↓ ↓ ↓
Survey Responses Random AES Key Encrypted with Secure Transit
Location Tracks Generated Fresh Research Team's to Server
Demographics for Each Upload Public Key
Images (Base64)
Image Encryption
Survey images are processed and encrypted as part of the survey data:
- Local Storage: Images remain as files on the device (file paths stored in database)
- Transmission Encryption: During upload, images are:
- Read from local filesystem
- Converted to Base64 encoding
- Included in the survey JSON payload
- Encrypted with the rest of the survey data using AES-256-GCM
- Transmitted securely to the research server
This ensures images are encrypted during transmission while maintaining efficient local storage.
layout: default title: Encryption Setup description: Data security implementation for the Wellbeing Mapper —
Wellbeing Mapper - Encryption and Security Setup
Step 1: Generate RSA Key Pairs
# Navidate to the directory where you want to store the keys
# Generate 4096-bit RSA private key. Choose and confirm password when prompted
openssl genpkey -algorithm RSA -aes256 -out private_key.pem -pkeyopt rsa_keygen_bits:4096
# Extract public key
openssl rsa -pubout -in private_key.pem -out public_key.pem
# Verify key generation
openssl rsa -in private_key.pem -noout -text | head -20
# Set secure permissions
chmod 600 private_key.pem
chmod 644 public_key.pem
Key Verification
# Test encryption/decryption with generated keys
echo "Test message" | openssl rsautl -encrypt -pubin -inkey public_key.pem | openssl rsautl -decrypt -inkey private_key.pem
Step 2: Configure Mobile App
Update Data Upload Service
Edit /lib/services/data_upload_service.dart and replace the placeholder public keys:
static const Map<String, ServerConfig> _serverConfigs = {
'barcelona': ServerConfig(
baseUrl: 'https://barcelona-research.your-domain.com',
uploadEndpoint: '/api/v1/participant-data',
publicKey: '''-----BEGIN PUBLIC KEY-----
YOUR_PUBLIC_KEY
-----END PUBLIC KEY-----''',
),
'gauteng': ServerConfig(
baseUrl: 'https://gauteng-research.your-domain.com',
uploadEndpoint: '/api/v1/participant-data',
publicKey: '''-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0987654321fedcba...
[PASTE YOUR GAUTENG PUBLIC KEY CONTENT HERE - ALL LINES]
...0987654321fedcba0987654321fedcba0987654321fedcba0987654321fedcba
-----END PUBLIC KEY-----''',
),
};
Get Public Key Content
To get the exact content to paste into the app:
# For Barcelona
cat /secure/keys/barcelona/barcelona_public_key.pem
# For Gauteng
cat /secure/keys/gauteng/gauteng_public_key.pem
Copy the entire content including the -----BEGIN PUBLIC KEY----- and -----END PUBLIC KEY----- lines.
Validate Configuration
After updating the app configuration, validate the keys are properly formatted:
// Add this test to your app's test suite
void testEncryptionKeys() {
final barcelonaConfig = DataUploadService._serverConfigs['barcelona']!;
final gautengConfig = DataUploadService._serverConfigs['gauteng']!;
// Test that keys can be parsed
expect(() => RSAKeyParser().parse(barcelonaConfig.publicKey), returnsNormally);
expect(() => RSAKeyParser().parse(gautengConfig.publicKey), returnsNormally);
print('✅ All public keys are valid');
}
Step 3: Server-Side Decryption Setup
Private Key Storage
Store private keys securely on your servers:
# On Barcelona server
sudo mkdir -p /etc/wellbeing-research/keys
sudo cp barcelona_private_key.pem /etc/wellbeing-research/keys/
sudo chown research-app:research-app /etc/wellbeing-research/keys/barcelona_private_key.pem
sudo chmod 600 /etc/wellbeing-research/keys/barcelona_private_key.pem
# On Gauteng server
sudo mkdir -p /etc/wellbeing-research/keys
sudo cp gauteng_private_key.pem /etc/wellbeing-research/keys/
sudo chown research-app:research-app /etc/wellbeing-research/keys/gauteng_private_key.pem
sudo chmod 600 /etc/wellbeing-research/keys/gauteng_private_key.pem
Decryption Implementation (Node.js)
const crypto = require('crypto');
const fs = require('fs');
class DataDecryptor {
constructor(privateKeyPath) {
this.privateKey = fs.readFileSync(privateKeyPath, 'utf8');
}
decryptUpload(encryptedPayload, encryptionMetadata) {
try {
// Parse the hybrid encrypted data
// Format: base64(rsa_encrypted_aes_key)|base64(aes_encrypted_data)
const [encryptedAESKeyB64, encryptedDataB64] = encryptedPayload.split('|');
if (!encryptedAESKeyB64 || !encryptedDataB64) {
throw new Error('Invalid encrypted payload format');
}
// Step 1: Decrypt AES key with RSA private key
const encryptedAESKey = Buffer.from(encryptedAESKeyB64, 'base64');
const aesKey = crypto.privateDecrypt(
{
key: this.privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
encryptedAESKey
);
// Step 2: Decrypt data with AES-256-GCM
const iv = Buffer.from(encryptionMetadata.iv, 'base64');
const encryptedData = Buffer.from(encryptedDataB64, 'base64');
// Split encrypted data and auth tag (last 16 bytes)
const authTag = encryptedData.slice(-16);
const ciphertext = encryptedData.slice(0, -16);
const decipher = crypto.createDecipherGCM('aes-256-gcm', aesKey);
decipher.setIV(iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext, null, 'utf8');
decrypted += decipher.final('utf8');
return JSON.parse(decrypted);
} catch (error) {
throw new Error(`Decryption failed: ${error.message}`);
}
}
}
// Usage example
const barcelonaDecryptor = new DataDecryptor('/etc/wellbeing-research/keys/barcelona_private_key.pem');
const gautengDecryptor = new DataDecryptor('/etc/wellbeing-research/keys/gauteng_private_key.pem');
// In your upload handler
app.post('/api/v1/participant-data', async (req, res) => {
const { researchSite, encryptedData, encryptionMetadata } = req.body;
try {
const decryptor = researchSite === 'barcelona' ? barcelonaDecryptor : gautengDecryptor;
const decryptedData = decryptor.decryptUpload(encryptedData, encryptionMetadata);
// Process decrypted data
await processParticipantData(decryptedData, req.body);
res.json({ success: true, message: 'Data processed successfully' });
} catch (error) {
console.error('Decryption error:', error);
res.status(400).json({ success: false, error: 'Decryption failed' });
}
});
Decrypted Data Structure
The decrypted JSON payload contains:
{
"participantUuid": "uuid-v4-string",
"researchSite": "gauteng",
"uploadTimestamp": "2025-07-23T10:30:00Z",
"surveys": [
{
"type": "initial" | "recurring",
"submittedAt": "2025-07-23T10:30:00Z",
"responses": {
"wellbeingScore": 7,
"stressLevel": 3,
"suburb": "City Centre", // Research-site specific
"generalHealth": "good", // Gauteng only
// ... other survey fields
}
}
],
"locationTracks": [
{
"timestamp": "2025-07-23T10:15:00Z",
"latitude": -26.1076,
"longitude": 28.0567,
"accuracy": 10.5
}
]
}
Step 4: Testing the Encryption Pipeline
End-to-End Test Script
Create a test script to verify the entire encryption/decryption pipeline:
// test-encryption.js
const crypto = require('crypto');
const fs = require('fs');
// Test data
const testData = {
participantUuid: '123e4567-e89b-12d3-a456-426614174000',
researchSite: 'barcelona',
uploadTimestamp: new Date().toISOString(),
surveys: [{
type: 'initial',
submittedAt: new Date().toISOString(),
responses: { wellbeingScore: 7, stressLevel: 3 }
}],
locationTracks: [{
timestamp: new Date().toISOString(),
latitude: 41.3851,
longitude: 2.1734,
accuracy: 15.0
}]
};
function testEncryptionDecryption(publicKeyPath, privateKeyPath) {
try {
// Step 1: Generate AES key
const aesKey = crypto.randomBytes(32); // 256-bit key
const iv = crypto.randomBytes(12); // 96-bit IV for GCM
// Step 2: Encrypt data with AES-256-GCM
const cipher = crypto.createCipherGCM('aes-256-gcm', aesKey);
cipher.setIV(iv);
const jsonData = JSON.stringify(testData);
let encrypted = cipher.update(jsonData, 'utf8');
encrypted = Buffer.concat([encrypted, cipher.final()]);
const authTag = cipher.getAuthTag();
const encryptedDataWithTag = Buffer.concat([encrypted, authTag]);
// Step 3: Encrypt AES key with RSA public key
const publicKey = fs.readFileSync(publicKeyPath, 'utf8');
const encryptedAESKey = crypto.publicEncrypt(
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
aesKey
);
// Step 4: Create final payload
const finalPayload = encryptedAESKey.toString('base64') + '|' + encryptedDataWithTag.toString('base64');
console.log('✅ Encryption successful');
console.log('Payload size:', finalPayload.length, 'bytes');
// Step 5: Test decryption
const privateKey = fs.readFileSync(privateKeyPath, 'utf8');
const [encryptedAESKeyB64, encryptedDataB64] = finalPayload.split('|');
// Decrypt AES key
const decryptedAESKey = crypto.privateDecrypt(
{
key: privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
Buffer.from(encryptedAESKeyB64, 'base64')
);
// Decrypt data
const encryptedDataBuffer = Buffer.from(encryptedDataB64, 'base64');
const authTagFromData = encryptedDataBuffer.slice(-16);
const ciphertext = encryptedDataBuffer.slice(0, -16);
const decipher = crypto.createDecipherGCM('aes-256-gcm', decryptedAESKey);
decipher.setIV(iv);
decipher.setAuthTag(authTagFromData);
let decrypted = decipher.update(ciphertext, null, 'utf8');
decrypted += decipher.final('utf8');
const decryptedData = JSON.parse(decrypted);
console.log('✅ Decryption successful');
console.log('Original data matches:', JSON.stringify(testData) === JSON.stringify(decryptedData));
return true;
} catch (error) {
console.error('❌ Test failed:', error.message);
return false;
}
}
// Run tests
console.log('Testing Barcelona encryption...');
testEncryptionDecryption(
'/secure/keys/barcelona/barcelona_public_key.pem',
'/secure/keys/barcelona/barcelona_private_key.pem'
);
console.log('\nTesting Gauteng encryption...');
testEncryptionDecryption(
'/secure/keys/gauteng/gauteng_public_key.pem',
'/secure/keys/gauteng/gauteng_private_key.pem'
);
Run the test:
node test-encryption.js
Mobile App Testing
Add this test to your Flutter test suite:
// test/encryption_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:wellbeing_mapper/services/data_upload_service.dart';
void main() {
group('Encryption Tests', () {
test('can encrypt sample data', () async {
final testData = {
'participantUuid': '123e4567-e89b-12d3-a456-426614174000',
'surveys': [{'wellbeingScore': 7}],
'locationTracks': [{'latitude': 41.3851, 'longitude': 2.1734}]
};
final result = await DataUploadService.encryptData(testData, 'barcelona');
expect(result.success, true);
expect(result.encryptedData, isNotNull);
expect(result.encryptedData!.contains('|'), true); // Should have key|data format
expect(result.encryptionMetadata, isNotNull);
});
test('encryption produces different output each time', () async {
final testData = {'test': 'data'};
final result1 = await DataUploadService.encryptData(testData, 'barcelona');
final result2 = await DataUploadService.encryptData(testData, 'barcelona');
expect(result1.encryptedData, isNot(equals(result2.encryptedData)));
expect(result1.encryptionMetadata!['iv'], isNot(equals(result2.encryptionMetadata!['iv'])));
});
});
}
Step 5: Key Rotation Strategy
Annual Key Rotation
- Generate new key pair:
# Generate new keys with timestamp openssl genrsa -out barcelona_private_key_2026.pem 4096 openssl rsa -in barcelona_private_key_2026.pem -pubout -out barcelona_public_key_2026.pem - Update app configuration with new public key
- Deploy new app version to all participants
- Keep old private key for 6 months to decrypt legacy uploads
- Securely destroy old keys after transition period
Emergency Key Rotation
If keys are compromised:
- Immediately generate new keys
- Emergency app update with new public keys
- Revoke old keys on all servers
- Notify research ethics boards of security incident
Security Best Practices
Key Storage
- Hardware Security Modules (HSM): Use HSM for production private keys
- File Permissions:
600for private keys,644for public keys - Backup Encryption: Encrypt all key backups with strong passphrases
- Access Logging: Log all access to private key files
Operational Security
- Principle of Least Privilege: Only essential personnel access private keys
- Key Escrow: Securely store backup copies with trusted third parties
- Regular Audits: Quarterly security audits of key management procedures
- Incident Response: Documented procedures for suspected key compromise
Monitoring
- Decryption Failures: Alert on unusual decryption failure rates
- Key Usage: Log and monitor private key usage patterns
- Access Attempts: Monitor failed authentication attempts
- System Health: Regular checks of encryption/decryption performance
Troubleshooting
Common Issues
“Invalid key format” error:
- Verify public key includes complete
-----BEGIN/END PUBLIC KEY-----headers - Check for extra whitespace or missing newlines
- Validate key with
openssl rsa -pubin -in key.pem -noout -text
“Decryption failed” error:
- Verify private key matches the public key used for encryption
- Check file permissions on private key (must be readable by app)
- Ensure AES IV is correctly extracted from metadata
“Payload too large” error:
- Check server request size limits (increase if needed)
- Verify base64 encoding is working correctly
- Consider data compression before encryption
Diagnostic Commands
# Verify key pair match
diff <(openssl rsa -in private.pem -pubout) <(cat public.pem)
# Test key encryption/decryption
echo "test" | openssl rsautl -encrypt -pubin -inkey public.pem | openssl rsautl -decrypt -inkey private.pem
# Check key details
openssl rsa -in private.pem -noout -text | grep "Private-Key"
openssl rsa -pubin -in public.pem -noout -text | grep "Public-Key"
Processing Survey Images
When decrypting survey data that contains images, the encrypted_images field contains an array of Base64-encoded image data:
// Example decrypted survey JSON structure with images
{
"type": "biweekly_survey",
"participant_uuid": "123e4567-e89b-12d3-a456-426614174000",
"survey_id": "survey_456",
"data": { /* survey response data */ },
"encrypted_images": [
"{\"filename\":\"image1.jpg\",\"size\":45123,\"data\":\"base64-encoded-image-data\"}",
"{\"filename\":\"image2.jpg\",\"size\":62456,\"data\":\"base64-encoded-image-data\"}"
],
"metadata": {
"has_images": true,
"submission_method": "encrypted_proxy"
}
}
// To extract and save images:
const fs = require('fs');
function extractImages(decryptedSurvey, outputDir) {
if (decryptedSurvey.encrypted_images) {
decryptedSurvey.encrypted_images.forEach((imageJson, index) => {
const imageData = JSON.parse(imageJson);
const imageBuffer = Buffer.from(imageData.data, 'base64');
const filename = `${decryptedSurvey.survey_id}_${imageData.filename}`;
fs.writeFileSync(`${outputDir}/${filename}`, imageBuffer);
console.log(`Extracted: ${filename} (${imageData.size} bytes)`);
});
}
}
Support
For encryption-related issues:
- Check the logs for specific error messages
- Verify key formats using OpenSSL commands
- Test end-to-end with the provided test scripts
- Contact the development team with detailed error information
Remember: Never share private keys in support requests or logs!