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. |
|
Amendment (2026-07-13, C3/ADR-049 as-built, #1021). The |
|
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
|
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 |
|
195 |
|
226 |
|
263 |
|
288 |
|
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 ( |
absent |
(encrypt path) |
return value as-is (encryption-disabled mode is a documented dev-loop path; production deploys must set |
present |
true |
decrypt; cipher Err → 500 ( |
present |
false |
return value as-is (legacy plaintext from before encryption was enabled) |
absent |
true |
500 ( |
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.rscallsites becomelet ssn = encrypt_field(…)?;etc. Compile- time enforcement of the new signature. -
decrypt_person_piiand the batch variant returnResult<(), ApiError>. Callers inget_person,create_person(post-insert read), andupdate_personpropagate. -
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
FieldEncryptorAPI incrates/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 aMAGIC || versionheader — see the Status amendment.) -
No data migration. Existing rows continue to be served correctly because the
is_encryptedheuristic 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
-
Marker prefix (
enc:v1:) — Resolved (#906, 2026-06-29): shipped as a binaryMAGIC || versionenvelope 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. -
Decrypt-on-list performance —
decrypt_persons_piiis 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.