chore(security): asymmetric PII encryption — persons master index stores subject names/DOB plaintext (resolve via ADR)
Summary
CRAIG's PII-at-rest encryption posture is asymmetric: PII captured at the intake edge (a reporter's
name/phone, the narrative, and the children/adults/raw_submission JSONB) is application-layer encrypted,
but the same subjects' PII becomes plaintext once it is materialized into the persons master index. A
child's name is ciphertext in reports.children and plaintext in persons.first_name/last_name; subject
date-of-birth is plaintext; gender/race/ethnicity are plaintext.
This is not a clear-cut bug — it follows a real technical boundary (see below) and the demographic fields are deliberately queryable for compliance reporting. But the subject name + DOB half of it is an under-finished encryption rollout, not a deliberate decision, and it deserves an explicit architectural ruling rather than sitting as an accident. Discovered while implementing NCANDS export D1 (#651 (closed)); not in scope for that work (NCANDS reads these fields as the architecture currently intends).
Evidence
persons.first_name,last_name,date_of_birth,gender,race,ethnicityare plaintext structured columns; the only encrypted person field isssn_last_four—services/craig-cases/src/store/models.rs(Person) +services/craig-cases/src/api/encryption/person.rs(decrypts SSN only).- Reporter PII is encrypted at the report/referral edge —
services/craig-cases/src/api/encryption/report.rs(encrypt_report_pii: reporter_first/last/phone + narrative + the 3 JSONB envelopes) and.../encryption/referral.rs(encrypt_referral_pii). - A child's name flows plaintext into
personsvia the convert/auto-link + person-create path (services/craig-cases/src/matching/mod.rs,store/persons.rs::create_person).
Why it exists (the searchability boundary)
App-layer encryption uses a random nonce per value, so ciphertext is opaque to Postgres — no ILIKE, no
pg_trgm similarity, no range/equality predicates. The persons table is the operational master index and
must stay searchable:
- Names are matched by fuzzy trigram (
(first_name||' '||last_name) % $1, GIN index) for person dedup/matching (ADR-019) —store/report_persons.rs::prefilter_candidates_by_name. A blind index gives equality, not fuzzy, so it can't replace trigram on its own. - DOB is filtered by equality/range in
store/persons.rs::search_persons. - gender/race/ethnicity are low-cardinality categorical attributes whose purpose is aggregate compliance reporting (AFCARS/NCANDS, dashboards, rules-engine inputs); encrypting them yields weak confidentiality (low cardinality leaks under frequency analysis) and breaks aggregate queries.
- The pattern for "encrypt but keep searchable" already exists for SSN: encrypted value (
ssn_last_four) + an HMAC blind index (ssn_hmac) for equality search. Names/DOB never got this treatment.
So gender/race/ethnicity plaintext is architecturally defensible and stable. Subject name + DOB plaintext is the genuinely-arguable part — DOB is a quasi-identifier and could take the SSN encrypt+blind-index treatment; names are the asymmetry vs. the encrypted reporter name.
The decision to make
Pick a posture and record it in an ADR (do not silently leave it accidental):
- Harden — extend the SSN encrypt+blind-index pattern to subject name + DOB. Equality search is preserved via blind index; fuzzy name matching needs a tokenized/normalized blind-index scheme (or a deliberate move of fuzzy matching off the encrypted column). Requires a migration + matching-engine rework.
- Formally accept — document the threat model in an ADR:
personsis the authz-gated operational record; compensating controls are role-based access (craig-authz), audit, and at-rest disk/volume encryption; the intake edge stays encrypted because it is the broadest-exposure surface. Demographic categoricals stay plaintext by design for reporting. - Hybrid — encrypt DOB with a blind index (equality suffices for matching), keep names plaintext (or tokenized) for trigram, keep categoricals plaintext. Closes the quasi-identifier gap at lowest cost.
Acceptance criteria
- An ADR records the chosen posture (harden / accept / hybrid) with the threat model + rationale.
- If harden/hybrid: a migration + blind-index plan and a matching-engine impact assessment, filed as follow-up implementation issue(s).
- If accept: the compensating controls (authz, audit, at-rest encryption) are documented as the posture, and
the
coding-conventions/security Antora page reflects the deliberate boundary. - The reporter-edge vs. subject-master asymmetry is explicitly addressed (not left implicit).
Context
- Type: design-first (ADR before any code).
- Priority: real confidentiality consideration, but pre-1.0 with no production data and authz-gated — deliberate, not urgent.
- Discovered during NCANDS export D1 (#651 (closed)); related, not blocking.