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.
SQL is illustrative
SQL fragments in this document are examples meant to convey shape and intent — they're not authoritative reproductions of the production schema. The real migrations live in services/api/migrations/core/.
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
The platform reserves column-level encryption for two narrow categories — regulated identifiers (pii_regulated) and credential material (auth_secret). Everything else relies on the layered envelope (RLS + audit + at-rest disk encryption + encrypted backups + restricted DB access). The rule is enforced mechanically — see decisions.md → Why most PII is plaintext.
| Table | Field | Contains | Why Encrypted |
|---|---|---|---|
organization_billing | tax_id_encrypted | CUI / national tax ID for RO clinics | pii_regulated — extra-protected by national law beyond GDPR Art. 6 |
organization_integrations (planned) | api_key_encrypted | Third-party API keys | auth_secret — credentials grant access; compromise = identity takeover |
| Future webhook signing secrets, integration tokens | *_encrypted | Outbound webhook signers, OAuth refresh tokens | auth_secret — same logic |
Why These Fields?
Regulated identifiers (CUI, SSN, passport):
- National law treats them as sensitive beyond GDPR Art. 6.
- Extra-strict consequences if leaked.
- Never searched on the plaintext value (the join is by FK to the row, not by the identifier itself).
- The narrow access pattern absorbs the encrypt/decrypt cost without affecting hot paths.
Credentials (API keys, signing secrets, refresh tokens):
- The plaintext is access — leaking the value gives the attacker what they wanted.
- Compromise = identity takeover for the integration.
- Hashed where one-way is sufficient (
*_hash BYTEAfor API keys, SHA-256). Encrypted where the platform must read the value back to use it. - Never searched on plaintext.
Why NOT phone, email, name, address, etc.
Patient phone, email, contact info, names, dates of birth, addresses, allergies, diagnoses — all plaintext. The pattern in EHRs (Epic, Cerner, Athena, Meditech) is the same: column-level encryption protects against logical-backup leaks, but the same backup leaks the rest of the row in plaintext, so encrypting just one column doesn't reduce notification surface or change the realistic threat model. Industry-default and the explicit decision in decisions.md.
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_profiles.name (TEXT), humans.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
The helper lives at services/api/internal/core/crypto/. The public API is package-level functions backed by a process-wide keyring installed once at startup (crypto.Init):
// services/api/internal/core/crypto/crypto.go
func Encrypt(plaintext []byte) ([]byte, error)
func Decrypt(blob []byte) ([]byte, error)
// String helpers — most call sites encrypt/decrypt strings.
func EncryptString(s string) ([]byte, error)
func DecryptString(blob []byte) (string, error)The keyring is an interface, with two implementations:
InMemoryKeyring— keys parsed fromENCRYPTION_KEYSconfiguration. This is the Phase 1 production path (and the dev / test path). In Phase 1 production,ENCRYPTION_KEYSlives in therestartix/{env}/encryptionSecrets Manager secret, which is enveloped under a customer-managed KMS CMK — so the SM fetch transparently goes through KMS once at startup, then the plaintext keyring is held in process memory and AES-GCM ops are local. See aws-infrastructure.md → Encryption keys: KMS.kmsKeyring(stub today, Phase 2 work) — direct per-data-key KMS calls; enables per-tenant key custody (BYOK). ReturnsErrNotImplementeduntil Phase 2 lands. Phase 1 does NOT use this path.
Each call to Encrypt generates a fresh 12-byte nonce; nonces are never reused under the same key, so GCM's authenticity guarantees hold.
KMS-rooted protection in Phase 1
Phase 1 production uses InMemoryKeyring loaded from a KMS-envelope-protected Secrets Manager secret. Practically:
- Boot path:
SecretsManager.GetSecretValue("restartix/{env}/encryption")→ SM service silently callskms:Decryptagainst the customer-managed CMK → plaintext SM blob returned →LoadInMemoryKeyringFromEnvparsesENCRYPTION_KEYSinto the keyring. - Steady-state: keyring lives in process memory; AES-256-GCM column ops never call KMS again until the next process boot.
- Audit signal: CloudTrail logs the per-task
kms:Decryptaccess on the CMK, which is the audit guarantee a customer-managed CMK exists to provide. - Rotation: rotating the CMK affects the SM ciphertext only (re-wraps automatically); rotating the column-encryption keys themselves is a key-version bump in the SM secret + restart, per key-rotation.md.
This is not a bridge. It's the Phase 1 production architecture. The kmsKeyring stub becomes load-bearing only in Phase 2 when direct KMS access per data key (and per-tenant key custody) is needed — see aws-infrastructure.md → Direct-KMS keyring + BYOK (Phase 2+). Until then, USE_KMS_ENCRYPTION stays false everywhere including production.
Wire Format
Encrypted values are stored as raw bytes (BYTEA column in PostgreSQL). Every blob carries the key version it was sealed under, so rotation works without re-encrypting old rows on the spot.
Format:
[1-byte version][12-byte nonce][ciphertext + 16-byte GCM auth tag]Example:
-- organization_billing.tax_id_encrypted column
SELECT tax_id_encrypted FROM organization_billing WHERE organization_id = 'b3f0...';
-- Returns: \x013a8f2b... (\x01 = version 1; rest is binary)Usage Example
Encryption is a repository-layer concern. Handlers and services see plaintext.
// In a repository — e.g. organization_billing.SetTaxID
cipher, err := crypto.EncryptString(billing.TaxID)
if err != nil { return err }
db.TxFromContext(ctx).Exec(ctx,
`UPDATE organization_billing SET tax_id_encrypted = $1 WHERE organization_id = $2`,
cipher, orgID)
// In a repository — e.g. organization_billing.Get
var cipher []byte
db.TxFromContext(ctx).QueryRow(ctx, `SELECT ..., tax_id_encrypted FROM organization_billing WHERE organization_id = $1`, orgID).
Scan(..., &cipher)
billing.TaxID, err = crypto.DecryptString(cipher)Encryption Key Management
Key Storage
Encryption keys are stored as environment variables, never in code or database.
Environment variables:
# Phase 1 (current, all envs including production): in-memory keyring loaded
# from ENCRYPTION_KEYS. In production this string lives in the
# restartix/{env}/encryption Secrets Manager secret, enveloped under a
# customer-managed KMS CMK — so the secret fetch goes through KMS once at boot.
# In dev / test, ENCRYPTION_KEYS is a comma-separated "version:hexkey" list:
ENCRYPTION_KEYS=1:a1b2c3d4e5f6...64chars
ACTIVE_ENCRYPTION_VERSION=1
# Phase 2 (deferred): direct kmsKeyring with per-data-key KMS calls + BYOK.
# Flip per environment when Phase 2 work lands; not used in Phase 1.
USE_KMS_ENCRYPTION=false
# During key rotation, list both versions while re-encryption is in flight:
# ENCRYPTION_KEYS=1:a1b2c3d4e5f6...64chars,2:f6e5d4c3b2a1...64chars
# ACTIVE_ENCRYPTION_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
Multi-version support is built into the helper from day one — see the Keyring interface in services/api/internal/core/crypto/keyring.go. A keyring holds every key version the process knows about; Encrypt always seals under ActiveVersion(), and Decrypt reads the version byte from the blob and looks the key up.
A row's encrypted blob needs re-encryption when its version byte (blob[0]) differs from the keyring's ActiveVersion(). The re-encryption job described below uses that signal directly. (A small helper that wraps blob[0] != activeVersion() is not shipped today — when the first encrypted column lands in Layer 2 and the re-encryption tool is built, it will be added to the crypto package alongside the Keyring interface.)
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 / KMS)
ENCRYPTION_KEYS=1:<old_key>,2:<new_key> # both versions decryptable
ACTIVE_ENCRYPTION_VERSION=2 # new encryptions use v2In production with USE_KMS_ENCRYPTION=true, the equivalent operation is registering the new wrapped DEK in KMS and updating the active-version pointer the keyring resolves at startup.
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
Not yet built. The re-encryption job lands alongside the first encrypted column in Layer 2. Until then the rotation procedure is "deploy with both versions in
ENCRYPTION_KEYS, leave the old version active until any encrypted columns are re-keyed by hand, then remove" — there is nothing to re-encrypt while no_encryptedcolumns exist in the schema.
When the tool ships it will live at services/api/cmd/reencrypt/ and:
- Scan every
_encrypted BYTEAcolumn registered in a per-table re-encryption manifest (kept in the package alongside the tool so adding a new encrypted field updates one place). - For each row whose blob's version byte differs from
keyring.ActiveVersion(): decrypt with the old key → encrypt with the active key →UPDATEin batches. - Be idempotent: a second run after success no-ops because every blob already carries the active version byte.
Step 5: Verify re-encryption complete
-- Check all org tax IDs are using v2
SELECT COUNT(*) FROM organization_billing
WHERE tax_id_encrypted IS NOT NULL
AND get_byte(tax_id_encrypted, 0) != 2;
-- Expected: 0
-- Check all API keys are using v2 (when organization_integrations ships)
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_KEYS=2:<new_key>
ACTIVE_ENCRYPTION_VERSION=2
# version 1 entry 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: A "no rotation in 95 days" monitor lands with the rest of production observability in Layer 12.
See key-rotation.md for the operational runbook.
References
Related Documentation
- reference/rls-policies.md — RLS as the orthogonal isolation layer
- reference/rbac-permissions.md — Role-based access control
- reference/gdpr-compliance.md — Data protection and privacy
- reference/key-rotation.md — Operational runbook for rotating the active key
Related Features (planned — Layer 2+)
- Organizations: API Keys —
api_key_encrypted(planned,auth_secret) - Forms — Why form values are plaintext (planned)
- Segments — Why segments require queryable form data (planned)
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 in the migration (with theAES-256-GCM (P12)annotation indata-model.md). - In the repository, call
crypto.EncryptString(value)before INSERT/UPDATE. - In the repository, call
crypto.DecryptString(blob)after SELECT. - Register the column in the re-encryption tool's manifest so the next rotation includes it (the tool itself ships when the first encrypted column lands — see Layer 2).
- Document why this field needs app-level encryption (justify the complexity beyond infrastructure encryption).
Handlers and services should never see the encrypted bytes — encryption is a storage concern.
Example:
ALTER TABLE patients ADD COLUMN ssn_encrypted BYTEA;// Encrypt before insert
cipher, err := crypto.EncryptString(patient.SSN)
if err != nil { return err }
db.TxFromContext(ctx).Exec(ctx,
`INSERT INTO patients (name, ssn_encrypted) VALUES ($1, $2)`,
patient.Name, cipher)
// Decrypt after read
var cipher []byte
db.TxFromContext(ctx).QueryRow(ctx,
`SELECT ssn_encrypted FROM patients WHERE id = $1`, id).Scan(&cipher)
patient.SSN, err = crypto.DecryptString(cipher)