# PHI Pseudonymization Design — Offshore Review Boundary **Status:** Design (implementation deferred to a later phase) **Applies to:** `governance.PseudonymousCaseRecord`, `governance.PseudonymousClinicalPacket`, offshore reviewer role **Related:** [Security_Access_Control_Plan.md](Security_Access_Control_Plan.md), [Database_Schema_Draft.md](../architecture/Database_Schema_Draft.md) §`pseudonymous_case_records`, client requirement #13 (offshore separation) ## 1. Purpose Offshore clinical reviewers must be able to work with case-level clinical data **without any path to identity information** (name, DOB, email, phone, MBI, address). This document defines how the `case_id` pseudonym is generated, how packet contents are de-identified, and where the trust boundary sits. The database schema scaffold (`PseudonymousCaseRecord.case_id`, `PseudonymousClinicalPacket`) already exists; this design specifies the missing generation mechanism. ## 2. Requirements 1. **Stability** — a reviewer's case ID must not change between sessions or across care periods for the same beneficiary, so review notes stay linked to the right person. 2. **Irreversibility without the key** — `case_id` must not be derivable from `beneficiary.id` (or any other identifier) without the pseudonymization key. 3. **Separation of keys** — the pseudonymization key is a *different* KMS key from the PHI field-encryption key (`PHI_KMS_KEY_ID`). Compromise of one must not yield the other. 4. **No identity leakage in packets** — `PseudonymousClinicalPacket.structured_data` must satisfy Safe Harbor (or an approved Expert Determination) before it is reachable by the offshore role. 5. **Auditability** — creation of case records, any re-identification attempt, and key access are audit events. ## 3. Case ID Generation ### Format ``` case_id = "cf-" + hex(HMAC-SHA256(key, "access-case:" || beneficiary.id.uuid4.bytes)[:16]) └─ 35 characters total (e.g. cf-9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c) ``` ### Algorithm ```python import hashlib, hmac def generate_case_id(beneficiary_id: uuid.UUID, key: bytes) -> str: digest = hmac.new(key, b"access-case:" + beneficiary_id.bytes, hashlib.sha256).digest() return "cf-" + digest[:16].hex() ``` - **HMAC-SHA256** (not plain `SHA256(key || id)`) — prevents length-extension and chosen-prefix attacks on the key. - **Deterministic in beneficiary only** (not care_period): one stable ID per person across all `PseudonymousCaseRecord` rows, satisfying requirement 1. The model's `care_period` FK continues to scope which *packets* belong to which period. - **128-bit output** — collision probability is negligible at expected scale (<10⁴ beneficiaries), and the namespace is keyed per environment anyway. ### Key management | Property | Value | |---|---| | KMS key purpose | `PHI_PSEUDONYMIZATION_KEY_ID` (new alias, distinct from `PHI_KMS_KEY_ID`) | | Algorithm | Symmetric HMAC key material retrieved via `KMS Decrypt` of a wrapped DEK, or used directly if the KMS key type supports export-free HMAC (preferred: wrap a 32-byte DEK) | | Environments | Separate key per environment; dev/test use local keys like the field-encryption layer (`PHI_PSEUDONYMIZATION_LOCAL_KEY`) | | Rotation | Only on incident. Rotation changes all case IDs; mitigation is a dual-read window (old key retained N days, both accepted on read) — see §7 | ## 4. Packet De-identification (Safe Harbor mapping) `PseudonymousClinicalPacket.structured_data` is produced by a de-identification transform applied **server-side, in-region only**. Mapping of the 18 Safe Harbor elements to this schema: | Element | Treatment | |---|---| | Name / email / phone / address / MBI | Never included (transform never reads them; only DOB is encrypted at rest today, so exclusion relies on the packet allowlist) | | All dates directly related to a person | DOB → `age_band` (e.g. "65-74", computed from the decrypted DOB in-process, raw date discarded); encounter/collection dates → quarter-year buckets or day-offsets from care-period start | | Geographic subdivisions smaller than state | Zip → 3-digit prefix only if population > 20k, else dropped | | All elements not listed above (IDs, device serials, URLs) | Stripped by allowlist: packet schemas enumerate permitted keys; unknown keys are rejected at write time | `deidentification_method` records the applied standard (`safe_harbor_v1`) and transform version so review can be re-run if the standard changes. `review_status`/`reviewed_by` support a physician spot-check gate before packets become visible offshore (see open questions §8). ## 5. Trust boundary ``` [In-region app] [Offshore API surface] Beneficiary + clinical data PseudonymousCaseRecord │ ▲ ▼ │ case_id-scoped queries only pseudonymize_care_period (service, deferred) │ RBAC: offshore_reviewer cannot ├─ generate_case_id(beneficiary.id) │ SELECT identity.* or traverse └─ build PseudonymousClinicalPacket │ the beneficiary FK ``` - The `beneficiary` PROTECT FK on `PseudonymousCaseRecord` exists for **system linkage only**. Offshore queries must be written against `case_id` exclusively; RBAC denies the offshore role access to `identity_beneficiary` and any view joining through it (enforced by role-scoped table permissions, not application convention). - The re-identification mapping (`beneficiary.id → case_id`) is reproducible **only** with the KMS key. No plaintext mapping table is stored in RDS or S3. ## 6. Audit | Event | Logged via `governance.AuditEvent` (or equivalent) | |---|---| | Case record created | actor=system, target=`PseudonymousCaseRecord`, metadata includes care_period id (never DOB/name) | | Packet de-identified | method + transform version | | Re-identification key access | KMS CloudTrail → ingested to audit log; any `KMS Decrypt` on the pseudonymization key is a security alert | ## 7. Key rotation & incident response 1. Create new key, mark old key **retired (read-only)**. 2. Dual-read window: accept both keys when *validating* inbound case IDs (generation always uses the active key). 3. After N days (default 90), schedule old-key deletion; case IDs produced under it become unrecoverable — acceptable because they are regeneratable from `beneficiary.id` + retired key until deletion, and reviewer notes can be re-mapped during the window by an admin operation. ## 8. Open questions (legal/compliance input required) | # | Question | Default assumption | |---|---|---| | 1 | Safe Harbor vs Expert Determination for offshore packets? | Safe Harbor (documentable, no periodic expert re-certification) | | 2 | Is a physician pre-review gate (`review_status`) mandatory before offshore visibility? | Yes, until legal signs off on direct visibility | | 3 | Cross-border transfer mechanism under HIPAA (BAA with offshore vendor vs Expert Determination)? | BAA + pseudonymous-only scope | | 4 | Retention of the re-identification key after program end / participant withdrawal? | Until data retention schedule resolved | ## 9. Follow-ups outside this document's scope - **Full PHI field rollout** (name, email, phone, MBI, address → `EncryptedField`): the DOB cutover (`intake/0004`, `identity/0004`) establishes the pattern; remaining fields need per-field migrations plus HMAC sidecar indexes to preserve uniqueness/equality lookups (e.g. intake dedupe by email) without decrypting at query time. - **Runtime service** `pseudonymize_care_period` + management command, triggered on CarePeriod activation. - Offshore API endpoint hardening tests asserting FK traversal is impossible for the offshore role.