Skip to content

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

Restartix uses a defense-in-depth encryption strategy with two distinct layers:

  1. Infrastructure encryption - Encrypts ALL data at rest and in transit (AWS RDS, S3, TLS)
  2. 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?

ConcernSolution
HIPAA requires encryption at restInfrastructure encryption covers 100% of data
Some fields need extra protectionApplication-level encryption for phone, API keys
Forms must be queryable for segmentsForms stored in plaintext (protected by infrastructure)
Custom fields must be searchableCustom fields in plaintext (protected by infrastructure)
Performance at scaleSelective 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

ComponentEncryption MethodCoverage
AWS RDS (PostgreSQL)AES-256 encryption at restAll database storage (tables, indexes, backups, snapshots, logs)
AWS S3Server-side encryption (SSE-S3)All uploaded files (form attachments, signatures, documents)
TLS in TransitTLS 1.2+ for all connectionsAPI ↔ 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

TableFieldContainsWhy Extra Encryption
patient_personsphone_encryptedPatient phone numberHigh-sensitivity PII, not needed for queries
patient_personsemergency_contact_phone_encryptedEmergency contact phoneHigh-sensitivity PII, not needed for queries
organization_integrationsapi_key_encryptedThird-party API keysCredentials 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:

sql
-- 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:

  1. Fetch all forms
  2. Decrypt each one in the application
  3. 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:

sql
-- 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

go
// 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:

sql
-- patient_persons.phone_encrypted column
SELECT phone_encrypted FROM patient_persons WHERE id = 81;
-- Returns: \x3a8f2b... (binary data, not human-readable)

Usage Example

go
// 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:

bash
# 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=2

Key Generation

Generate a new encryption key:

bash
openssl rand -hex 32
# Output: a1b2c3d4e5f6...64chars

Critical: Use a cryptographically secure random number generator. Do NOT use weak sources like Math.random() or sequential values.

Key Access Control

EnvironmentWhere Keys Are StoredWho Has Access
ProductionAWS Secrets Manager (encrypted)CTO, lead engineer only
StagingAWS 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:

  1. Password manager (1Password/Bitwarden) - Shared vault, restricted access
  2. 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

go
// 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

bash
openssl rand -hex 32
# Output: f6e5d4c3b2a1...64chars (save this as v2)

Step 2: Add new key to config (AWS Secrets Manager)

bash
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 v2

Step 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

bash
go run cmd/tools/reencrypt/main.go

This script:

  • Scans patient_persons.phone_encrypted, patient_persons.emergency_contact_phone_encrypted, and organization_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

sql
-- 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: 0

Step 6: Remove old key from config

Once all data is re-encrypted, remove the old key:

bash
ENCRYPTION_KEY_V2=<new_key>
ENCRYPTION_CURRENT_VERSION=2
# ENCRYPTION_KEY_V1 removed

Step 7: Audit log entry

Log the rotation in the audit system:

sql
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

TriggerFrequencyNotes
Quarterly rotationEvery 90 daysAutomated reminder via GitHub Actions
Suspected compromiseImmediateManual trigger, emergency procedure
Employee offboardingWithin 24 hours of access revocationIf employee had access to keys
Regulatory requirementAs mandatedIf 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

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:

  1. RLS still enforces organization isolation (even with the key, can't cross orgs without RLS bypass)
  2. Audit logging records all access (compromise is detectable)
  3. 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:

  1. Old key is kept active during rotation
  2. If deployment fails, rollback to previous version
  3. Old key still works, no data loss
  4. Re-encryption job is idempotent (can be re-run)

How do I add a new encrypted field?

  1. Add <field>_encrypted BYTEA column to the table
  2. Update the repository to use encryptor.Encrypt() before writing
  3. Update the repository to use encryptor.Decrypt() when reading
  4. Add the field to the re-encryption job script
  5. Document why this field needs app-level encryption (justify the complexity)

Example:

sql
ALTER TABLE patients ADD COLUMN ssn_encrypted BYTEA;
go
// 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)