Encryption Strategy
This guide explains Restartix's two-level encryption approach, what data is encrypted at which layer, and why certain design decisions were made for compliance and performance.
Table of Contents
- Overview: Two-Level Encryption
- Level 1: Infrastructure Encryption
- Level 2: Application-Level Encryption
- What is NOT App-Level Encrypted (and Why)
- Implementation Details
- Encryption Key Management
- Key Rotation Strategy
- References
Overview: Two-Level Encryption
Restartix uses a defense-in-depth encryption strategy with two distinct layers:
- Infrastructure encryption - Encrypts ALL data at rest and in transit (AWS RDS, S3, TLS)
- Application-level encryption - Adds targeted encryption for high-sensitivity columns (phone numbers, API keys)
This approach satisfies HIPAA's encryption requirements while maintaining database queryability for critical features like patient segments and form filtering.
Why Two Levels?
| Concern | Solution |
|---|---|
| HIPAA requires encryption at rest | Infrastructure encryption covers 100% of data |
| Some fields need extra protection | Application-level encryption for phone, API keys |
| Forms must be queryable for segments | Forms stored in plaintext (protected by infrastructure) |
| Custom fields must be searchable | Custom fields in plaintext (protected by infrastructure) |
| Performance at scale | Selective app-level encryption avoids query overhead |
Key principle: Infrastructure encryption is the HIPAA compliance baseline. Application-level encryption is for fields that require extra protection beyond infrastructure.
Level 1: Infrastructure Encryption
Infrastructure encryption covers ALL data without exception. This satisfies HIPAA §164.312(a)(2)(iv) encryption at rest requirements.
Components
| Component | Encryption Method | Coverage |
|---|---|---|
| AWS RDS (PostgreSQL) | AES-256 encryption at rest | All database storage (tables, indexes, backups, snapshots, logs) |
| AWS S3 | Server-side encryption (SSE-S3) | All uploaded files (form attachments, signatures, documents) |
| TLS in Transit | TLS 1.2+ for all connections | API ↔ Client, API ↔ Database, API ↔ S3, API ↔ Integrations |
What This Means
Every piece of data stored in the system is encrypted at rest, even if not encrypted at the application level. This includes:
- All table data (patients, forms, appointments, custom fields, etc.)
- All JSONB fields (form values, form fields, custom field values)
- All indexes
- All database backups and WAL archives
- All S3 file uploads
Compliance: This satisfies HIPAA's baseline encryption requirement. Even if a disk is physically stolen from AWS, the data is unreadable.
Level 2: Application-Level Encryption
Application-level encryption adds a second layer of protection for specific high-sensitivity fields. These fields are encrypted using AES-256-GCM before being written to the database.
Which Fields Are App-Level Encrypted
| Table | Field | Contains | Why Extra Encryption |
|---|---|---|---|
patient_persons | phone_encrypted | Patient phone number | High-sensitivity PII, not needed for queries |
patient_persons | emergency_contact_phone_encrypted | Emergency contact phone | High-sensitivity PII, not needed for queries |
organization_integrations | api_key_encrypted | Third-party API keys | Credentials require extra protection |
Why These Fields?
Phone numbers:
- High-sensitivity PII (can be used for identity theft, phishing)
- Not needed for database queries or filtering
- Small data volume (one per patient_persons record)
- Extra protection reduces risk of insider threat or credential compromise
API keys:
- Credentials to external systems (Daily.co, webhooks, etc.)
- Compromise could allow unauthorized access to third-party services
- Not needed for queries
- Extra layer protects against database dumps or SQL injection
What is NOT App-Level Encrypted (and Why)
Many fields contain PHI but are stored in plaintext (protected only by infrastructure encryption). This is an intentional design decision based on functionality requirements.
Form Values (JSONB)
Storage: forms.values - JSONB column Encrypted: No (plaintext) Why: Must be queryable for patient segments and filtering
Example use case:
-- Segment: "Patients who answered 'yes' to smoking question"
SELECT patient_id FROM forms
WHERE values->>'smoking_history' = 'yes';If values were app-level encrypted, this query would be impossible. We would have to:
- Fetch all forms
- Decrypt each one in the application
- Filter in memory
This doesn't scale to thousands of patients.
Protection: Infrastructure encryption (RDS AES-256) + RLS (organization isolation) + RBAC (role-based access)
Form Field Definitions (JSONB)
Storage: form_templates.fields - JSONB column Encrypted: No (plaintext) Why: Must be readable for form rendering and validation
Form templates are organization-specific configurations. They don't contain patient data, but they define what questions are asked (which may be sensitive in some contexts).
Protection: Infrastructure encryption + RLS + RBAC
Note: The private flag on a form field controls visibility (specialist-only, hidden from patient PDFs), NOT encryption. See Forms.
Custom Field Values
Storage: custom_field_values.value - TEXT column Encrypted: No (plaintext) Why: Must be queryable for profile searches and segments
Example use case:
-- Search patients by custom field "Preferred Language"
SELECT patient_id FROM custom_field_values
WHERE custom_field_id = 123
AND value ILIKE '%spanish%';Protection: Infrastructure encryption + RLS + RBAC
Patient Names, Emails
Storage: patient_persons.name (TEXT), users.email (TEXT) Encrypted: No (plaintext) Why: Used in every query, displayed in UI, required for sorting/filtering
Encrypting names/emails would make the application unusable:
- Can't sort patients alphabetically
- Can't search by name
- Can't filter by email domain
- Every page load requires decrypting hundreds of records
Protection: Infrastructure encryption + RLS + RBAC + audit logging
Specialist Data
Storage: specialists.name, specialists.email - TEXT columns Encrypted: No (plaintext) Why: Not PHI (staff data, not patient data). Used for scheduling, assignment, UI.
Protection: Infrastructure encryption + RBAC
Implementation Details
Encryption Algorithm
AES-256-GCM (Galois/Counter Mode)
Why GCM?
- Authenticated encryption (prevents tampering)
- Faster than CBC for modern CPUs (parallelizable)
- Built-in integrity check (no need for separate HMAC)
Code Implementation
// internal/pkg/crypto/encryption.go
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
)
type Encryptor struct {
gcm cipher.AEAD
}
// NewEncryptor creates an encryptor with a 32-byte key (from 64 hex chars env var)
func NewEncryptor(key []byte) (*Encryptor, error) {
if len(key) != 32 {
return nil, fmt.Errorf("encryption key must be 32 bytes, got %d", len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create GCM: %w", err)
}
return &Encryptor{gcm: gcm}, nil
}
// Encrypt encrypts plaintext and returns base64-encoded ciphertext.
// Format: nonce + ciphertext (GCM includes auth tag in ciphertext).
func (e *Encryptor) Encrypt(plaintext string) ([]byte, error) {
nonce := make([]byte, e.gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("generate nonce: %w", err)
}
ciphertext := e.gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return ciphertext, nil
}
// Decrypt decrypts the ciphertext bytes.
func (e *Encryptor) Decrypt(ciphertext []byte) (string, error) {
nonceSize := e.gcm.NonceSize()
if len(ciphertext) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := e.gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("decrypt: %w", err)
}
return string(plaintext), nil
}Wire Format
Encrypted values are stored as raw bytes (BYTEA column in PostgreSQL).
Format:
[12-byte nonce][ciphertext + 16-byte GCM auth tag]Example:
-- patient_persons.phone_encrypted column
SELECT phone_encrypted FROM patient_persons WHERE id = 81;
-- Returns: \x3a8f2b... (binary data, not human-readable)Usage Example
// Encrypting a patient phone number
encryptor, _ := crypto.NewEncryptor(config.EncryptionKey)
encrypted, _ := encryptor.Encrypt("+40700123456")
// Store in database (phone lives on patient_persons, not patients)
db.Exec(`UPDATE patient_persons SET phone_encrypted = $1 WHERE id = $2`, encrypted, patientPersonID)
// Decrypting when needed
var encrypted []byte
db.QueryRow(`SELECT phone_encrypted FROM patient_persons WHERE id = $1`, patientPersonID).Scan(&encrypted)
plaintext, _ := encryptor.Decrypt(encrypted)
// plaintext = "+40700123456"Encryption Key Management
Key Storage
Encryption keys are stored as environment variables, never in code or database.
Environment variables:
# Single-version setup (simple, no rotation in progress)
ENCRYPTION_KEY=a1b2c3d4e5f6...64chars # 32 bytes = 64 hex characters
# Multi-version setup (during key rotation)
ENCRYPTION_KEY_V1=a1b2c3d4e5f6...64chars
ENCRYPTION_KEY_V2=f6e5d4c3b2a1...64chars
ENCRYPTION_CURRENT_VERSION=2Key Generation
Generate a new encryption key:
openssl rand -hex 32
# Output: a1b2c3d4e5f6...64charsCritical: Use a cryptographically secure random number generator. Do NOT use weak sources like Math.random() or sequential values.
Key Access Control
| Environment | Where Keys Are Stored | Who Has Access |
|---|---|---|
| Production | AWS Secrets Manager (encrypted) | CTO, lead engineer only |
| Staging | AWS Secrets Manager (different key from prod) | Engineering team |
| Development | .env file (git-ignored) | Individual developers (local keys only) |
Key separation: Production, staging, and development use different encryption keys. This prevents:
- Staging data being decrypted with production key
- Leaked development keys compromising production
Key Backup
Production encryption keys are backed up to:
- Password manager (1Password/Bitwarden) - Shared vault, restricted access
- Printed copy - Stored in physical safe, office location
Why physical backup? If all digital systems are compromised (ransomware, AWS account takeover), the printed key allows data recovery.
Key Rotation Strategy
HIPAA requires periodic key rotation. Restartix rotates encryption keys quarterly (every 90 days) or immediately on suspected compromise.
Why Key Rotation?
- Limits blast radius of key compromise (old data can't be decrypted with new key)
- Compliance requirement (HIPAA §164.308(a)(8))
- Industry best practice
Versioned Encryption
The system supports multiple active keys simultaneously during the rotation window. Every encrypted value is prefixed with a 1-byte key version identifier.
Wire format:
[1-byte version][12-byte nonce][ciphertext + GCM auth tag]Example:
- Version 1 key encrypts data as:
\x01<nonce><ciphertext> - Version 2 key encrypts data as:
\x02<nonce><ciphertext>
When decrypting, the system reads the version byte and uses the corresponding key.
Implementation
// internal/pkg/crypto/encryption.go
type VersionedEncryptor struct {
encryptors map[byte]*Encryptor // version → encryptor
current byte // version used for new encryptions
}
// NewVersionedEncryptor loads all active keys. currentVersion is used for new encryptions.
// Example env: ENCRYPTION_KEY_V1=aabb..., ENCRYPTION_KEY_V2=ccdd..., ENCRYPTION_CURRENT_VERSION=2
func NewVersionedEncryptor(keys map[byte][]byte, currentVersion byte) (*VersionedEncryptor, error) {
encryptors := make(map[byte]*Encryptor, len(keys))
for version, key := range keys {
enc, err := NewEncryptor(key)
if err != nil {
return nil, fmt.Errorf("key version %d: %w", version, err)
}
encryptors[version] = enc
}
if _, ok := encryptors[currentVersion]; !ok {
return nil, fmt.Errorf("current version %d not in key map", currentVersion)
}
return &VersionedEncryptor{encryptors: encryptors, current: currentVersion}, nil
}
// Encrypt always uses the current key version.
func (ve *VersionedEncryptor) Encrypt(plaintext string) ([]byte, error) {
ciphertext, err := ve.encryptors[ve.current].Encrypt(plaintext)
if err != nil {
return nil, err
}
return append([]byte{ve.current}, ciphertext...), nil
}
// Decrypt reads the version prefix and uses the corresponding key.
func (ve *VersionedEncryptor) Decrypt(data []byte) (string, error) {
if len(data) < 2 {
return "", fmt.Errorf("ciphertext too short")
}
version := data[0]
enc, ok := ve.encryptors[version]
if !ok {
return "", fmt.Errorf("unknown key version: %d", version)
}
return enc.Decrypt(data[1:])
}
// NeedsReEncrypt returns true if the data was encrypted with an old key.
func (ve *VersionedEncryptor) NeedsReEncrypt(data []byte) bool {
return len(data) > 0 && data[0] != ve.current
}Rotation Procedure
Step 1: Generate new key
openssl rand -hex 32
# Output: f6e5d4c3b2a1...64chars (save this as v2)Step 2: Add new key to config (AWS Secrets Manager)
ENCRYPTION_KEY_V1=<old_key> # keep old key for decryption
ENCRYPTION_KEY_V2=<new_key> # add new key
ENCRYPTION_CURRENT_VERSION=2 # new encryptions use v2Step 3: Deploy
Deploy the updated config. At this point:
- New encryptions use v2
- Old data can still be decrypted with v1
- System is running with both keys active
Step 4: Run re-encryption job
go run cmd/tools/reencrypt/main.goThis script:
- Scans
patient_persons.phone_encrypted,patient_persons.emergency_contact_phone_encrypted, andorganization_integrations.api_key_encrypted - For each row: decrypt with old key → encrypt with new key → update
- Processes in batches of 500 with progress logging
- Is idempotent (checks
NeedsReEncrypt()before processing)
Step 5: Verify re-encryption complete
-- Check all patient phone numbers are using v2
SELECT COUNT(*) FROM patient_persons
WHERE phone_encrypted IS NOT NULL
AND get_byte(phone_encrypted, 0) != 2;
-- Expected: 0
-- Check all emergency contact phones are using v2
SELECT COUNT(*) FROM patient_persons
WHERE emergency_contact_phone_encrypted IS NOT NULL
AND get_byte(emergency_contact_phone_encrypted, 0) != 2;
-- Expected: 0
-- Check all API keys are using v2
SELECT COUNT(*) FROM organization_integrations
WHERE api_key_encrypted IS NOT NULL
AND get_byte(api_key_encrypted, 0) != 2;
-- Expected: 0Step 6: Remove old key from config
Once all data is re-encrypted, remove the old key:
ENCRYPTION_KEY_V2=<new_key>
ENCRYPTION_CURRENT_VERSION=2
# ENCRYPTION_KEY_V1 removedStep 7: Audit log entry
Log the rotation in the audit system:
INSERT INTO audit_log (
organization_id, user_id, action, entity_type,
changes, action_context, created_at
) VALUES (
NULL, -- system action
1, -- superadmin user who performed rotation
'KEY_ROTATION',
'encryption_key',
'{"old_version": 1, "new_version": 2}'::jsonb,
'compliance_maintenance',
NOW()
);Rotation Schedule
| Trigger | Frequency | Notes |
|---|---|---|
| Quarterly rotation | Every 90 days | Automated reminder via GitHub Actions |
| Suspected compromise | Immediate | Manual trigger, emergency procedure |
| Employee offboarding | Within 24 hours of access revocation | If employee had access to keys |
| Regulatory requirement | As mandated | If HIPAA guidance changes |
Monitoring: Datadog monitor alerts if no key rotation has occurred in 95 days (5-day grace period for scheduling).
See Key Rotation Procedure for detailed operational steps.
References
Related Documentation
- Auth and Security - Full security architecture
- RBAC and Permissions - Role-based access control
- GDPR Compliance - Data protection and privacy
- Key Rotation Procedure - Operational runbook
Related Features
- Organizations: API Keys -
api_key_encryptedfield - Patients -
phone_encryptedfield - Forms - Why form values are plaintext
- Segments - Why segments require queryable form data
External Standards
- HIPAA §164.312(a)(2)(iv) - Encryption and decryption (addressable)
- HIPAA §164.308(a)(8) - Evaluation (periodic key rotation)
- NIST SP 800-57 - Recommendation for Key Management
- GDPR Article 32 - Security of processing (encryption as technical measure)
FAQ
Why not encrypt everything at the application level?
Performance and functionality. Encrypting all fields would:
- Make queries slow (can't use indexes, must decrypt everything)
- Break patient segments (can't filter encrypted JSONB)
- Break search (can't search encrypted text)
- Increase costs (more CPU for decryption on every page load)
Infrastructure encryption already protects all data. Application-level encryption is for fields that need extra protection beyond infrastructure.
What if someone gets a database dump?
They get encrypted binary blobs. Without the encryption key (stored separately in AWS Secrets Manager, not in the database), they cannot:
- Decrypt phone numbers
- Decrypt API keys
- Access any data (infrastructure encryption covers everything)
What if someone gets the encryption key?
Defense in depth:
- RLS still enforces organization isolation (even with the key, can't cross orgs without RLS bypass)
- Audit logging records all access (compromise is detectable)
- Key rotation limits blast radius (old data can't be decrypted with new key after rotation)
Can I search encrypted fields?
No. Encrypted fields cannot be searched or filtered at the database level. This is intentional:
- Phone numbers don't need to be searchable (search by name/email instead)
- API keys don't need to be searchable (lookup by integration type)
If a field needs to be searchable, it should NOT be app-level encrypted.
What happens during key rotation if a deployment fails?
Graceful degradation:
- Old key is kept active during rotation
- If deployment fails, rollback to previous version
- Old key still works, no data loss
- Re-encryption job is idempotent (can be re-run)
How do I add a new encrypted field?
- Add
<field>_encrypted BYTEAcolumn to the table - Update the repository to use
encryptor.Encrypt()before writing - Update the repository to use
encryptor.Decrypt()when reading - Add the field to the re-encryption job script
- Document why this field needs app-level encryption (justify the complexity)
Example:
ALTER TABLE patients ADD COLUMN ssn_encrypted BYTEA;// Encrypt before insert
encrypted, _ := encryptor.Encrypt(patient.SSN)
db.Exec(`INSERT INTO patients (name, ssn_encrypted) VALUES ($1, $2)`, patient.Name, encrypted)
// Decrypt after read
var encrypted []byte
db.QueryRow(`SELECT ssn_encrypted FROM patients WHERE id = $1`, id).Scan(&encrypted)
plaintext, _ := encryptor.Decrypt(encrypted)