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.
  • 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 only PersonNameServiceImpl was 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 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 (SEC-019)

Encrypt with the active key; each envelope records the keyId used. Decrypt with whichever key the envelope namesPiiKeyring resolves it from the active key plus every retired one.

Before SEC-019 this section described something the code could not do. PiiCipher held 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 that PiiKeyring exists before rotating anything.

To rotate:

  1. Generate the new key. Give it a new id — never reuse the old one.

  2. Move the current key to retired-keys under its original id:

    pii:
    encryption:
    key: <new base64 key>
    key-id: k2
    retired-keys:
    k1: <the previous base64 key>
  3. Deploy. New writes carry k2; rows written under k1 keep 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:

ShownWhy it matters
Active key idwhich id new writes are encrypted with
Retired key ids still helda row encrypted under an id this deployment no longer holds cannot be decrypted
Blind index configuredwithout 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.

ProviderSourceUse
env (default)PII_ENCRYPTION_KEY, PII_BLIND_INDEX_KEY from configurationanywhere without a key service, and the fallback
vaulta stored data key unwrapped with Vault's Transit engine at startupKubernetes 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

VariableMeaning
PII_KEY_PROVIDERenv or vault
VAULT_ADDRVault's address. In a cluster prefer the active service: Transit decrypt is a POST and standbys redirect writes
VAULT_ROLE, VAULT_AUTH_PATHKubernetes auth. The pod's ServiceAccount token is exchanged for a short-lived Vault token, so no credential is stored
VAULT_TOKENa static token, for local development only
VAULT_CACERTpath to a mounted ca.crt when Vault's listener uses a private CA
VAULT_KEK_TRANSIT_KEYthe Transit key that wraps the data keys
PII_WRAPPED_KEY, PII_WRAPPED_BLIND_INDEX_KEYfull 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.

  1. wrap the existing encryption-key and blind-index-key with the target Vault
  2. store the two vault:vN: strings, prefix included. ⚠ Never strip the prefix: it names the key version that wrapped the value
  3. 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

RequirementWhy
One key set per cell, generated in that cellA shared key makes one compromise reach every country
Keys never leave the countryThis is the reason the cell model exists at all
The same fields as the facility tierDivergence 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 PiiKeyProvider SPI with env (a Kubernetes Secret or a 0640 file), vault (a site with its own cluster) and awskms (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 (see PiiEncryptionAtRestTest).
  • ⚠ 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).