- Zero-knowledge encryption for ALL sensitive data + metadata - Blood pressure example: value + type + unit ALL encrypted - 9 collections: users, families, profiles, health_data, lab_results, medications, appointments, shares, refresh_tokens - Client-side encryption (AES-256-GCM, PBKDF2) - Server NEVER decrypts data - Privacy-preserving queries (plaintext fields: userId, profileId, familyId, date, tags) - Tagging system for encrypted data search - Date range queries (plaintext dates) Key principle: - Both value AND metadata encrypted (e.g., "blood_pressure" + "120/80") - No plaintext metadata leaks - Server stores ONLY encrypted data Updated tech stack decisions with MongoDB schema All major research complete (Rust, Mobile, Web, State, Auth, Database) Next: Backend development (Axum + MongoDB)
1089 lines
28 KiB
Markdown
1089 lines
28 KiB
Markdown
# MongoDB Schema Design for Normogen
|
|
|
|
**Date**: 2026-02-14
|
|
**Focus**: Zero-knowledge encryption for all sensitive data AND metadata
|
|
**Database**: MongoDB 6.0+
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
1. [Zero-Knowledge Encryption Requirements](#zero-knowledge-encryption-requirements)
|
|
2. [Database Architecture Overview](#database-architecture-overview)
|
|
3. [Collection Schemas](#collection-schemas)
|
|
4. [Encryption Strategy](#encryption-strategy)
|
|
5. [Indexing Strategy](#indexing-strategy)
|
|
6. [Privacy-Preserving Queries](#privacy-preserving-queries)
|
|
7. [Data Migration](#data-migration)
|
|
8. [Performance Considerations](#performance-considerations)
|
|
---
|
|
|
|
## Zero-Knowledge Encryption Requirements
|
|
|
|
### Core Principle
|
|
**ALL sensitive data AND metadata must be encrypted client-side before reaching MongoDB.**
|
|
|
|
### What Must Be Encrypted
|
|
|
|
#### Health Data (Value + Metadata)
|
|
```javascript
|
|
// Blood pressure reading - BOTH value AND metadata encrypted
|
|
{
|
|
value: "10", // Encrypted ❌
|
|
metadata: {
|
|
type: "blood_pressure", // Encrypted ❌
|
|
unit: "mmHg" // Encrypted ❌
|
|
}
|
|
}
|
|
|
|
// After encryption (stored in MongoDB)
|
|
{
|
|
value: {
|
|
encrypted: true,
|
|
data: "a1b2c3d4...",
|
|
iv: "e5f6g7h8...",
|
|
authTag: "i9j0k1l2..."
|
|
},
|
|
metadata: {
|
|
encrypted: true,
|
|
data: "m3n4o5p6...",
|
|
iv: "q7r8s9t0...",
|
|
authTag: "u1v2w3x4..."
|
|
}
|
|
}
|
|
```
|
|
|
|
#### What Can Be Plaintext
|
|
```javascript
|
|
// ONLY non-sensitive, non-identifying fields
|
|
{
|
|
userId: "user-123", // Plaintext (for queries) ✅
|
|
familyId: "family-456", // Plaintext (for family queries) ✅
|
|
profileId: "profile-789", // Plaintext (for profile queries) ✅
|
|
createdAt: ISODate("2026-02-14"), // Plaintext (for sorting) ✅
|
|
updatedAt: ISODate("2026-02-14"), // Plaintext (for sorting) ✅
|
|
|
|
// ALL health data encrypted ❌
|
|
healthData: [
|
|
{
|
|
encrypted: true,
|
|
data: "...",
|
|
iv: "...",
|
|
authTag: "..."
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
#### Why Metadata Must Be Encrypted
|
|
|
|
**Problem**: If metadata is plaintext, attackers can infer sensitive information.
|
|
|
|
**Example**:
|
|
```javascript
|
|
// BAD: Metadata plaintext (leaks information)
|
|
{
|
|
userId: "user-123",
|
|
healthData: [
|
|
{
|
|
type: "hiv_test", // Reveals HIV status
|
|
result: "positive", // Reveals HIV status
|
|
date: "2026-02-14", // Reveals when tested
|
|
doctor: "Dr. Smith", // Reveals healthcare provider
|
|
}
|
|
]
|
|
}
|
|
|
|
// GOOD: Metadata encrypted (privacy-preserving)
|
|
{
|
|
userId: "user-123",
|
|
healthData: [
|
|
{
|
|
encrypted: true,
|
|
data: "a1b2c3d4...", // Encrypted: type + result + date + doctor
|
|
iv: "e5f6g7h8...",
|
|
authTag: "i9j0k1l2..."
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Database Architecture Overview
|
|
|
|
### Database Structure
|
|
```
|
|
normogen (database)
|
|
├── users (collection)
|
|
├── families (collection)
|
|
├── profiles (collection)
|
|
├── health_data (collection)
|
|
├── lab_results (collection)
|
|
├── medications (collection)
|
|
├── appointments (collection)
|
|
├── shares (collection)
|
|
└── refresh_tokens (collection)
|
|
```
|
|
|
|
### Data Flow
|
|
```
|
|
Client (React Native / React)
|
|
├── User enters data
|
|
├── Client encrypts data (AES-256-GCM, PBKDF2)
|
|
├── Client sends encrypted data to server
|
|
│
|
|
Server (Axum / Rust)
|
|
├── Server receives encrypted data
|
|
├── Server NEVER decrypts data
|
|
├── Server stores encrypted data in MongoDB
|
|
│
|
|
MongoDB
|
|
├── Stores ONLY encrypted data
|
|
├── No plaintext sensitive data
|
|
└── Zero-knowledge architecture maintained
|
|
```
|
|
|
|
---
|
|
|
|
## Collection Schemas
|
|
|
|
### 1. Users Collection
|
|
|
|
**Purpose**: User authentication and account data
|
|
|
|
**Schema**:
|
|
```javascript
|
|
{
|
|
_id: ObjectId("..."),
|
|
|
|
// Plaintext fields (for authentication)
|
|
userId: { type: String, unique: true, required: true },
|
|
email: { type: String, index: true, required: true }, // Plaintext for login
|
|
passwordHash: { type: String, required: true }, // Plaintext (bcrypt hash)
|
|
tokenVersion: { type: Number, default: 1 }, // Plaintext (for JWT revocation)
|
|
|
|
// Encrypted fields (zero-knowledge)
|
|
encryptedRecoveryPhrase: {
|
|
encrypted: true,
|
|
data: String, // Encrypted recovery phrase
|
|
iv: String,
|
|
authTag: String
|
|
},
|
|
|
|
// Family relationships
|
|
familyId: { type: String, index: true }, // Plaintext (for family queries)
|
|
familyRole: { type: String }, // Plaintext (parent, child, elderly)
|
|
permissions: [String], // Plaintext (for JWT permissions)
|
|
|
|
// Metadata (plaintext)
|
|
createdAt: { type: Date, default: Date.now },
|
|
updatedAt: { type: Date, default: Date.now },
|
|
lastLoginAt: Date
|
|
}
|
|
```
|
|
|
|
**Encryption Notes**:
|
|
- ✅ **Plaintext**: `userId`, `email`, `passwordHash`, `tokenVersion`, `familyId`, `familyRole`, `permissions`
|
|
- ❌ **Encrypted**: `encryptedRecoveryPhrase`
|
|
|
|
**Indexes**:
|
|
```javascript
|
|
// Indexes for performance
|
|
db.users.createIndex({ userId: 1 }, { unique: true });
|
|
db.users.createIndex({ email: 1 }, { unique: true });
|
|
db.users.createIndex({ familyId: 1 });
|
|
db.users.createIndex({ createdAt: -1 }); // For sorting
|
|
```
|
|
|
|
---
|
|
|
|
### 2. Families Collection
|
|
|
|
**Purpose**: Family structure and relationships
|
|
|
|
**Schema**:
|
|
```javascript
|
|
{
|
|
_id: ObjectId("..."),
|
|
|
|
// Plaintext fields (for queries)
|
|
familyId: { type: String, unique: true, required: true },
|
|
createdAt: { type: Date, default: Date.now },
|
|
updatedAt: { type: Date, default: Date.now },
|
|
|
|
// Encrypted family name (privacy-preserving)
|
|
familyName: {
|
|
encrypted: true,
|
|
data: String, // Encrypted family name
|
|
iv: String,
|
|
authTag: String
|
|
},
|
|
|
|
// Encrypted family metadata
|
|
familyMetadata: {
|
|
encrypted: true,
|
|
data: String, // Encrypted metadata (address, phone, etc.)
|
|
iv: String,
|
|
authTag: String
|
|
},
|
|
|
|
// Plaintext family structure (for queries)
|
|
members: [
|
|
{
|
|
userId: String, // Plaintext (for queries)
|
|
profileId: String, // Plaintext (for queries)
|
|
role: String, // Plaintext (parent, child, elderly)
|
|
permissions: [String] // Plaintext (for JWT)
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**Encryption Notes**:
|
|
- ✅ **Plaintext**: `familyId`, `members[*].userId`, `members[*].profileId`, `members[*].role`, `members[*].permissions`
|
|
- ❌ **Encrypted**: `familyName`, `familyMetadata`
|
|
|
|
---
|
|
|
|
### 3. Profiles Collection
|
|
|
|
**Purpose**: Person profiles (users can have multiple profiles)
|
|
|
|
**Schema**:
|
|
```javascript
|
|
{
|
|
_id: ObjectId("..."),
|
|
|
|
// Plaintext fields (for queries)
|
|
profileId: { type: String, unique: true, required: true },
|
|
userId: { type: String, index: true, required: true }, // Owner
|
|
familyId: { type: String, index: true },
|
|
profileType: { type: String }, // self, child, elderly, pet
|
|
|
|
// Encrypted profile data (privacy-preserving)
|
|
profileName: {
|
|
encrypted: true,
|
|
data: String, // Encrypted name (e.g., "John Doe")
|
|
iv: String,
|
|
authTag: String
|
|
},
|
|
|
|
// Encrypted profile metadata
|
|
profileMetadata: {
|
|
encrypted: true,
|
|
data: String, // Encrypted metadata (birth date, gender, etc.)
|
|
iv: String,
|
|
authTag: String
|
|
},
|
|
|
|
// Metadata (plaintext)
|
|
createdAt: { type: Date, default: Date.now },
|
|
updatedAt: { type: Date, default: Date.now }
|
|
}
|
|
```
|
|
|
|
**Encryption Notes**:
|
|
- ✅ **Plaintext**: `profileId`, `userId`, `familyId`, `profileType`
|
|
- ❌ **Encrypted**: `profileName`, `profileMetadata`
|
|
|
|
---
|
|
|
|
### 4. Health Data Collection
|
|
|
|
**Purpose**: Health records (weight, height, blood pressure, etc.)
|
|
|
|
**Schema**:
|
|
```javascript
|
|
{
|
|
_id: ObjectId("..."),
|
|
|
|
// Plaintext fields (for queries)
|
|
healthDataId: { type: String, unique: true, required: true },
|
|
userId: { type: String, index: true, required: true }, // Owner
|
|
profileId: { type: String, index: true, required: true }, // Subject
|
|
familyId: { type: String, index: true },
|
|
|
|
// Encrypted health data (value + metadata)
|
|
healthData: [
|
|
{
|
|
// Encrypted value + metadata
|
|
encrypted: true,
|
|
data: String, // Encrypted: { value: 10, type: "blood_pressure", unit: "mmHg", date: "2026-02-14" }
|
|
iv: String,
|
|
authTag: String
|
|
}
|
|
],
|
|
|
|
// Metadata (plaintext)
|
|
createdAt: { type: Date, default: Date.now },
|
|
updatedAt: { type: Date, default: Date.now },
|
|
dataSource: String // Plaintext (e.g., "manual", "healthKit", "googleFit")
|
|
}
|
|
```
|
|
|
|
**Example: Blood Pressure Reading**:
|
|
```javascript
|
|
// Client-side data structure
|
|
const healthData = {
|
|
value: "120/80",
|
|
type: "blood_pressure",
|
|
unit: "mmHg",
|
|
date: "2026-02-14T10:30:00Z",
|
|
notes: "After morning coffee"
|
|
};
|
|
|
|
// Client encrypts healthData
|
|
const encryptedHealthData = encrypt(healthData, userKey);
|
|
|
|
// Stored in MongoDB
|
|
{
|
|
_id: ObjectId("..."),
|
|
healthDataId: "health-123",
|
|
userId: "user-456",
|
|
profileId: "profile-789",
|
|
familyId: "family-012",
|
|
|
|
// Encrypted (value + metadata)
|
|
healthData: [
|
|
{
|
|
encrypted: true,
|
|
data: "a1b2c3d4...", // Contains: value, type, unit, date, notes
|
|
iv: "e5f6g7h8...",
|
|
authTag: "i9j0k1l2..."
|
|
}
|
|
],
|
|
|
|
// Metadata (plaintext)
|
|
createdAt: ISODate("2026-02-14T10:30:00Z"),
|
|
updatedAt: ISODate("2026-02-14T10:30:00Z"),
|
|
dataSource: "healthKit"
|
|
}
|
|
```
|
|
|
|
**Encryption Notes**:
|
|
- ✅ **Plaintext**: `healthDataId`, `userId`, `profileId`, `familyId`, `createdAt`, `updatedAt`, `dataSource`
|
|
- ❌ **Encrypted**: `healthData[*]` (value + metadata)
|
|
|
|
---
|
|
|
|
### 5. Lab Results Collection
|
|
|
|
**Purpose**: Lab test results (imported via QR code)
|
|
|
|
**Schema**:
|
|
```javascript
|
|
{
|
|
_id: ObjectId("..."),
|
|
|
|
// Plaintext fields (for queries)
|
|
labResultId: { type: String, unique: true, required: true },
|
|
userId: { type: String, index: true, required: true }, // Owner
|
|
profileId: { type: String, index: true, required: true }, // Subject
|
|
familyId: { type: String, index: true },
|
|
|
|
// Encrypted lab data (value + metadata)
|
|
labData: {
|
|
encrypted: true,
|
|
data: String, // Encrypted: { testType: "blood_test", results: [...], date: "...", lab: "..." }
|
|
iv: String,
|
|
authTag: String
|
|
},
|
|
|
|
// Encrypted lab metadata
|
|
labMetadata: {
|
|
encrypted: true,
|
|
data: String, // Encrypted: { labName: "LabCorp", doctor: "Dr. Smith", address: "..." }
|
|
iv: String,
|
|
authTag: String
|
|
},
|
|
|
|
// Metadata (plaintext)
|
|
createdAt: { type: Date, default: Date.now },
|
|
updatedAt: { type: Date, default: Date.now },
|
|
dataSource: String // Plaintext (e.g., "qr_code", "manual_entry")
|
|
}
|
|
```
|
|
|
|
**Example: Blood Test Results**:
|
|
```javascript
|
|
// Client-side data structure
|
|
const labData = {
|
|
testType: "blood_panel",
|
|
results: [
|
|
{ test: "cholesterol", value: 200, unit: "mg/dL", normalRange: "125-200" },
|
|
{ test: "glucose", value: 95, unit: "mg/dL", normalRange: "70-100" }
|
|
],
|
|
date: "2026-02-14T08:00:00Z",
|
|
lab: "LabCorp",
|
|
doctor: "Dr. Smith",
|
|
notes: "Fasting for 12 hours"
|
|
};
|
|
|
|
// Client encrypts labData + labMetadata
|
|
const encryptedLabData = encrypt(labData, userKey);
|
|
const encryptedLabMetadata = encrypt({ lab: "LabCorp", doctor: "Dr. Smith" }, userKey);
|
|
|
|
// Stored in MongoDB
|
|
{
|
|
_id: ObjectId("..."),
|
|
labResultId: "lab-123",
|
|
userId: "user-456",
|
|
profileId: "profile-789",
|
|
|
|
// Encrypted lab data (value + metadata)
|
|
labData: {
|
|
encrypted: true,
|
|
data: "m3n4o5p6...",
|
|
iv: "q7r8s9t0...",
|
|
authTag: "u1v2w3x4..."
|
|
},
|
|
|
|
// Encrypted lab metadata
|
|
labMetadata: {
|
|
encrypted: true,
|
|
data: "y5z6a7b8...",
|
|
iv: "c9d0e1f2...",
|
|
authTag: "g3h4i5j6..."
|
|
},
|
|
|
|
// Metadata (plaintext)
|
|
createdAt: ISODate("2026-02-14T08:00:00Z"),
|
|
updatedAt: ISODate("2026-02-14T08:00:00Z"),
|
|
dataSource: "qr_code"
|
|
}
|
|
```
|
|
|
|
**Encryption Notes**:
|
|
- ✅ **Plaintext**: `labResultId`, `userId`, `profileId`, `familyId`, `createdAt`, `updatedAt`, `dataSource`
|
|
- ❌ **Encrypted**: `labData` (value + metadata), `labMetadata`
|
|
|
|
---
|
|
|
|
### 6. Medications Collection
|
|
|
|
**Purpose**: Medication tracking and reminders
|
|
|
|
**Schema**:
|
|
```javascript
|
|
{
|
|
_id: ObjectId("..."),
|
|
|
|
// Plaintext fields (for queries)
|
|
medicationId: { type: String, unique: true, required: true },
|
|
userId: { type: String, index: true, required: true }, // Owner
|
|
profileId: { type: String, index: true, required: true }, // Subject
|
|
familyId: { type: String, index: true },
|
|
|
|
// Encrypted medication data (value + metadata)
|
|
medicationData: {
|
|
encrypted: true,
|
|
data: String, // Encrypted: { name: "Aspirin", dosage: "100mg", frequency: "daily", shape: "round" }
|
|
iv: String,
|
|
authTag: String
|
|
},
|
|
|
|
// Encrypted reminder schedule
|
|
reminderSchedule: {
|
|
encrypted: true,
|
|
data: String, // Encrypted: { times: ["08:00", "20:00"], days: ["mon", "tue", "wed", "thu", "fri"] }
|
|
iv: String,
|
|
authTag: String
|
|
},
|
|
|
|
// Metadata (plaintext)
|
|
active: { type: Boolean, default: true },
|
|
createdAt: { type: Date, default: Date.now },
|
|
updatedAt: { type: Date, default: Date.now }
|
|
}
|
|
```
|
|
|
|
**Example: Medication**:
|
|
```javascript
|
|
// Client-side data structure
|
|
const medicationData = {
|
|
name: "Aspirin",
|
|
dosage: "100mg",
|
|
frequency: "daily",
|
|
shape: "round",
|
|
color: "white",
|
|
instructions: "Take with water after meals"
|
|
};
|
|
|
|
const reminderSchedule = {
|
|
times: ["08:00", "20:00"],
|
|
days: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
|
|
notifications: true
|
|
};
|
|
|
|
// Client encrypts medicationData + reminderSchedule
|
|
const encryptedMedicationData = encrypt(medicationData, userKey);
|
|
const encryptedReminderSchedule = encrypt(reminderSchedule, userKey);
|
|
|
|
// Stored in MongoDB
|
|
{
|
|
_id: ObjectId("..."),
|
|
medicationId: "med-123",
|
|
userId: "user-456",
|
|
profileId: "profile-789",
|
|
|
|
// Encrypted medication data (value + metadata)
|
|
medicationData: {
|
|
encrypted: true,
|
|
data: "k9l0m1n2...",
|
|
iv: "o3p4q5r6...",
|
|
authTag: "s7t8u9v0..."
|
|
},
|
|
|
|
// Encrypted reminder schedule
|
|
reminderSchedule: {
|
|
encrypted: true,
|
|
data: "w1x2y3z4...",
|
|
iv: "a5b6c7d8...",
|
|
authTag: "e9f0g1h2..."
|
|
},
|
|
|
|
// Metadata (plaintext)
|
|
active: true,
|
|
createdAt: ISODate("2026-02-14T10:00:00Z"),
|
|
updatedAt: ISODate("2026-02-14T10:00:00Z")
|
|
}
|
|
```
|
|
|
|
**Encryption Notes**:
|
|
- ✅ **Plaintext**: `medicationId`, `userId`, `profileId`, `familyId`, `active`, `createdAt`, `updatedAt`
|
|
- ❌ **Encrypted**: `medicationData` (value + metadata), `reminderSchedule`
|
|
|
|
---
|
|
|
|
### 7. Appointments Collection
|
|
|
|
**Purpose**: Medical appointments and checkups
|
|
|
|
**Schema**:
|
|
```javascript
|
|
{
|
|
_id: ObjectId("..."),
|
|
|
|
// Plaintext fields (for queries)
|
|
appointmentId: { type: String, unique: true, required: true },
|
|
userId: { type: String, index: true, required: true }, // Owner
|
|
profileId: { type: String, index: true, required: true }, // Subject
|
|
familyId: { type: String, index: true },
|
|
|
|
// Encrypted appointment data (value + metadata)
|
|
appointmentData: {
|
|
encrypted: true,
|
|
data: String, // Encrypted: { type: "checkup", doctor: "Dr. Smith", date: "...", notes: "..." }
|
|
iv: String,
|
|
authTag: String
|
|
},
|
|
|
|
// Encrypted reminder settings
|
|
reminderSettings: {
|
|
encrypted: true,
|
|
data: String, // Encrypted: { reminder: 24, unit: "hours", method: "push_notification" }
|
|
iv: String,
|
|
authTag: String
|
|
},
|
|
|
|
// Metadata (plaintext)
|
|
createdAt: { type: Date, default: Date.now },
|
|
updatedAt: { type: Date, default: Date.now }
|
|
}
|
|
```
|
|
|
|
**Encryption Notes**:
|
|
- ✅ **Plaintext**: `appointmentId`, `userId`, `profileId`, `familyId`, `createdAt`, `updatedAt`
|
|
- ❌ **Encrypted**: `appointmentData` (value + metadata), `reminderSettings`
|
|
|
|
---
|
|
|
|
### 8. Shares Collection
|
|
|
|
**Purpose**: Time-limited access to shared data (from encryption.md)
|
|
|
|
**Schema**:
|
|
```javascript
|
|
{
|
|
_id: ObjectId("..."),
|
|
|
|
// Plaintext fields (for queries)
|
|
shareId: { type: String, unique: true, required: true },
|
|
userId: { type: String, index: true, required: true }, // Owner
|
|
|
|
// References to original data
|
|
documentId: { type: String, required: true },
|
|
collectionName: { type: String, required: true }, // health_data, lab_results, etc.
|
|
|
|
// Encrypted shared data (encrypted with share-specific password)
|
|
encryptedData: {
|
|
encrypted: true,
|
|
data: String, // Encrypted with share-specific password
|
|
iv: String,
|
|
authTag: String
|
|
},
|
|
|
|
// Metadata (plaintext)
|
|
createdAt: { type: Date, default: Date.now },
|
|
expiresAt: { type: Date, index: true },
|
|
accessCount: { type: Number, default: 0 },
|
|
maxAccessCount: { type: Number },
|
|
|
|
// Optional: Additional security
|
|
allowedEmails: [String],
|
|
isRevoked: { type: Boolean, default: false },
|
|
revokedAt: Date
|
|
}
|
|
```
|
|
|
|
**Encryption Notes**:
|
|
- ✅ **Plaintext**: `shareId`, `userId`, `documentId`, `collectionName`, `createdAt`, `expiresAt`, `accessCount`, `maxAccessCount`, `allowedEmails`, `isRevoked`, `revokedAt`
|
|
- ❌ **Encrypted**: `encryptedData` (encrypted with share-specific password)
|
|
|
|
---
|
|
|
|
### 9. Refresh Tokens Collection
|
|
|
|
**Purpose**: JWT refresh token storage (from JWT authentication)
|
|
|
|
**Schema**:
|
|
```javascript
|
|
{
|
|
_id: ObjectId("..."),
|
|
|
|
// Plaintext fields (for queries)
|
|
jti: { type: String, unique: true, required: true }, // JWT ID
|
|
userId: { type: String, index: true, required: true },
|
|
|
|
// Metadata (plaintext)
|
|
createdAt: { type: Date, default: Date.now },
|
|
expiresAt: { type: Date, index: true, required: true },
|
|
revoked: { type: Boolean, default: false },
|
|
revokedAt: Date
|
|
}
|
|
```
|
|
|
|
**Encryption Notes**:
|
|
- ✅ **Plaintext**: `jti`, `userId`, `createdAt`, `expiresAt`, `revoked`, `revokedAt`
|
|
- ❌ **Encrypted**: None (refresh tokens are not sensitive data)
|
|
|
|
---
|
|
|
|
## Encryption Strategy
|
|
|
|
### Client-Side Encryption (Before Sending to Server)
|
|
|
|
**Encryption Flow**:
|
|
```javascript
|
|
// 1. User enters health data
|
|
const healthData = {
|
|
value: "120/80",
|
|
type: "blood_pressure",
|
|
unit: "mmHg",
|
|
date: "2026-02-14T10:30:00Z"
|
|
};
|
|
|
|
// 2. Client derives encryption key from password
|
|
const userKey = await deriveKeyFromPassword(userPassword);
|
|
|
|
// PBKDF2: 100,000 iterations, SHA-256, 32-byte key
|
|
|
|
// 3. Client encrypts health data
|
|
const encryptedHealthData = await encryptData(healthData, userKey);
|
|
// AES-256-GCM: 16-byte IV, auth tag for integrity
|
|
|
|
// 4. Client sends encrypted data to server
|
|
await fetch('/api/health-data', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
userId: 'user-123',
|
|
profileId: 'profile-456',
|
|
familyId: 'family-789',
|
|
healthData: [encryptedHealthData] // Encrypted (value + metadata)
|
|
})
|
|
});
|
|
|
|
// 5. Server stores encrypted data in MongoDB
|
|
// Server NEVER decrypts data
|
|
```
|
|
|
|
### Encryption Implementation (Client-Side)
|
|
|
|
**React Native / React**:
|
|
```typescript
|
|
import * as crypto from 'crypto';
|
|
|
|
// Encrypted field structure
|
|
interface EncryptedField {
|
|
encrypted: true;
|
|
data: string; // Encrypted data
|
|
iv: string; // Initialization vector
|
|
authTag: string; // Authentication tag (AES-256-GCM)
|
|
}
|
|
|
|
// Encrypt data
|
|
async function encryptData(data: any, key: Buffer): Promise<EncryptedField> {
|
|
const iv = crypto.randomBytes(16);
|
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
|
|
let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex');
|
|
encrypted += cipher.final('hex');
|
|
const authTag = cipher.getAuthTag();
|
|
|
|
return {
|
|
encrypted: true,
|
|
data: encrypted,
|
|
iv: iv.toString('hex'),
|
|
authTag: authTag.toString('hex')
|
|
};
|
|
}
|
|
|
|
// Decrypt data
|
|
async function decryptData(encryptedField: EncryptedField, key: Buffer): Promise<any> {
|
|
const decipher = crypto.createDecipheriv(
|
|
'aes-256-gcm',
|
|
key,
|
|
Buffer.from(encryptedField.iv, 'hex')
|
|
);
|
|
decipher.setAuthTag(Buffer.from(encryptedField.authTag, 'hex'));
|
|
|
|
let decrypted = decipher.update(encryptedField.data, 'hex', 'utf8');
|
|
decrypted += decipher.final('utf8');
|
|
|
|
return JSON.parse(decrypted);
|
|
}
|
|
|
|
// Derive key from password
|
|
async function deriveKeyFromPassword(password: string): Promise<Buffer> {
|
|
const salt = crypto.randomBytes(32);
|
|
return new Promise((resolve, reject) => {
|
|
crypto.pbkdf2(password, salt, 100000, 32, 'sha256', (err, derivedKey) => {
|
|
if (err) reject(err);
|
|
else resolve(derivedKey);
|
|
});
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Indexing Strategy
|
|
|
|
### Principle
|
|
**Index ONLY plaintext fields** (for performance and privacy).
|
|
|
|
### Indexes per Collection
|
|
|
|
#### Users
|
|
```javascript
|
|
db.users.createIndex({ userId: 1 }, { unique: true });
|
|
db.users.createIndex({ email: 1 }, { unique: true });
|
|
db.users.createIndex({ familyId: 1 });
|
|
db.users.createIndex({ createdAt: -1 });
|
|
```
|
|
|
|
#### Families
|
|
```javascript
|
|
db.families.createIndex({ familyId: 1 }, { unique: true });
|
|
db.families.createIndex({ createdAt: -1 });
|
|
```
|
|
|
|
#### Profiles
|
|
```javascript
|
|
db.profiles.createIndex({ profileId: 1 }, { unique: true });
|
|
db.profiles.createIndex({ userId: 1 });
|
|
db.profiles.createIndex({ familyId: 1 });
|
|
db.profiles.createIndex({ createdAt: -1 });
|
|
```
|
|
|
|
#### Health Data
|
|
```javascript
|
|
db.health_data.createIndex({ healthDataId: 1 }, { unique: true });
|
|
db.health_data.createIndex({ userId: 1 });
|
|
db.health_data.createIndex({ profileId: 1 });
|
|
db.health_data.createIndex({ familyId: 1 });
|
|
db.health_data.createIndex({ createdAt: -1 });
|
|
db.health_data.createIndex({ updatedAt: -1 });
|
|
```
|
|
|
|
#### Lab Results
|
|
```javascript
|
|
db.lab_results.createIndex({ labResultId: 1 }, { unique: true });
|
|
db.lab_results.createIndex({ userId: 1 });
|
|
db.lab_results.createIndex({ profileId: 1 });
|
|
db.lab_results.createIndex({ familyId: 1 });
|
|
db.lab_results.createIndex({ createdAt: -1 });
|
|
db.lab_results.createIndex({ updatedAt: -1 });
|
|
```
|
|
|
|
#### Medications
|
|
```javascript
|
|
db.medications.createIndex({ medicationId: 1 }, { unique: true });
|
|
db.medications.createIndex({ userId: 1 });
|
|
db.medications.createIndex({ profileId: 1 });
|
|
db.medications.createIndex({ familyId: 1 });
|
|
db.medications.createIndex({ active: 1 });
|
|
db.medications.createIndex({ createdAt: -1 });
|
|
db.medications.createIndex({ updatedAt: -1 });
|
|
```
|
|
|
|
#### Appointments
|
|
```javascript
|
|
db.appointments.createIndex({ appointmentId: 1 }, { unique: true });
|
|
db.appointments.createIndex({ userId: 1 });
|
|
db.appointments.createIndex({ profileId: 1 });
|
|
db.appointments.createIndex({ familyId: 1 });
|
|
db.appointments.createIndex({ createdAt: -1 });
|
|
db.appointments.createIndex({ updatedAt: -1 });
|
|
```
|
|
|
|
#### Shares
|
|
```javascript
|
|
db.shares.createIndex({ shareId: 1 }, { unique: true });
|
|
db.shares.createIndex({ userId: 1 });
|
|
db.shares.createIndex({ expiresAt: 1 }); // For TTL index
|
|
db.shares.createIndex({ createdAt: -1 });
|
|
db.shares.createIndex({ isRevoked: 1 });
|
|
```
|
|
|
|
#### Refresh Tokens
|
|
```javascript
|
|
db.refresh_tokens.createIndex({ jti: 1 }, { unique: true });
|
|
db.refresh_tokens.createIndex({ userId: 1 });
|
|
db.refresh_tokens.createIndex({ expiresAt: 1 }); // For TTL index
|
|
db.refresh_tokens.createIndex({ revoked: 1 });
|
|
```
|
|
|
|
### TTL Indexes (Auto-Expiration)
|
|
|
|
```javascript
|
|
// Shares: Auto-delete expired shares
|
|
db.shares.createIndex(
|
|
{ expiresAt: 1 },
|
|
{ expireAfterSeconds: 0 } // Delete immediately after expiration
|
|
);
|
|
|
|
// Refresh Tokens: Auto-delete expired tokens
|
|
db.refresh_tokens.createIndex(
|
|
{ expiresAt: 1 },
|
|
{ expireAfterSeconds: 0 } // Delete immediately after expiration
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## Privacy-Preserving Queries
|
|
|
|
### Challenge
|
|
**How to query encrypted data without decrypting it?**
|
|
|
|
### Solutions
|
|
|
|
#### 1. Plaintext Queries (Recommended)
|
|
|
|
**Query by plaintext fields only**:
|
|
```javascript
|
|
// GOOD: Query by plaintext fields
|
|
const healthData = await db.health_data.find({
|
|
userId: 'user-123', // Plaintext ✅
|
|
profileId: 'profile-456', // Plaintext ✅
|
|
familyId: 'family-789' // Plaintext ✅
|
|
}).toArray();
|
|
|
|
// Client decrypts healthData[i].healthData[j]
|
|
```
|
|
|
|
#### 2. Tagging System (Encrypted Search)
|
|
|
|
**Client adds searchable tags to encrypted data**:
|
|
```javascript
|
|
// Client adds tags to encrypted data
|
|
const healthData = {
|
|
value: "120/80",
|
|
type: "blood_pressure",
|
|
unit: "mmHg",
|
|
date: "2026-02-14T10:30:00Z",
|
|
tags: ["cardio", "daily"] // Plaintext tags (for client-side search)
|
|
};
|
|
|
|
// Stored in MongoDB
|
|
{
|
|
_id: ObjectId("..."),
|
|
healthDataId: "health-123",
|
|
userId: "user-123",
|
|
profileId: "profile-456",
|
|
|
|
// Encrypted health data
|
|
healthData: [
|
|
{
|
|
encrypted: true,
|
|
data: "a1b2c3d4...",
|
|
iv: "e5f6g7h8...",
|
|
authTag: "i9j0k1l2..."
|
|
}
|
|
],
|
|
|
|
// Plaintext tags (for client-side search)
|
|
tags: ["cardio", "daily"]
|
|
}
|
|
|
|
// Query by tags
|
|
const healthData = await db.health_data.find({
|
|
userId: 'user-123',
|
|
tags: { $in: ['cardio', 'daily'] } // Plaintext tags ✅
|
|
}).toArray();
|
|
|
|
// Client decrypts healthData[i].healthData[j]
|
|
```
|
|
|
|
#### 3. Date Range Queries (Plaintext Dates)
|
|
|
|
**Store dates as plaintext** (for range queries):
|
|
```javascript
|
|
// Client encrypts health data BUT stores date as plaintext
|
|
const healthData = {
|
|
value: "120/80",
|
|
type: "blood_pressure",
|
|
unit: "mmHg",
|
|
date: "2026-02-14T10:30:00Z" // Plaintext date ✅
|
|
};
|
|
|
|
// Stored in MongoDB
|
|
{
|
|
_id: ObjectId("..."),
|
|
healthDataId: "health-123",
|
|
userId: "user-123",
|
|
profileId: "profile-456",
|
|
|
|
// Plaintext date (for range queries)
|
|
date: ISODate("2026-02-14T10:30:00Z"), // Plaintext ✅
|
|
|
|
// Encrypted health data (without date)
|
|
healthData: [
|
|
{
|
|
encrypted: true,
|
|
data: "a1b2c3d4...", // Encrypted: { value, type, unit } (no date)
|
|
iv: "e5f6g7h8...",
|
|
authTag: "i9j0k1l2..."
|
|
}
|
|
]
|
|
}
|
|
|
|
// Query by date range
|
|
const healthData = await db.health_data.find({
|
|
userId: 'user-123',
|
|
date: {
|
|
$gte: ISODate("2026-02-01T00:00:00Z"),
|
|
$lte: ISODate("2026-02-28T23:59:59Z")
|
|
}
|
|
}).toArray();
|
|
|
|
// Client decrypts healthData[i].healthData[j]
|
|
```
|
|
|
|
---
|
|
|
|
## Data Migration
|
|
|
|
### Key Rotation
|
|
|
|
**Strategy**: Re-encrypt all data with new key
|
|
|
|
```javascript
|
|
// 1. User changes password
|
|
const newPassword = "new-secure-password";
|
|
const newKey = await deriveKeyFromPassword(newPassword);
|
|
|
|
// 2. Client fetches all encrypted data
|
|
const healthData = await db.health_data.find({ userId: 'user-123' }).toArray();
|
|
// 3. Client decrypts with old key
|
|
const decryptedHealthData = healthData.map(d => ({
|
|
_id: d._id,
|
|
decrypted: await decryptData(d.healthData[0], oldKey)
|
|
}));
|
|
// 4. Client re-encrypts with new key
|
|
const reencryptedHealthData = decryptedHealthData.map(d => ({
|
|
_id: d._id,
|
|
encrypted: await encryptData(d.decrypted, newKey)
|
|
}));
|
|
// 5. Client sends re-encrypted data to server
|
|
for (const d of reencryptedHealthData) {
|
|
await db.health_data.updateOne(
|
|
{ _id: d._id },
|
|
{ $set: { healthData: [d.encrypted], updatedAt: new Date() } }
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Performance Considerations
|
|
|
|
### 1. Encryption Overhead
|
|
- **Client-side encryption**: Minimal (10-50ms per encryption)
|
|
- **Server-side storage**: No overhead (encrypted data stored directly)
|
|
- **Network transfer**: Encrypted data is 20-30% larger than plaintext
|
|
|
|
|
|
### 2. Index Size
|
|
- **Plaintext indexes**: Smaller (only plaintext fields)
|
|
- **Encrypted data**: Not indexed (no performance impact)
|
|
|
|
### 3. Query Performance
|
|
- **Plaintext queries**: Fast (indexed fields)
|
|
- **Tag-based queries**: Fast (indexed plaintext tags)
|
|
- **Date range queries**: Fast (indexed plaintext dates)
|
|
- **Encrypted data queries**: Not possible (client-side filtering)
|
|
|
|
### 4. Storage Size
|
|
- **Encrypted data**: 20-30% larger than plaintext
|
|
- **MongoDB storage**: No impact (stores binary data)
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
### Zero-Knowledge Encryption
|
|
- ✅ **Client-side encryption**: All sensitive data encrypted before reaching server
|
|
- ✅ **Metadata encryption**: Health data metadata (type, unit, etc.) also encrypted
|
|
- ✅ **Plaintext queries**: Query by plaintext fields (userId, profileId, familyId, date, tags)
|
|
- ✅ **Server blindness**: Server stores ONLY encrypted data, never decrypts
|
|
|
|
### Collections
|
|
- ✅ **Users**: Authentication, profiles, family relationships
|
|
- ✅ **Families**: Family structure, encrypted family name/metadata
|
|
- ✅ **Profiles**: Person profiles, encrypted profile name/metadata
|
|
- ✅ **Health Data**: Encrypted health records (value + metadata)
|
|
- ✅ **Lab Results**: Encrypted lab data (value + metadata)
|
|
- ✅ **Medications**: Encrypted medication data + reminders
|
|
- ✅ **Appointments**: Encrypted appointment data + reminders
|
|
- ✅ **Shares**: Time-limited access to shared data
|
|
- ✅ **Refresh Tokens**: JWT refresh token storage
|
|
|
|
### Privacy Preserved
|
|
- ✅ **Blood pressure**: Value + type + unit + date encrypted
|
|
- ✅ **HIV test**: Test type + result + date + doctor encrypted
|
|
- ✅ **Cholesterol**: Test type + result + date + lab encrypted
|
|
- ✅ **All health data**: Value + metadata encrypted
|
|
---
|
|
|
|
## Next Steps
|
|
|
|
1. Create MongoDB indexes
|
|
2. Implement client-side encryption (React Native + React)
|
|
3. Implement server-side API (Axum + MongoDB)
|
|
4. Test encryption flow
|
|
5. Test data migration (key rotation)
|
|
6. Test privacy-preserving queries
|
|
7. Performance testing
|
|
|
|
---
|
|
|
|
## References
|
|
|
|
- [Normogen Encryption Guide](../encryption.md)
|
|
- [JWT Authentication Research](./2026-02-14-jwt-authentication-research.md)
|
|
- [Technology Stack Decisions](./2026-02-14-tech-stack-decision.md)
|
|
- [MongoDB Documentation](https://docs.mongodb.com/)
|
|
- [AES-256-GCM](https://en.wikipedia.org/wiki/Galois/Counter_Mode)
|
|
- [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2)
|