ADR-020: Encryption Fail-Closed Semantics for PII Field Helpers

On this page

Status

Accepted (2026-04-29). Implemented in Platform Stabilization plan § Step 2.

encrypt_field / decrypt_field (and the two decrypt_persons_pii / decrypt_person_pii helpers) now return Result<_, ApiError>; cipher failures map to 500. The four-row §D1 semantics matrix is enforced by unit tests in services/craig-cases/src/api/encryption.rs (encryption_disabled_passthrough, encryption_enabled_round_trip, tampered_ciphertext_fails_closed, encryption_disabled_but_stored_appears_encrypted_500s).

Amendment (2026-06-29, #906). The deferred explicit ciphertext marker (Open questions §1) is now implemented. craig-crypto emits a self-describing envelope — MAGIC ("CGEF") || version || nonce || ciphertext || tag, base64-encoded — and FieldEncryptor::is_encrypted checks the magic header instead of the base64-length heuristic. This eliminates the heuristic’s false-positive on base64-shaped plaintext (a pasted token/hash, or any TEXT field ≥ ~40 base64 chars), which had produced an unauthenticated-reachable 500 on encryption-disabled reads via the intake edge. The fail-closed decision and the §D1 behavior matrix below are unchanged — only the ciphertext-identification mechanism changed, and it is now reliable. Pre-1.0 on-disk format break: reseed (no production data; no backfill migration). Supersedes the "heuristic retained" note in §D1 and resolves Open questions §1.

Amendment (2026-07-13, C3/ADR-049 as-built, #1021). The encrypt_field / decrypt_field helpers this ADR introduced are retired (C3 MR6, #1020): field encryption/decryption is now the registry-driven craig_search::row walk (encrypt_row/decrypt_row over the craig-cases-fields registries), and the per-entity encrypt_*pii/decrypt*_pii helpers are thin wrappers that fix the entity and map errors. The fail-closed decision and the semantics matrix below are unchanged — the matrix is preserved, uniformly typed, in craig_search::SearchError: keyless + RequiredMissingKeyRequired; stored ciphertext with no key → CiphertextWithoutKey; both map to PII-redacted 500s via craig-common’s `search feature. The internal 500 detail strings changed with the unification (pre-1.0; disclosed in CHANGELOG.adoc); statuses and fail-closed behavior did not. The matrix tests moved with the mechanism: crates/craig-search/tests/row.rs (the framework matrix, including tamper + stale-shape fail-loud) plus the wrapper-level tests in services/craig-cases/src/api/encryption/. See ADR-049 (its Status note carries the as-built record). Code snippets and file anchors below are the original 2026-04-29 record, kept for history.

Amendment (2026-07-14, epic &66 as-built, #988). Field encryption now runs end-to-end in the devstack (C7, #987) — this ADR’s fail-closed semantics are exercised keyed on every dev start, pre-push battery, and E2E run, not just in unit tests. What changed around the decision (the decision itself is unchanged):

  • Key lifecycle is ADR-048's: the dev key is a gitignored xtask-generated devstack/field-key.env mounted only into craig-cases + craig-seed (both required); CI materializes the same file from the masked+protected CRAIG_FIELD_ENCRYPTION_KEY CI/CD variable (C4-gate, #984/#1012 — landed 2026-07-14 with the compose seed-completion gate). A key change (or absent .devstack/field-key.kcv marker) forces a full-volume wipe + keyed reseed automatically — key↔data drift is a staleness dimension, not a runbook step. (Updated 2026-08-10, ADR-064/#1385: the key’s SOURCE moved to the repo-committed encrypted store — recipients adopt it locally via rotation-by-commit, and CI decrypts it with the CRAIG_CI_AGE_KEY identity; the wipe+reseed escalation and the runtime CRAIG_FIELD_ENCRYPTION_KEY env contract are unchanged. See ADR-048 §D5’s U7 amendment.)

  • The "operational misconfiguration" row got a startup guard. The semantics table’s read-time 500s (absent | true, wrong key) are now largely pre-empted by the craig-cases boot verify-only (ADR-048 §D3, boot_verify): scheme metadata → KCV recompute → canary decrypt against crypto_key_lineage, refusing startup — with the remedy in the refusal — on a wrong key, keyless data under a keyed boot (or vice versa), or ciphertext with no lineage anchor. The request-time 500 remains the backstop for drift that arises after boot.

  • Seed determinism: the seeder encrypts as a render pass between manifest-render (plaintext) and SQL-render (ciphertext), so the committed manifest + byte-identity guard stay deterministic while the gitignored rendered SQL carries real envelopes; verify-seed --expect keyed asserts every covered column at rest.

  • Searchability is governed by ADR-049 (C3): an encrypted column is structurally unsearchable through the field registry — the reports search parameter was repointed from encrypted columns (which silently matched nothing) to plaintext admin_unit/reporter_type. Fail-closed now extends to queries, not just reads/writes.

  • The table’s "encryption-disabled mode is a documented dev-loop path" parenthetical is historical: since C7 the dev loop runs required like production. Keyless optional mode remains supported (and fail-closed per this matrix) for unit-test contexts and non-PII deployments.

Related: ADR-014 (shared client patterns; unrelated but a similar shared-utility-with-error-semantics precedent).

Context

services/craig-cases/src/api/encryption.rs:15-43 currently implements field-level PII encryption with a fail-open posture:

pub fn encrypt_field(encryptor: Option<&FieldEncryptor>, value: &str) -> String {
    match encryptor {
        Some(enc) => enc.encrypt_str(value).unwrap_or_else(|e| {
            tracing::warn!("field encryption failed, storing plaintext: {e}");
            value.to_string()
        }),
        None => value.to_string(),
    }
}

When encrypt_str returns Err (e.g., cipher error from a misconfigured key, OOM during encryption, or any other failure mode), the function logs a warning and stores plaintext. The decrypt path mirrors this: decrypt_str failure returns the original (possibly tampered) ciphertext blob to the caller.

This violates federal compliance posture for child-welfare PII:

  • 45 CFR Part 1355 — Title IV-B/IV-E record-retention and access controls require protected information to be safeguarded with encryption at rest where applicable.

  • 42 USC §5106a — CAPTA confidentiality requirements: child-abuse report data must not be disclosed except as authorized.

  • NIST SP 800-53 (referenced by ACF for CCWIS security assessments) — SC-13 (Cryptographic Protection) requires "the system implements cryptographic mechanisms". Silent plaintext fallback is the absence of cryptographic mechanism on that path.

The external review (2026-04-28) flagged this as the highest-severity P0 finding in the audit. An offline-worker app that replicates encrypted fields to a phone or tablet amplifies the risk: any caseworker device that experienced a transient cipher error during field write would carry plaintext PII out of the building.

Affected callsites (exhaustive)

All five callsites are in services/craig-cases/src/api/persons.rs:

Line Context

172

create_person body construction — encrypts ssn_last_four

195

create_person response after read — decrypts via decrypt_person_pii

226

get_person after fetch — decrypts via decrypt_person_pii

263

update_person body construction — encrypts ssn_last_four

288

update_person response after read — decrypts via decrypt_person_pii

The two helper fns in encryption.rs (decrypt_person_pii, decrypt_persons_pii) currently mutate Person in place; they will propagate Result<(), ApiError> post-ADR.

Decision

encrypt_field and decrypt_field change signatures from String to Result<String, ApiError>. Callers propagate via ?. The Result becomes a 500 "Internal Server Error" via the existing ApiError machinery; the response body uses the standard problem-details shape.

pub fn encrypt_field(
    encryptor: Option<&FieldEncryptor>,
    value: &str,
) -> Result<String, ApiError>;

pub fn decrypt_field(
    encryptor: Option<&FieldEncryptor>,
    value: &str,
) -> Result<String, ApiError>;

pub fn decrypt_person_pii(
    encryptor: Option<&FieldEncryptor>,
    person: &mut Person,
) -> Result<(), ApiError>;

pub fn decrypt_persons_pii(
    encryptor: Option<&FieldEncryptor>,
    persons: &mut [Person],
) -> Result<(), ApiError>;

Semantic table

Encryptor state is_encrypted(value) Behavior

present

(encrypt path)

encrypt; cipher Err → 500 (field encryption failed)

absent

(encrypt path)

return value as-is (encryption-disabled mode is a documented dev-loop path; production deploys must set CRAIG_FIELD_ENCRYPTION_KEY)

present

true

decrypt; cipher Err → 500 (field decryption failed)

present

false

return value as-is (legacy plaintext from before encryption was enabled)

absent

true

500 (encryption disabled but stored value appears encrypted — operational misconfiguration)

absent

false

return value as-is

Ciphertext is identified by a self-describing envelope header (MAGIC || version) that FieldEncryptor::is_encrypted checks — see the 2026-06-29 amendment (#906). (This began as a base64-length heuristic; the explicit-marker upgrade in Open questions §1 was deferred until the first real false-positive surfaced, which #906 was.)

Why fail closed

A 500 on cipher failure is louder than a tracing::warn!. Operationally:

  • Caseworker workflow: a transient cipher error blocks the create/update; the worker retries; if the failure persists, an operations incident is opened. No PII has left the system in plaintext.

  • Audit posture: every cipher failure becomes a queryable event in audit_log (via the existing wildcard subscriber) instead of a log line that may be filtered by retention.

  • Federal compliance: aligns with NIST SC-13 and CAPTA disclosure constraints — the system either encrypts or refuses to write.

The cost — occasional 500 on transient cipher failure — is acceptable. The alternative (silent plaintext) is not.

Consequences

  • All five persons.rs callsites become let ssn = encrypt_field(…​)?; etc. Compile- time enforcement of the new signature.

  • decrypt_person_pii and the batch variant return Result<(), ApiError>. Callers in get_person, create_person (post-insert read), and update_person propagate.

  • New integration tests ship with the implementation (Step 2):

    • encryption_disabled_passthrough

    • encryption_enabled_round_trip

    • encryption_failure_fails_write (inject tampered ciphertext via direct DB UPDATE; subsequent GET returns 500)

    • encryption_disabled_but_stored_appears_encrypted_500s

  • The FieldEncryptor API in crates/craig-crypto/ does not change — the fail-closed policy lives in the helpers, where the decision belongs. (Amended #906: the public signatures still do not change, but the on-disk ciphertext format gained a MAGIC || version header — see the Status amendment.)

  • No data migration. Existing rows continue to be served correctly because the is_encrypted heuristic distinguishes plaintext-from-pre-encryption from ciphertext-with-cipher-error. (Amended #906: the heuristic was replaced by the self-describing envelope; that is a pre-1.0 on-disk format break — reseed, no backfill, since no production data exists.)

Open questions

  1. Marker prefix (enc:v1:)Resolved (#906, 2026-06-29): shipped as a binary MAGIC || version envelope header (not a string prefix), which eliminates the heuristic ambiguity. The predicted false-positive surfaced — base64-shaped plaintext 500’d on encryption-disabled reads — so the deferred marker landed. No backfill: pre-1.0 reseed, no production data. See the Status amendment.

  2. Decrypt-on-list performancedecrypt_persons_pii is called on list endpoints. Fail-closed means a single bad row 500s the entire list. Mitigation: catch the row- level error, surface a per-row sentinel, return the rest. Defer this question until it’s a measured problem.

Alternatives considered

A. Status quo (fail open with tracing::warn!)

Rejected. Federal compliance posture (NIST SC-13, CAPTA confidentiality) does not permit silent plaintext storage. The audit log and ops process need a stronger signal than a warn-level trace.

B. Panic on cipher error

Rejected. Panicking takes down the entire service for one row. A 500 propagated to the caller surfaces the problem at the right boundary (the request that caused it) without affecting other in-flight requests.

C. Quarantine table for cipher failures

Rejected. A quarantine row is silent plaintext stored elsewhere — same federal compliance problem, plus operational complexity of a second table to monitor. The fail-closed 500 is simpler and louder.

D. Lazy decryption only on access

Rejected. Encryption is the default for ssn_last_four and similar fields; lazy decryption would mean no PII in API responses until requested individually. Doesn’t fit the existing "list returns full Person" contract.

Edit this page · latest