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.PersonPiiRevealer(SEC-017) — the one place the decrypt-after-fetch half happens for names. Person, patient, provider and all three demographic searches call it after mapping; without it they returned the envelope where the name should be, and onlyPersonNameServiceImplwas correct.
⚠ Reveal the DTO, never the entity. Decrypting a managed PersonName makes it dirty, and the
flush at commit would write the plaintext back over the ciphertext — encryption that decrypts the
database as it is read. Every reveal takes a DTO that mapping has already detached.
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 (SEC-019)
Encrypt with the active key; each envelope records the keyId used. Decrypt with whichever key the
envelope names — PiiKeyring resolves it from the active key plus every retired one.
⚠ Before SEC-019 this section described something the code could not do.
PiiCipherheld a single key and threw"PII envelope was encrypted with an unknown key id"for anything else, so the first rotation would have made every encrypted name, address, phone and email permanently unreadable. If you are reading an older deployment, check thatPiiKeyringexists before rotating anything.
To rotate:
-
Generate the new key. Give it a new id — never reuse the old one.
-
Move the current key to
retired-keysunder its original id:pii:encryption:key: <new base64 key>key-id: k2retired-keys:k1: <the previous base64 key> -
Deploy. New writes carry
k2; rows written underk1keep decrypting.
Nothing is re-encrypted, and nothing becomes unreadable. Rows migrate onto the new key naturally as
they are rewritten; a deliberate re-encryption pass is optional and only needed if you intend to
eventually drop k1.
⚠ A retired key is still a live secret
It decrypts real patient data. Protect it exactly as you protect the active key — same storage, same backup, same access control.
Never remove one. Rows are not re-encrypted in place, so deleting a retired key is equivalent to deleting every row it protects. A key leaves the config only after a re-encryption pass has provably moved every row off it, and "provably" means a query returning zero rows whose envelope names that id — not an assumption that the backfill covered everything.
⚠ The blind-index key is not part of this
PII_BLIND_INDEX_KEY is deliberately outside the keyring, and rotating it is a different
operation with a different cost.
A blind index is an HMAC, and a hash cannot be decrypted into the new one. Changing that key therefore invalidates every stored hash at once, and every exact-match lookup — patient search by name, phone, contact value — stops finding anything until a full re-index has run.
So it must never sit on an automatic rotation policy, and a KMS/Vault auto-rotation schedule must not be pointed at it. Treat it as a planned migration with a re-index window, in the same category as a data model change rather than a credential refresh.
Seeing the current state (FE-395)
Admin → Platform → Security (/admin/platform/security), backed by
GET /api/v1/platformconfig/pii-key-status behind platformconfig.pii-key.read. It answers the two
questions a rotation depends on:
| Shown | Why it matters |
|---|---|
| Active key id | which id new writes are encrypted with |
| Retired key ids still held | a row encrypted under an id this deployment no longer holds cannot be decrypted |
| Blind index configured | without it, name search is broken rather than merely slow |
| Startup backfill setting | ⚠ the setting, not a completion signal — see below |
⚠ Ids and flags only. The endpoint never returns key material, and there is deliberately no write endpoint: keys are configuration, and an API that set one would put a key in a request body and in the access log. Rotation stays a deployment action.
⚠ The screen cannot tell you whether the backfill has finished. PiiBackfillRunner logs its
counts and persists no record, so nothing can honestly report completion. This matters more than it
sounds: enabling encryption without running the backfill leaves existing rows in clear and their
blind indexes unwritten, so every patient already in the database becomes unfindable by name.
Until a completion marker exists, confirm from the startup log line
(PII backfill complete: N names, N addresses, N attributes encrypted).
Where the keys come from (M33-005)
pii.encryption.provider selects how key material is obtained. It does not change how encryption
works: AES-GCM and the blind-index HMAC always run in process.
| Provider | Source | Use |
|---|---|---|
env (default) | PII_ENCRYPTION_KEY, PII_BLIND_INDEX_KEY from configuration | anywhere without a key service, and the fallback |
vault | a stored data key unwrapped with Vault's Transit engine at startup | Kubernetes deployments |
With vault, Transit is a key-encryption key. The application calls
transit/decrypt/<key> twice during startup, once per wrapped key, and then never contacts Vault
again. Rotating the Transit key rotates the KEK; the wrapped keys are unchanged, vault:vN:
ciphertext is self-describing, and previously wrapped values keep unwrapping. See D19 and D33.
Settings
| Variable | Meaning |
|---|---|
PII_KEY_PROVIDER | env or vault |
VAULT_ADDR | Vault's address. In a cluster prefer the active service: Transit decrypt is a POST and standbys redirect writes |
VAULT_ROLE, VAULT_AUTH_PATH | Kubernetes auth. The pod's ServiceAccount token is exchanged for a short-lived Vault token, so no credential is stored |
VAULT_TOKEN | a static token, for local development only |
VAULT_CACERT | path to a mounted ca.crt when Vault's listener uses a private CA |
VAULT_KEK_TRANSIT_KEY | the Transit key that wraps the data keys |
PII_WRAPPED_KEY, PII_WRAPPED_BLIND_INDEX_KEY | full vault:vN:... strings from transit/encrypt |
⚠⚠ Migrating from env to vault
Wrap the keys the deployment already uses. Never generate new ones.
A fresh data key makes every stored pii:v1: envelope undecryptable. A fresh blind-index key is
worse, because it fails quietly: a blind-index hash carries no key id, so every patient name and
phone search returns zero rows with nothing in any log to explain it.
Done correctly there is no migration at all. Wrapping the existing values and unwrapping them
returns byte-identical material, key-id is unchanged, and no row is re-encrypted.
- wrap the existing
encryption-keyandblind-index-keywith the target Vault - store the two
vault:vN:strings, prefix included. ⚠ Never strip the prefix: it names the key version that wrapped the value - set
PII_KEY_PROVIDER=vault
⚠ The application refuses to start when the provider is vault and a wrapped key is missing, so
flip the provider last. Rollback is one line back to env, with the original keys untouched.
⚠ Never point a rotation policy at anything feeding the blind index. Rotating the KEK is safe; rotating the key that derives the hash is not.
The cloud tier's posture (M29-006)
The cloud is a full instance per country, not a thin aggregation layer, so everything above applies there unchanged. Three things are specific to it, and they are written down here because each has already been argued once and would otherwise be re-argued or half-built.
⚠⚠ The cloud can decrypt, and that is deliberate
The original brief said "end-to-end encrypted". Taken literally — meaning the cloud holds only ciphertext it cannot open — the product would not work:
- Patient search would be impossible. Search matches on a blind index, which is an HMAC the server computes over the search term. A server with no key cannot compute it.
- The MCP server would have nothing to answer with. It exists to answer clinical questions over the record; a store of opaque bytes answers none of them.
- Deduplication and merge would be blind. Finding that two records are the same person requires comparing names and phone numbers.
So the resolved posture is: the cloud decrypts, using keys that never leave its own country. The protection is not that nobody can read the data; it is that only one country's infrastructure holds the keys to one country's data, and the blast radius of any compromise is a single cell.
⚠ Say this plainly to anyone who asks whether the platform is "end-to-end encrypted". Answering "yes" because the phrase appears in an old document would be false, and the honest answer is stronger: PHI is encrypted at rest and in transit, and the keys are held in-country per cell.
Per-cell keys
| Requirement | Why |
|---|---|
| One key set per cell, generated in that cell | A shared key makes one compromise reach every country |
| Keys never leave the country | This is the reason the cell model exists at all |
| The same fields as the facility tier | Divergence would leave a field encrypted in one tier and plain in the other, and the plain one wins |
⚠⚠ A key store in a foreign jurisdiction is a compliance question, not only a technical one (D19).
AWS has no Nigeria region, so cell-NG cannot use AWS KMS without moving Nigerian PHI to Cape Town or
Ireland — which would undo the reason Galaxy Backbone was chosen. cell-NG runs Vault in-cluster;
PiiKeyProvider makes that a configuration choice rather than a code change.
⚠⚠ Encryption stays in this process
PiiKeyProvider hands over key material, once, at startup. It exposes no encrypt, decrypt or
mac, and EncryptionStaysInProcessArchitectureTest fails if one is added.
That shape is the control. If the seam offered operations, a Vault or KMS adapter could be written as per-value Transit calls — and a blind index is computed per stored row and per search term, so patient search would become a network round trip per candidate value, against a service that can rate-limit or be unreachable. "Find this patient" would then fail at exactly the moment a clinician needs it.
⚠⚠ Never point key auto-rotation at the blind-index key
A KMS or Vault auto-rotation policy applied to the blind-index key makes every name and phone search return zero results, with no error anywhere. The hashes carry a key id since M33-005B, so a rotation must be paired with the rehash runner; a rotation on its own is silent data loss in the only sense that matters — the records are there and nobody can find them.
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 is SEC-020 (D19): a
PiiKeyProviderSPI withenv(a Kubernetes Secret or a0640file),vault(a site with its own cluster) andawskms(the cloud tier, M29+). ⚠ Not LocalStack — a test double in the trust path for real PHI, where losing the key loses the data. - ⚠ Encryption stays local whichever provider is used. The blind index is an HMAC computed per search term and per stored row, so routing per-value through KMS or Vault Transit would make patient search unusable. The provider supplies key material at startup; the AES and HMAC work stays in-process.
- Clinical PHI encryption (beyond demographics) is a separate follow-up (SEC-002+).
Verification
- Enable the flag with test keys and confirm the persisted columns hold
pii:v1:envelopes while the API returns plaintext (seePiiEncryptionAtRestTest). - ⚠ Assert on the returned name, not just that a search found the row. SEC-017 was a decryption
gap on four of five read paths that survived because the blind-index test checked only
personId. - Confirm tampered ciphertext is rejected (
PiiCipherTest) and the backfill is idempotent (PiiBackfillRunnerTest).