Skip to main content

PII encryption at rest (SEC-001)

Protects personally identifiable information — names, house-address lines + postal code, and phone numbers — with authenticated field-level encryption, so a database or backup compromise does not expose plaintext. Encryption is transparent to services and APIs (authorized callers still see plaintext); only ciphertext lands in the tables.

  • Status: shipped (PR #270, closes GitHub #206).
  • Owned by: common.security.crypto (cipher/blind-index/protector) + the demographic engine (which applies it in the service layer).
  • Default: off — opt-in per deployment.

How it works

  • PiiCipher — AES-256-GCM, a fresh random 96-bit IV per value, a 128-bit auth tag, wrapped in a self-describing envelope pii:v1:<keyId>:<iv>:<ciphertext+tag> (Base64). The embedded keyId enables key rotation (always encrypt with the active key; decrypt by matching the envelope's id). Tampered ciphertext fails GCM authentication and is rejected — it never silently mis-decrypts.
  • PiiBlindIndex — HMAC-SHA256 (its own key) → a deterministic *_hash companion column, so encrypted names/phones can still be looked up by exact match without exposing plaintext.
  • PiiProtector — the facade the demographic services use: encrypt before repository.save, decrypt after fetch (no JPA converter — repositories/entities/mappers only ever hold ciphertext). reveal() is tolerant: a value that is not a pii:v1: envelope (plaintext, mixed data) is returned as-is, so reads never break.

Encrypted fields: person_name given/middle/family (+ given/family blind index), person_address line1/line2/postal_code, and person_attribute values for attribute types flagged encrypted (e.g. phone — ciphertext stored JSON-wrapped in value_jsonb, blind-indexed into value_hash). Schema in migration demographic/009.

Configuration

All values come from .env/secrets and are referenced from application.yml as ${VAR}. Never commit real key material — only safe placeholders live in .env.example.

Env varPropertyMeaning
PII_ENCRYPTION_ENABLEDpii.encryption.enabledMaster switch. Off by default.
PII_ENCRYPTION_KEYpii.encryption.keyBase64 of 32 random bytes (AES-256). Required when enabled.
PII_ENCRYPTION_KEY_IDpii.encryption.key-idShort id of the active key, embedded in each envelope for rotation.
PII_BLIND_INDEX_KEYpii.encryption.blind-index-keyBase64 HMAC-SHA256 key (separate from the AES key). Required when enabled.
PII_BACKFILL_ON_STARTUPpii.encryption.backfill-on-startupRun the one-time idempotent backfill of pre-existing rows at startup. Off by default.

When enabled is true and a required key is missing, the app fails fast at startup — there is no insecure default. Generate keys out-of-band, e.g. openssl rand -base64 32.

Enabling on a platform already in use

Turning encryption on mid-flight produces a transient mixed state — pre-existing rows stay plaintext until re-saved, new/updated rows are encrypted — which is safe because reads are tolerant. To make all data uniformly encrypted, enable the flag together with the one-time backfill:

PII_ENCRYPTION_ENABLED=true
PII_ENCRYPTION_KEY=<base64-32-bytes>
PII_ENCRYPTION_KEY_ID=k1
PII_BLIND_INDEX_KEY=<base64-32-bytes>
PII_BACKFILL_ON_STARTUP=true # run once, then set back to false

On the next startup PiiBackfillRunner encrypts and hashes every pre-existing plaintext row (idempotent — it skips rows that are already envelopes), then you can set PII_BACKFILL_ON_STARTUP back to false.

Why enable + backfill together: with encryption on, name/phone search uses the blind-index hash. A pre-existing row that hasn't been backfilled has a NULL *_hash, so it won't be found by search until it is backfilled (reads by id still work). Running the backfill alongside enabling keeps search complete.

Rotation

Encrypt with the active key; each envelope records the keyId used. To rotate, deploy a new active key/id and (optionally) re-save or re-backfill rows so they move onto the new key. Old envelopes remain decryptable as long as their key is still available to the cipher.

Limitations / follow-ups

  • The first-cut backfill covers non-voided rows only (soft-deleted rows are hidden by @SQLRestriction), so voided historical PII stays plaintext until a follow-up handles it.
  • Turning the flag back off after encrypting leaves envelopes that cannot be read without the keys — treat "on" as a one-way door once backfilled, and keep the keys.
  • Substring/fuzzy search over encrypted fields is out of scope (needs searchable encryption); only exact-match blind-index lookup is supported.
  • KMS integration (AWS KMS / Vault) and clinical PHI encryption are separate follow-ups (SEC-002+).

Verification

  • Enable the flag with test keys and confirm the persisted columns hold pii:v1: envelopes while the API returns plaintext (see PiiEncryptionAtRestTest).
  • Confirm tampered ciphertext is rejected (PiiCipherTest) and the backfill is idempotent (PiiBackfillRunnerTest).