ADR-048: Field-Encryption Key Lifecycle — Devstack/CI Storage, Seeding, Lineage & Activation
On this page
Status
Accepted (2026-07-10). Resolves the design forks blocking epic &66
(Make craig-cases Field Encryption Work End-to-End).
Builds on ADR-020 (fail-closed field-helper
semantics + the CGEF self-describing ciphertext envelope) — this ADR adds the operational key
lifecycle around that primitive and does not change ADR-020’s runtime field-helper semantics. The C8
doc child (#988) amends ADR-020 to cross-reference this ADR rather than duplicating it.
Amendment (2026-07-12, C4-floor as-built, #1011). The C4 decision text below is unchanged in intent; these are the as-built refinements discovered during implementation:
-
D2 concurrency — no advisory lock. The per-DB
_seed_markeruses a shell pre-check (seed.sh)
ON_ERROR_STOPon everypsql+ deterministic-PK fail-loud, not the "advisory lock + post-lock recheck" §D2 sketched. All 34 seeded tables carry an explicit deterministicid UUID PRIMARY KEYand each per-DB file is one atomicBEGIN…COMMIT, so a concurrent/second seeder hits a duplicate-key thatON_ERROR_STOPturns into a loud non-zero exit rolling back the whole transaction — safe by failing, never a silent partial commit. The lock only bought graceful-skip-under-race at the cost of a server-sideERRORlogged in all six DBs on every re-run + fragile stderr-sentinel parsing. -
D2 marker placement — wrapped, not embedded.
seed_marker::wrap_sql_phaseinjects the marker into the already-rendered transaction (inwrite_output), so the byte-identity guard (which callsrender_*_sqldirectly) is untouched — no re-bless. The identity’scontent_digestis over the plaintext (pre- encryption) renders and stays so under C5; the key enters only via the KCV component. -
D1 recreation test — read-only. Automated as a
SHOW data_directoryassertion (the PG18 path only holds if the named volume is mounted at the parent); the complementary "no stray anonymous parent"docker inspectcheck is a one-time transition step in Local Development rather than a force-recreating test (which would disrupt a concurrent devstack). -
Completion gate split out (CI deferred). The D6/§Consequences "`ON_ERROR_STOP` floor + completion gate ship together" pairing is delivered for local dev by
verify_seedon every xtask bring path plus a newverify_seedinauto_refresh(dev refresh). The declarative composeservice_completed_successfullygate + the garage-init compose one-shot + CI seed-exit fail-loud are deferred to a follow-up C4-gate (CI is currently unreliable and out of scope). The C4 table’s other items (lineageINSERT,kcv()/canary wiring, D5 provisioning, the verify-seed skeleton) are NOT in C4-floor — they remain in C5/C4d/C7 per the decomposition.
Amendment (2026-07-12, C4d as-built, #984). D5 provisioning tooling landed for local dev, with two scope refinements from adversarial review:
-
Windows/non-unix strategy (D5 was silent). On
cfg(unix)the generateddevstack/field-key.envis hardened to0600with a parent-dir fsync; on other platforms it is a best-effort atomic write plus a loud "permissions not hardened — use WSL2, or supply the key yourself" warning (user-chosen 2026-07-12). Publication is a temp file +persist_noclobber(no-clobber rename), which is symlink-safe on write and race-safe against a locklessvalidate→`ensure_ready`; a literalO_NOFOLLOWopen is moot because C4d only writes (the read path is C7). An existing file is validated + reused (never re-keyed); loose perms are repaired; a symlink/non-regular target is refused. -
CI variable-first + verify-seed deferred out of C4d. CI never runs the local generate-once tooling (
--skip-devstack+ rawdocker compose up), so the D5 CI variable-first delivery would be dead code + a second CI signal — it rides with the deferred C4-gate. Theverify-seedsubcommand is deferred to C6: the existingverify_seedreads only{{.ExitCode}}, so a still-running one-shot false-succeeds; a correct exposure (exited-success / poll-with-timeout + the lifecycle lock) belongs with C6’s verification harness rather than a placeholder over a known-broken primitive.
The generated key stays dormant (no compose mount, nothing reads it) until C7 activates encryption.
Amendment (2026-07-12, C5 as-built, #985). The encryption-capable seeder landed, keyless→plaintext until C7:
-
D2 identity, realized.
write_output’s `_seed_identitycontent digest is over the plaintext renders (cases with a sentinel lineage row) andcompute_identityfolds in the realFieldEncryptor::kcv()when keyed (KCV_SENTINELkeyless). The randomized ciphertext (random nonce) + the realcrypto_key_lineagerow are written to the cases output file only — never the identity — so the identity is stable for a fixed(data, key)yet key-bound (a key change flips it → reseed). The localseed_marker::FORMAT_VERSIONdup was dropped in favour ofcraig_crypto::FORMAT_VERSION_V1. -
Lineage matches the as-built migration. The seeder’s
INSERT INTO crypto_key_lineagesupplies(kcv, kcv_version, canary, envelope_version, algo)— includingkcv_version, which the §D3 prose above omits; the migration + its constraint tests are authoritative. -
Fail-closed load (D4).
CRAIG_SEED__ENCRYPTION_MODEaccepts only absent (⇒ optional),optional,required; any other value is a hard error. A malformed/wrong-length key is fatal in both modes (only an absent key is tolerated, and only in optional mode) — deliberately unlike cases'load_encryptor, which swallows a malformed key under optional. -
D7.
CASES_SHA256re-blessed once (thessn_hmaccolumn + the sentinel lineage row shifted the keyless cases bytes; the other five renders are byte-identical); proven by a round-trip proptest, a semantic encrypted-render test (decrypts every covered value from the real render), a plaintext-leak test, and a real-binary key/mode matrix.
Amendment (2026-07-13, C6 as-built, #986). The D6 verification harness landed (keyless devstack; the encryption-asserting cases light up at C7):
-
Expected-mode oracle, not auto-detect.
cargo xtask verify-seed --expect {keyless,keyed}asserts the intended mode. The §D6 text below implies reading the stored state and checking it; the as-built makes the caller declare the mode, because an auto-detecting gate is circular — a silent C7 key-mount failure emits plaintext + the sentinel KCV, which an auto gate passes vacuously. C6 wires--expect keyless(validate devstack phase + the non-blockingpentestCI backstop); C7 flips both to--expect keyed. -
Completion poll fixed (the C4d-deferred concern, realized).
devstack::verify_seednow polls{{.State}}|{{.ExitCode}}through a checked compose capture + a pureclassify_seed, replacing the{{.ExitCode}}-only read that misclassified a still-running one-shot (exit0) as success and a dead daemon asOk(""). Both callers (bring_up,auto_refresh) inherit it. Since #1420 an absent container (the exited one-shot pruned by routinedocker container prune) is no longer a hard error: the poll falls back to the durable_seed_markersql-phase row (§D2, "marker present ⟺ committed") and refuses only when container AND marker are both absent — a genuinely unseeded stack. -
At-rest coverage via a throwaway DB, not a lift to
craig-test-lib. The in-process keyed router runs each test against a per-testcraig_cases_enctest_<uuid>database (created + migrated + dropped onDrop), returning itsFieldEncryptor— isolating the random key from the shared DB (an isolation improvement over C2’s shared-DB + row-cleanup). Lifting the harness tocraig-test-libwas rejected (it would drag five service crates into the shared test lib). An API-created-report test proves the 4 TEXT
3 JSONB report columns encrypt at rest (the seed emits no reports); a decrypt-through-service round-trip checks seeded persons/referrals through the live API against thecraig_seed::generateoracle. -
Hardened key loader.
xtask::field_key::load_field_key(used by the keyed branch; reused by provisioning) rejects symlink/non-regular/loose-perms, requires exactly one key assignment, and is TOCTOU-safe via a stat→open→fstat(dev, ino)inode re-check — theO_NOFOLLOWintent (D5) achieved without a libc/rustix dependency, and now on the read path C4d deferred. -
from_envboundary + sentinel home. Two gaps (valid-base64-wrong-length, non-Unicode) added to C5’s real-binary key/mode matrix (absent/valid/malformed already covered).KCV_SENTINELmoved to its canonical home incraig-crypto(value unchanged ⇒ byte-identity stable). -
Negative test → C7. The restart-under-wrong-key ⇒ refuse-boot negative test moved to #987 (its boot verifier must exist first); the
#986AC moved with it, soCloses #986strands nothing.
Amendment (2026-07-13, C3 as-built, #1021). The C3 sub-epic (&67, ADR-049) landed; three references below are superseded as-built:
-
Field-helper anchors.
api/encryption/field.rs(cited in §Context, and in §D6’s pass-through rationale) is deleted: the fail-closed read/write matrix now lives in the registry-drivencraig_search::rowwalk, uniformly typed asSearchError(stored ciphertext with no key →CiphertextWithoutKey, still a hard 500), per the ADR-020 amendment of the same date. §D6’s point survives unchanged — the walk still passes legacy plaintext through on a keyed decrypt (pinned by acraig-searchrow test), so a decrypt-through GET remains non-proof and the exhaustive raw-DB assertions stay necessary. -
§Covered columns is now registry-derived. The covered set is declared once in the
craig-cases-fieldsregistries and consumed viacraig_search::encrypted_fields()by the service write/decrypt paths, the seeder, andverify-seed— the §Covered-columns prose below stays accurate as a snapshot, but the registries (not this prose) are normative.encrypt_field/encrypt_jsonb_valueare retired; JSONB fields keep the same{"v": "<ct>"}envelope, now emitted by the walk. -
The SSN blind index is per-field domain-separated.
ssn_hmacmoved off the single globalBLIND_INDEX_INFO_V1domain ontohmac_domain("ssn", 1, …)(BLIND_INDEX_SCHEME_V1), derived through the one sharedFieldSpec::blind_indexat every site (service write, planner query, seeder,verify-seed). Pre-1.0 breaking for keyed data: a keyed deployment reseeds (devstackdown -v); C7’s activation reseed subsumes this, and D3’s KCV domain (craig-crypto/kcv/v1) remains disjoint.
Amendment (2026-07-13, C7 as-built, #987). Activation landed; the devstack is keyed end-to-end. Three as-built refinements against the D3/D4 prose above:
-
The D3 boot matrix, realized in
craig_cases::boot_verifywith two refinements. (1) Scheme metadata (kcv_version/envelope_version/algo) is checked BEFORE the KCV, so a format/scheme migration refuses with its own error rather than masquerading as a wrong key (KCVs across schemes are incomparable). (2) The "noCGEF-bearing rows" test in the lineage-absent branch is a registry-driven scan (the samecraig-cases-fieldsregistries every boundary consults, all five entities): a SQLLIKEprefilter on the newcraig_crypto::ENVELOPE_B64_PREFIX(the base64 encoding of the magic’s first three bytes — a necessary condition, pinned toENVELOPE_MAGICby test) bounds the fetch, and each candidate is confirmed with the exact structuralFieldEncryptor::is_encrypted, so plaintext can never refuse boot. The verifier runs after migrations and before the authz engine/workers/listener. A sentinel lineage row under a keyed boot refuses (as the seeder’s canary-sentinel doc anticipated); a real-KCV row under a keyless boot refuses (hoisting ADR-020’s request-time 500s to a startup refusal). -
The D3 host-KCV→wipe guard is a dedicated marker, not a file hash.
.devstack/field-key.kcvrecords the provisioned key’s KCV (never raw key material; the file stays excluded frominfra.sha256per §D5/C4d); absence or mismatch escalates toVolumeAction::Wipe+ reseed — the same mechanism a modified migration uses, ridingauto_refresh’s bounced (wipe) path, which stays on the safe side of the #1005 marker-ordering bug. Absence-⇒-wipe is what makes the first post-C7 `dev startself-activating (§D4’s coordinated recreation with no manual step). -
§D6 keyed flip + the CI half. Both
verify-seedcall sites now assert--expect keyed. The CI backstop (and every raw-compose CI devstack job) fail-closes on the missingenv_fileuntil the deferred C4-gate delivers the §D5 CI variable-first key — red-but-allow_failure, named here rather than hidden. The #986-deferred restart-under-wrong-key negative landed as theboot_verifyintegration matrix over the C6 throwaway-DB harness (key-A lineage + key-B encryptor ≡ the restart, which a fresh empty stack cannot exercise).
Amendment (2026-07-14, C4-gate as-built, #1012 + #984 close-out). The deferred declarative/CI half landed; the epic’s last child. As built:
-
Completion gate.
craig-webgates oncraig-seed: service_completed_successfully, and the seed gates on a new in-graphgarage-initcompose one-shot (devstack/garage-init/, alpine
curl/jq against the Garage admin API ongarage:3903— endpoints verified against the live v2.2.0 instance). Anydocker compose up— dev or the raw CI jobs — now fails loud on a bad seed ("didn’t complete successfully"), and the seed’s attachment upload can never race an uninitialized bucket: proven live, theattachmentsmarker lands on the FIRST fresh start (pre-gate it needed a second run). The duplicated bootstrap wrappers are retired: xtask’sinit_garage(bring_up
auto_refresh) anddevstack/ci/garage-init.sh;devstack/ci/wait-healthy.shis superseded bydocker compose up -d --wait --wait-timeout 900in the three raw-compose CI jobs (the success-direction--waitover completed one-shots verified live). -
§D5 CI variable-first key. The three CI jobs materialize
devstack/field-key.envfrom the masked + protectedCRAIG_FIELD_ENCRYPTION_KEYproject variable viadevstack/ci/write-field-key.sh— unset ⇒ hard error naming the fix (never a keyless CI devstack). Protected means a manual perf-stress run on an unprotected ref hard-errors by design. The variable itself is maintainer-provisioned (openssl rand -base64 32; rotate freely — every CI devstack seeds from scratch). -
Deliberate-break proof (AC1). A broken seed (non-numeric
CRAIG_FAMILIESfailing the seeder’s arg parse) makes a rawdocker compose up -d --waitexit non-zero with craig-web never started — the gate, exercised for real.
Amendment (2026-08-10, ADR-064 U7 as-built, #1385 — the CI half switches to the
encrypted store). §D5’s CI half no longer materializes devstack/field-key.env from a
per-secret CI/CD variable: devstack/ci/write-field-key.sh now decrypts the
repo-committed store (secrets/dev.yaml, ADR-064) with the CI age identity
CRAIG_CI_AGE_KEY inside the digest-pinned devtools container (--network none,
read-only, cap-drop, host-UID; the identity reaches sops only as a read-only file
mount), validates the output (single line, base64 for exactly 32 bytes) BEFORE touching
the target, and publishes atomically with the prior file retained on any failure —
so a store rotation reaches CI on the next run with no variable edits. Pre-1.0
switchover with NO dual path: the script never reads the old
CRAIG_FIELD_ENCRYPTION_KEY variable, which stays inert as a rollback window until
gate G-C (#1031) deletes it. Test seams (CRAIG_SECRETS_FILE / CRAIG_SOPS_CONFIG /
CRAIG_FIELD_KEY_OUT / CRAIG_DEVTOOLS_CONTEXT) isolate the in-battery docker proof
from real files. Scheduled scan jobs keep their protected-ref posture structurally
(schedules target maintainer-configured refs; a manual run on an unprotected branch
hard-errors in the script by design, unchanged doctrine).
Amendment (2026-07-25, #1118 — the rules phase gets its own convergent identity). §D2’s single
identity ("any fixture-affecting change flips it → reseed") was too coarse for one phase: rule sets
are loaded via the craig-rules API into a DB the API can also update, so a ruleset edit never
needed a volume wipe — yet the identity fold forced one, and the phase’s silent-stale failure modes
(a version bump answered 409 "already exists"; the SQL fallback’s ON CONFLICT (name) DO NOTHING;
the fallback masking a charset defect that 422’d every underscore-named authz ruleset, leaving the
rules marker never-written on live devstacks) hid behind it. As built:
-
Identity split.
--extra-fingerprintnow folds onlytests/fixtures/+seed.sh; the rules phase carries its ownRULES_FP(sha256 overrulesets/) in the craig_rulesrulesmarker. A ruleset edit flips only the rules marker — the SQL-phase identity (and its wipe posture) is untouched, so a version bump lands ondev refresh/dev startwith no data wipe. -
Convergent, version-aware load. Per fixture file: GET by-name → absent ⇒ POST create; present at the fixture version ⇒ skip; any other version ⇒ PUT update (JDM recompiled, engine cache swapped,
ruleset.changednotified — the SQL fallback did none of these; it is deleted, and craig-seed’s compose gate on a healthy craig-rules means an unreachable API is a real failure). A fixture-vs-DB(name, version)receipt runs after the load; the marker is written only on full convergence, and any failure is fatal (the C4 gate then refuses the stack) — an incomplete authz surface must never present as debuggable-only-via-403s. -
Root cause.
is_valid_rule_set_name(810) banned, rejecting the system’s own derived authz names ({jurisdiction}-authz-{resource_type}); the charset now admits interior(no AMQP topic semantics —./*/stay banned), so every ruleset loads through the validated API path.
Context
Field-level PII encryption (craig_crypto::FieldEncryptor, AES-256-GCM-SIV) is implemented only in
craig-cases and is fail-closed per ADR-020: with a key loaded, decrypt_field reads mixed
plaintext/ciphertext rows via the per-value CGEF magic header, and "ciphertext present, no key" is a
hard 500 (services/craig-cases/src/api/encryption/field.rs:71-73; JSONB mirror report.rs:88-90). It
has never run end-to-end: the devstack sets optional mode with no key (docker-compose.yml:186) and
CI brings the stack up keyless, so the keyed path, the seed, key provisioning, and verification have
never been exercised. Turning it on — which production’s required mode already implies — is not a
one-line flip; it exposes a cluster of entangled infrastructure decisions that three independent design
reviews showed had to be resolved together, because each answer constrains the others: postgres storage,
seed idempotency, key↔data lineage, activation atomicity, secret delivery, and verification.
Each was researched independently (≥3 alternatives, current state-of-the-art, code-grounded) and the draft was hardened through two adversarial review rounds. The decisions below are mutually consistent and implementable on the current architecture; the "Interactions" notes make the couplings explicit.
Scope
In scope: the devstack/CI key lifecycle — the six decisions D1–D7 below.
Out of scope (owned by the deferred secrets-management program): production key delivery, key rotation (re-encrypting data to a new key), recovery beyond "supply the correct key", revocation, and destruction. Pre-1.0 production has no data, so none of these bite yet.
Shared-binary note (honest scoping). D3 changes the shared craig-cases binary (a migration + a boot check), not a devstack-only artifact. Its fail-closed boot is correct for production: an operator supplies the right key, or — if the key is genuinely lost — the data is unrecoverable by design, which is what encryption means. Only key rotation is deferred.
Threat model
This ADR assumes a passive database-disclosure threat model (at-rest confidentiality of PII). The cipher uses no storage-context AAD, so a valid ciphertext is replayable between rows/columns under the same key — an active-tamper / cell-swap threat that is out of scope here and recorded as a named follow-up (adding AAD is a ciphertext-format change for the threat-model/secrets program).
Covered columns
craig-cases encrypts: persons ssn_last_four (TEXT/CGEF) + ssn_hmac (a deterministic blind index
from the plaintext SSN, non-CGEF); referrals reporter_first_name/reporter_last_name/reporter_phone
(TEXT/CGEF, not reporter_relation); and on reports the TEXT fields reporter_* + narrative (via
encrypt_field) plus the JSONB-envelope {"v":"<ct>"} fields children/adults/raw_submission (via
encrypt_jsonb_value, report.rs:113-118). The seeder emits only persons.ssn_last_four and
referrals.reporter_* — there is no reports table in tools/craig-seed — so report-level encryption
is exercised by API-level integration tests, not the seed.
Decision
| # | Question | Decision |
|---|---|---|
D1 |
Postgres storage |
A named volume |
D2 |
Seed idempotency |
Skip-if-seeded + full-volume wipe — a per-database |
D3 |
Key↔data lineage |
The seeder writes an in-DB |
D4 |
Activation atomicity |
One shared key for seeder + cases, cases |
D5 |
Secret delivery |
A gitignored |
D6 |
Verification |
A |
D7 |
Byte-identity guard |
The reproducibility guard renders keyless (pins plaintext SQL); plus semantic encrypted-render tests that decrypt every generated field (ciphertext is unpinnable — random nonce). |
D1 — Postgres storage: a named volume at the correct PG18 path
Today the postgres service (docker-compose.yml:54-66) declares no volumes: and rides the
postgres:18-alpine image’s anonymous VOLUME. Adopt a named volume — but at the path PG18 actually
uses: postgres:18-alpine declares VOLUME /var/lib/postgresql with PGDATA=/var/lib/postgresql/18/docker
(verified via docker image inspect). Mounting the pre-18 path /var/lib/postgresql/data would leave the
real cluster in the anonymous parent volume and the named one empty — so the mount is
postgres-data:/var/lib/postgresql. Wipe semantics are unchanged (tear_down passes down -v,
xtask/src/devstack.rs:418, which removes named and anonymous volumes alike), while the volume becomes
discoverable (docker volume ls → craig_postgres-data) and — decisive for D3 — a stable, addressable
wipe target.
A container-recreation test guards the path: recreate postgres and assert SHOW data_directory =
/var/lib/postgresql/18/docker and docker inspect shows postgres-data mounted at
/var/lib/postgresql with no stray anonymous parent. Bind mount / tmpfs rejected as before (root-owned-file
friction; a dev persistence regression). Pre-1.0 devstack break: existing developers run a one-time
cargo xtask dev clean (documented in C8).
D2 — Seed idempotency: skip-if-seeded + full-volume wipe
The seeder emits plain INSERT for 32 of 34 tables (tools/craig-seed/src/sql_row.rs:41-43; only
admin_unit_registry + detection_rules use ON CONFLICT, sql.rs:80-92). Because postgres data
persists across a CachedRebuild up (xtask/src/devstack.rs:165-173 ups without down), the one-shot
craig-seed container re-runs against a populated DB. Today a duplicate-key error aborts the wrapping
BEGIN/COMMIT (sql.rs:74,94) and rolls back silently — seed.sh runs psql without ON_ERROR_STOP
(devstack/seed/seed.sh:116), so it exits 0. (D6’s floor turns that into a real failure; D2 makes the
re-run correct.)
Decision: the seed runs only against a fresh/empty DB, and a reseed is a full-volume down -v clean
slate — no in-place TRUNCATE, so the unbounded CASCADE closure (runtime-owned audit/outbox/attachment
tables, plus Garage objects and API-loaded rules a DB transaction can’t reach) never arises.
-
A per-database
_seed_marker(identity text, created_at)is written as the last statement inside each per-DB transaction (the generated.sqlis already oneBEGIN/COMMIT). So marker present ⟺ that DB fully committed — an interruptedpsql -frolls the whole DB back to empty and leaves no marker, so the next run re-seeds cleanly rather than colliding on a half-populated DB. -
identity= a digest of{CRAIG_SEED, CRAIG_FAMILIES, active-bundle content+version, canonical generator/manifest digest, encryption-format version, key KCV}— broad enough that any fixture-affecting change flips it. -
seed.shper DB:CREATE TABLE IF NOT EXISTS _seed_marker→SELECT identity→ match → skip (exit 0; no collision on a non-wipeup); absent → seed (the DB is empty — a reseed always arrives viadown -v); present + mismatch on a populated DB → fail loudly, non-destructively ("run `cargo xtask dev reseed`"). Never wipe in-place. -
A reseed’s
down -valso clearsgarage-data, so the non-SQL side effects (rules loaded via the Rules API, attachments) are clean-slate too — no stale rules, duplicate attachment blobs, or orphaned objects. -
This separates the D2/D6 contracts: a fresh/reseed/CI run is always the exact deterministic fixture (D6 verifies it); the "preserve developer mutations" case is simply "an unchanged
upskips", and D6 verification runs only after a fresh seed, never after a skip.
Blanket ON CONFLICT is rejected (a hand-maintained per-column list across 34 tables that would keep stale
ciphertext after a key change); in-place TRUNCATE is rejected for the destructive-closure reasons above.
D3 — Key↔data lineage: the seeder writes it, craig-cases verifies it
AES-256-GCM-SIV authenticates on decrypt: ciphertext written under key A fails under key B. Nothing today
binds the active key to the data. Add a singleton table
crypto_key_lineage(id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id), kcv TEXT, canary TEXT,
envelope_version INT, algo TEXT, created_at TIMESTAMPTZ) in craig_cases (migration), and a
FieldEncryptor::kcv() = base64( HKDF-SHA256(ikm = key, info = "craig-crypto/kcv/v1")[..8] ) — a
purpose-separated, key-derived check value (the crate already uses HKDF domain separation for the SSN
blind index, crates/craig-crypto/src/lib.rs:341-357; the distinct info keeps a KCV from ever
coinciding with a blind-index value). The canary is a fixed known plaintext encrypted under the key.
Both are one-way / decrypt-only and safe to expose (Kerckhoffs); the KCV is host-readable (recomputable in
Rust from the key without running the AES cipher).
-
Writer = the seeder, as a plain generated
INSERT. The seeder linkscraig-crypto(for the encryption pass), computes the KCV and encrypts the canary in Rust at generation time, and emits theINSERT INTO crypto_key_lineage …— atomic with the ciphertext in the same per-DBBEGIN/COMMIT. It needs no live DB connection and no in-SQL AES (which pgcrypto cannot do): on adown -vreseed the table is empty, so theINSERTsimply succeeds. (This is why the writer is the seeder, not a transactional read-verify: the file-emitting, DB-less seeder structurally cannot runSELECT … FOR UPDATE+ a canary decrypt.) -
Verifier = craig-cases (it has the key,
craig-crypto, and a live pool). Boot is verify-only: lineage row present → recompute the KCV and decrypt the canary, mismatch ⇒ refuse boot (whether or not ciphertext exists yet); lineage absent + noCGEF-bearing rows ⇒ boot; ciphertext present + lineage absent ⇒ refuse boot (unverifiable ⇒ fail closed). The production first-write "establish-or-verify lineage" on cases' write path (aSELECT … FOR UPDATEcases can run) is a prod-scoped concern and is deferred; in devstack the seeder pre-establishes the row and boot-verify confirms it, so cases writes proceed. -
Dev auto-reseed uses the path the launcher honors. The host key-KCV fingerprint feeds the guard’s
VolumeAction::Wipe(devstack_guard.rs:445) — the same mechanism a migration change uses, whichdecide_actionmaps to theStartAction::VolumeWipeexecutor (devstack.rs:149) for the primarydev startpath — notSeedAction(whichdecide_actionnever reads, so it would only fire ondev refresh). So a changeddevstack/field-key.envforces adown -vreseed before cases could hit refuse-to-boot. (The ordering bug where markers were written before the reseed was closed by #1005 — markers now bless only after every convergence step succeeds.) Theseed.shidentity-mismatch fail is the defense-in-depth backstop for a rawdocker compose upthat bypasses xtask.
A per-row key_id (a registry for a single-key model) and envelope encryption / wrapped-DEK (the named
endgame, whose rewrap-not-re-encrypt payoff only cashes out with the deferred KMS program) are both
deferred; the lineage table is shaped so a wrapped_dek column is a purely additive later move.
D4 — Activation: coordinated recreation, correctness via the round-trip
env_file injects values at container-create, so the processes do not continuously share one file — the guarantee is a protocol, not a static claim.
-
Coordinated recreation: activation (C7) mounts the shared key into cases + seed and runs one
up, then a full-volume reseed. Recreating a single container out-of-band is unsupported and is caught by cases boot-verify + theseed.shidentity-mismatch fail. -
Activation correctness = the D6 round-trip. On a fresh reseed cases boots first against an empty DB (compose: craig-seed
depends_oncraig-casesservice_healthy,docker-compose.yml:887-905), so boot-verify is trivial there — cases is simply up with the shared key and reads the seeded data correctly, and the D6 round-trip is the activation-time proof. Boot-verify is the guard for a later restart against persisted data. (There is no post-one-shot compose/xtask re-probe primitive, so the ADR does not claim one.) -
The enforced invariant is K_seed = K_cases = K_lineage (cases boot-verify refuses on mismatch; the D6 round-trip proves it at activation), not merely "a key is present."
-
C5 delivers the seeder’s encryption capability (an explicit mode + key input); in devstack it stays keyless/optional → plaintext until C7 mounts the shared key and sets seeder + cases to
required. "Required-mode" is the C7 configuration, not a C5 property. No mega-MR; the fail-open dual-read shim stays rejected (contradicts ADR-020 and the security baseline).
D5 — Secret delivery: CI-variable-first, hardened local file
Deliver the key to craig-cases (runtime) and the seeder (seed time) via a gitignored devstack/field-key.env,
env_file: into only those two services (the idiom ~13 services already use; zero new crypto API).
-
CI: the key is a required masked + protected GitLab CI/CD variable, which the xtask step overwrites the file from every run (variable-first, never file-first). An unset/empty variable in a CI environment is a hard error — the
generate_key()fallback is local-dev only, so a broken CI provisioning path fails loudly instead of self-consistently seeding + verifying under an ephemeral key. -
Local dev: generate-once-if-absent (precedent:
write_ports_env/.ports.env), written atomically (temp + rename), mode 0600,O_NOFOLLOW; excluded from CI artifacts/caches. -
A missing
field-key.envmakesdocker compose uperror on theenv_file:mount (fail-closed), and xtask generates it beforeup. Compose${VAR:-}interpolation is rejected (an unset var → empty → silent plaintext).#1003’s `.dockerignoredevstack/.envrule already keeps it out of the build context; gitignoring it keeps the PII key out of history (unlike the deliberately-committed *test-onlydevstack-actor-keys.env).
The textbook production answer — compose secrets: (file at /run/secrets) + a FieldEncryptor::from_file
loader — is out of scope (devstack/CI run synthetic data in throwaway stacks); it is the named production
target for the secrets-management program. from_env stays for &66.
D6 — Verification: pre-push gate + main backstop
CRAIG gates functional correctness at pre-push (CI runs security/supply-chain/release only,
.gitlab-ci.yml:170-173); the existing devstack-booting CI jobs (pentest/perf) are allow_failure,
non-blocking, and not per-MR (:503/:563/:609; main/schedule/manual). So the verifier runs in the
pre-push battery (which already
boots the devstack) as the enforced gate, plus a non-blocking backstop on those main/schedule jobs. A
blocking per-MR devstack-in-CI job is a macro CI-policy change (craig-seed pulls up a nine-service graph
with no per-MR images) and is out of scope.
Structural floor: ON_ERROR_STOP=1 on the seed.sh psql -f load (hardens the SQL + lineage path) and
a service_completed_successfully completion gate on craig-seed to a real downstream. Explicitly, the
floor covers the SQL/lineage path — not the attachment / rules-API branches, which are non-fatal by
construction (seed.sh || true + WARN, :161-179); that seed-robustness gap is a separate follow-up.
Exhaustive assertions — a decrypt-through GET is not proof, because field.rs:66
((Some(_), false, _) ⇒ Ok(value)) passes plaintext through when an encryptor is present:
-
raw-DB: every seeded encrypted column on all non-null rows carries a
CGEFenvelope (persons.ssn_last_four; referrals.reporter_first/last/phone);ssn_hmacnon-null and equal to HMAC(a known seeded SSN). -
round-trip: decrypt-through-service of a deterministic seeded ID equals the manifest plaintext, per covered column.
-
API-created report: POST a report and assert both TEXT (
narrative,reporter_*) and JSONB (children/adults/raw_submission) areCGEFat rest and decrypt back (covers the report path the seed omits). -
negative: restart cases under a wrong key against persisted key-A data ⇒ refuse boot (a restart-based test — a fresh stack boots empty, so the negative must exercise a restart).
A full Playwright e2e as the primary gate is rejected (against CI policy and heavier than the signal requires); a UI assertion on decrypted PII stays part of the existing pre-push Playwright battery.
D7 — Byte-identity guard: keyless render + semantic encrypted-render tests
The reproducibility guard SHA-256-pins the emitted SQL bytes for cases/placement/exchange/financial
(tools/craig-seed/tests/sql_byte_identity.rs). Once C5 encrypts, the cases SQL carries random-nonce
ciphertext (crates/craig-crypto/src/lib.rs:257-259) and is no longer byte-stable. The guard therefore
renders keyless (None encryptor), pinning the plaintext SQL; the _seed_marker and lineage
`INSERT`s render with a KCV-absent identity sentinel so the pinned bytes stay deterministic (production
renders the real KCV — unpinned, like ciphertext, consistent with this decision). This keeps the guard as
the generation-determinism check while acknowledging ciphertext is unpinnable-by-design.
Its correctness is proven separately by semantic encrypted-render tests: render with a key and
decrypt every generated encrypted field, asserting the round-trip — this exercises the actual encrypted
code path the keyless guard never touches. (No TRUNCATE preamble under D2, so no D2-forced re-bless.) The
alternative — hashing the pre-encryption SeedData model — is cleaner in principle but a larger refactor
with no additional signal for &66.
Consequences
-
New shared-crate + schema surface:
FieldEncryptor::kcv()+ a fixed canary constant incraig-crypto(C4); acrypto_key_lineagemigration (C4); the seeder-emitted lineageINSERT(C5); the craig-cases boot verifier (C7); averify-seedxtask subcommand (skeleton C4, exhaustive assertions C6/C7). No change to ADR-020’s runtime field-helper semantics. -
Encryption-covered seed surface is
persons.ssn_last_four(+ a newssn_hmac) andreferrals.reporter_first/last/phoneonly — the seeder has no reports table, so report TEXT/JSONB encryption is exercised by API-created-report tests (D6), not the seed. -
Child re-decomposition (the epic plan carries the authoritative table; summary):
| Child | Scope | Deps |
|---|---|---|
C4 (#984) |
D1 named volume (correct path) + recreation test; D2 per-DB in-transaction |
— |
C5 (#985) |
The encryption-capable seeder (keyless→plaintext until C7); the plain generated lineage |
C4 |
C2 (#982) |
Report-person read consumers decrypt after authz — |
— (precede C7) |
C3 (#983) |
Report search under encryption — its own ADR-first product decision, independent of this ADR. |
— (precede C7) |
C6 (#986) |
The |
C2, C5 |
C7 (#987) |
D4 activation: mount the shared key into cases+seed (coordinated recreate), set both |
C2, C3, C4, C5, C6 |
C8 (#988) |
ADR-020 cross-reference to this ADR; CHANGELOG; config-reference; local-dev (the D1 one-time |
with C7 |
-
C4/C5 ordering. The
ON_ERROR_STOPfloor — which turns today’s silent duplicate-key rollback into a hard failure — ships in the same child (C4) as the skip-if-seeded marker that prevents the collision, so no C4→C5 window breaksmain. -
Pre-1.0 devstack break: first pull after D1 needs a one-time
cargo xtask dev clean(documented in C8). -
Deferred, named-not-hidden: envelope-encryption / wrapped-DEK rotation, the compose-
secrets:production loader, and the full production key lifecycle all move to the secrets-management program; the lineage table and delivery path are shaped so those are additive, not rewrites. -
Follow-ups filed (not epic children): the marker-before-reseed ordering bug (
devstack_guard.rs:700↔:713); theCRAIG_FAMILIESdivergence (compose default 12,docker-compose.yml:883;seed.shdefault 9,:16;craig-seeddefault 9,main.rs:75; thefamilies9byte-identity fixture); the no-AAD cell-swap limitation (threat-model program); and the seed’s non-fatal attachment/rules-API branches (a seed-robustness gap D6’s floor does not cover).
Alternatives rejected
-
Anonymous / bind / tmpfs postgres storage, or the wrong PG18 mount path (D1).
-
Blanket
ON CONFLICTor in-placeTRUNCATE … CASCADE(D2) — an unmaintainable per-column list that keeps stale ciphertext, or an unbounded destructive closure that also can’t reach Garage/API state. -
A transactional claim-or-verify in the seeder (D3) — structurally impossible on the DB-less, file-emitting seeder; the verify role belongs on craig-cases.
-
Cases TOFU-writing the lineage row at boot (D3) — would attest cases' own key over data possibly encrypted under another; the seeder (the ciphertext writer) owns the write.
-
A phantom "post-seed cases re-probe" (D4) — no such compose/xtask primitive exists; the round-trip is the activation proof.
-
A blocking per-MR devstack-in-CI gate (D6) — reverses CRAIG’s local-only integration policy and multiplies CI cost (nine-service graph, no per-MR images).
-
Mega-MR / mode-flag dual-read (D4) — merge order is not the atomicity boundary; dual-read is fail-open.
-
Hashing the
SeedDatamodel now (D7) — larger refactor, no extra signal for &66.
Open questions
These do not block acceptance; they are resolved in the relevant child’s just-in-time plan.
-
(C4) Confirm the completion-gate downstream service and the Garage-init ordering so craig-seed cannot report success before the object-store steps could run.
-
(C4/C7) Pin the exact KCV parameters in code: HKDF
info = "craig-crypto/kcv/v1", an 8-byte (64-bit) truncation. -
(deferred/prod) Introducing lineage onto an already-encrypted production database (ciphertext present, lineage absent) needs a one-time bootstrap, since D3’s boot matrix refuses that state. Greenfield pre-1.0 prod starts empty, so &66 does not hit it; the secrets-management program owns it.
References
-
ADR-020 — fail-closed field-helper semantics + the
CGEFciphertext envelope (the primitive this ADR builds the lifecycle around). -
Epic &66 plan — the child decomposition this ADR re-scopes.
-
#1003 — the
.dockerignorebuild-context secret-leak fix that anticipatesdevstack/field-key.env. -
#982–#988 (C2–C8) — the epic children re-decomposed in Consequences.