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.

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

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

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.

TableFieldContainsWhy Encrypted
organization_billingtax_id_encryptedCUI / national tax ID for RO clinicspii_regulated — extra-protected by national law beyond GDPR Art. 6
organization_integrations (planned)api_key_encryptedThird-party API keysauth_secret — credentials grant access; compromise = identity takeover
Future webhook signing secrets, integration tokens*_encryptedOutbound webhook signers, OAuth refresh tokensauth_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 BYTEA for 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:

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_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):

go
// 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 from ENCRYPTION_KEYS configuration. This is the Phase 1 production path (and the dev / test path). In Phase 1 production, ENCRYPTION_KEYS lives in the restartix/{env}/encryption Secrets 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). Returns ErrNotImplemented until 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 calls kms:Decrypt against the customer-managed CMK → plaintext SM blob returned → LoadInMemoryKeyringFromEnv parses ENCRYPTION_KEYS into 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:Decrypt access 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:

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

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

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

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

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

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

bash
ENCRYPTION_KEYS=1:<old_key>,2:<new_key>   # both versions decryptable
ACTIVE_ENCRYPTION_VERSION=2               # new encryptions use v2

In 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 _encrypted columns exist in the schema.

When the tool ships it will live at services/api/cmd/reencrypt/ and:

  • Scan every _encrypted BYTEA column 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 → UPDATE in 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

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

Step 6: Remove old key from config

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

bash
ENCRYPTION_KEYS=2:<new_key>
ACTIVE_ENCRYPTION_VERSION=2
# version 1 entry 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: 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

  • Organizations: API Keysapi_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:

  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 in the migration (with the AES-256-GCM (P12) annotation in data-model.md).
  2. In the repository, call crypto.EncryptString(value) before INSERT/UPDATE.
  3. In the repository, call crypto.DecryptString(blob) after SELECT.
  4. 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).
  5. 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:

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