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) + thedemographicengine (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 envelopepii:v1:<keyId>:<iv>:<ciphertext+tag>(Base64). The embeddedkeyIdenables 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*_hashcompanion column, so encrypted names/phones can still be looked up by exact match without exposing plaintext.PiiProtector— the facade the demographic services use: encrypt beforerepository.save, decrypt after fetch (no JPA converter — repositories/entities/mappers only ever hold ciphertext).reveal()is tolerant: a value that is not apii: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 var | Property | Meaning |
|---|---|---|
PII_ENCRYPTION_ENABLED | pii.encryption.enabled | Master switch. Off by default. |
PII_ENCRYPTION_KEY | pii.encryption.key | Base64 of 32 random bytes (AES-256). Required when enabled. |
PII_ENCRYPTION_KEY_ID | pii.encryption.key-id | Short id of the active key, embedded in each envelope for rotation. |
PII_BLIND_INDEX_KEY | pii.encryption.blind-index-key | Base64 HMAC-SHA256 key (separate from the AES key). Required when enabled. |
PII_BACKFILL_ON_STARTUP | pii.encryption.backfill-on-startup | Run 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 (seePiiEncryptionAtRestTest). - Confirm tampered ciphertext is rejected (
PiiCipherTest) and the backfill is idempotent (PiiBackfillRunnerTest).