DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/cr-2026-102-key-custody-hardening-envelope-rotation-v0_1.md

Change Request — Key-custody hardening: rotation + envelope encryption (Levels A+B) — v0.1

CR number. CR-2026-102 Version. 0.1 Date. 2026-06-13 Repo (code). loomworks-engine Repo (this document). loomworks-record, change-requests/ Anchor. origin/main @ 7159870 (engine HEAD at drafting; §3 facts re-verified here — no drift). Authority. Confidentiality-posture standing note v0.2 (Rung 1 — key-custody hardening; Rung 1 definition unchanged from v0.1); key-custody hardening scoping note v0.1. Operator chose B+A+C on 2026-06-13; this CR is A+B only — Level C (external KMS) is carried to its own infrastructure scoping note + later CR. Status. Approved for implementation (Operator, 2026-06-13). Both §4 design calls confirmed: Call 1 — per-scope DEKs now (per-record flagged for the content-at-rest revisit); Call 2 — exclude object_store_secret_key from the envelope (routed to Level C secret-management). Build proceeds Step 1→4, per-step commits, two-pass on existing code, halt-before-push with the diff; the production migration run + old-key retirement (§8) is a separately gated step.

> CR-number note. Next number after a full-repo grep: the highest cr-2026-NNN referenced anywhere in loomworks-record is 101 (the founder-authority CR). Next free = 102. (Directory-only checks mislead — change-requests/ holds few numbered files; the repo-wide reference set is authoritative.)


1. Plain-language summary

The engine encrypts everything under one static key (LOOMWORKS_SECRET_KEY), plain Fernet, with no rotation, no envelope, and no migration path — a single point of total compromise, and no way to change the key without a flag-day re-encryption that no helper exists to perform. This CR hardens how the keys are managed without changing what is encrypted:

What does not change: the set of encrypted fields — exactly as it is today. This CR changes key management, not which data is protected. Level C (KMS/HSM), content-at-rest encryption, tokenization, and the enclave rungs are out of scope (separate arcs per the confidentiality-posture standing note).

This CR rests on the Operator's 2026-06-13 B+A+C decision; it builds A+B and defers C. It does not reopen that decision.


2. Inheritance — why now

The confidentiality-posture standing note v0.1 names a protection ladder; Rung 1 is key-custody hardening — the "build regardless" floor under every rung above, the one rung that makes no confidentiality claim against DUNIN7 itself but ends the single-static-key exposure. The data-at-rest inspections (status brief + follow-up @ 7159870) established the implemented state: one LOOMWORKS_SECRET_KEY, plain Fernet, no MultiFernet, no envelope, no re-encryption helper. This CR is Rung 1, built to the scoping note's A+B scope.


3. Verified inheritance (re-verified at engine HEAD 7159870 — no material drift)

No material drift from the inspection anchor. HEAD is the same commit (7159870); if a later implementation HEAD differs, halt and re-verify before building (drafting-handoff-encodes-stated-state).


4. Two CR-level design calls — CONFIRMED (Operator, 2026-06-13)

The scoping note left two design calls to the CR. Each is below with the lean and reasoning as drafted, followed by the Operator's confirmation (the reasoning is preserved as the record of why; the confirmation is the decision).

Call 1 — DEK granularity: per-record vs per-scope

Lean: per-scope (one DEK per logical scope of encrypted fields, wrapped by the KEK; aligned to the existing credentials.scope model — system / operator / engagement — and one DEK-domain per other encrypted-field family: api-keys, system_config, TOTP).

Reasoning:

CONFIRMED (2026-06-13): per-scope DEKs now — per-record flagged for the content-at-rest (enclave-rung) revisit. Build to per-scope.

Call 2 — does object_store_secret_key join the envelope scheme?

Lean: exclude it from the envelope; address it in the Level C secret-management arc instead. (This respectfully diverges from the handoff's "bring it in for consistency" lean — the handoff explicitly asked me to flag a different lifecycle, and it has one.)

Reasoning:

CONFIRMED (2026-06-13): excluded from this CR's envelope — routed to the Level C secret-management arc (it is an env access secret, not enveloped ciphertext). Do not bring it into the envelope.

(Design note, not a call) — the session-token special case

Session cookies (persons/session.py) are stateless Fernet tokens with no server-side row to hold a wrapped DEK. They therefore take Level A onlyMultiFernet rotation directly under the KEK material — and are not enveloped (embedding a wrapped DEK in every cookie is wasteful and pointless for a token that already lives only in the client). The envelope (Level B) applies to the DB-stored ciphertext (api-keys, system_config, credentials, TOTP). Stated here so the asymmetry is deliberate, not an omission.


5. The settled design (Levels A + B; KEK pluggable; C deferred)


6. Change set (per-step; each independently committable; two-pass on existing code; halt-before-push)

Step 1 — the KEK provider seam (no behaviour change)

Define KeyEncryptionKeyProvider (e.g. src/loomworks/credentials/kek.py — CC confirms home) yielding the KEK material, with an environment-backed implementation reading today's key (so behaviour is byte-identical) and exposing the ordered key set for MultiFernet. Refactor get_secret_key() / _resolve_secret(...) to obtain key material through the provider. Pure seam — no ciphertext changes yet.

> Enumeration correction (build, 2026-06-13): 3 → 10. This section originally named only get_secret_key() + the two _resolve_secret(...) as the sourcing reads to route. The build inspection found 10 direct settings.loomworks_secret_key sourcing reads, not 3 — routing only the named 3 would leave 7 direct reads and violate the §5 invariant ("never settings.loomworks_secret_key directly"), and would leave Level C's KMS swap unable to reach those 7 paths (defeating the pluggable-seam purpose). Per Operator direction (§5 governs over §6's undercount), Step 1 routed all 10 through kek_provider(): the named 3, plus api/deps.py (CredentialStore construction; the _resolve_optional_person session-cookie decode), api/app.py (the two agent-bootstrap calls — bootstrap_shaping_agents / bootstrap_render_specialists, which the build confirmed are not session issue/decode), orchestration/routers/converse.py + api/routers/seed_conversation.py (near-exhaustion), and api/routers/uploads.py (key resolver). Each kept its own empty-check + error type; byte-identical key material. The stateless session-cookie path (persons/session.py, plain Fernet) sources its key via the provider and remains Fernet — Level A (MultiFernet) only, never the envelope (Step 2 design note).

Step 2 — envelope scheme + Level A rotation (Level B)

Implement DEK generation + wrapping under the KEK (granularity per Call 1), the key-registry storage, MultiFernet for KEK/DEK rotation, and the format discriminator. New encrypt/decrypt helpers (envelope-aware) that the call sites will move onto in Step 4. Sessions stay on the Level-A MultiFernet path (design note, §4).

Step 3 — migration helper (net-new)

A one-time, idempotent, resumable helper that re-encrypts existing single-key ciphertext forward into the envelope. CC proposes: a management script (mirroring scripts/bootstrap_platform_founder.py / scripts/set_organization_window.py — run out-of-band, batched, progress-tracked) rather than an Alembic data migration, because re-encryption needs the app's key material and batching/resumability that a SQL migration handles poorly. Idempotency via the format discriminator (skip already-enveloped rows); resumable by batching over the encrypted tables.

Step 4 — repoint the 14 call sites (two-pass; existing code)

Move the 14 encrypt/decrypt sites onto the envelope helpers, minimal diff. Two-pass: inspect-and-report the exact current sites at implementation HEAD, confirm, then change. Sessions move onto the MultiFernet (Level A) path; the DB-stored sites move onto the envelope path.


7. Tests — extend BOTH key-injection points (load-bearing)

The key is injected in tests at two places; a custody change must extend both or tests hit Fernet InvalidToken silently (set under one key, read under another):

  1. Per-file autouse monkeypatchmonkeypatch.setattr(settings, "loomworks_secret_key", TEST_SECRET_KEY) across many files (test_phase_16_contributions.py, test_phase_48_login_gate_and_reactivate.py, test_phase_49_exhaustion_choice.py, test_phase_25_commit_orchestration.py, test_b1_finalize_authorization_parity.py, test_phase_two_substrate.py, …).
  2. conftest dependency overrideconftest.py:588 app.dependency_overrides[get_secret_key] = lambda: TEST_SECRET_KEY (constant :286; CredentialStore fixture :315).

Both must extend to whatever the new scheme uses (the KEK provider / envelope material), and the standing test convention — autouse monkeypatch of the key — applies to the provider seam too.

New tests:

Full suite green — zero failures — before any push (the standing "0 failed" baseline).


8. Deploy-ordering note (so no ciphertext is stranded)

The migration must run such that the system reads both old and new ciphertext during the transition:

  1. Deploy the A+B code — it can READ legacy single-key ciphertext (the old key is present in the MultiFernet array / the discriminator routes legacy reads) AND enveloped ciphertext, and it WRITES enveloped (new writes use the envelope).
  2. Run the migration helper — convert all legacy ciphertext forward to the envelope (idempotent, resumable).
  3. Only after migration completes, retire the old single key from the MultiFernet array (a later, separate change) — at which point no legacy ciphertext remains to strand.

Naming the sequence explicitly: never drop the old key before the migration helper reports complete, or any unmigrated ciphertext becomes unreadable.


9. What this CR does NOT do


10. After this CR

Level C (KMS) — its own infrastructure scoping note decides which KMS against the DUNIN7-M4 / Cloudflare deployment; a later CR swaps the KeyEncryptionKeyProvider implementation from environment-backed to KMS-backed. The Step-1 seam is what makes that a contained change. The env access secret object_store_secret_key (design call 2) is the natural companion of that secret-management work.


Halt note

Approved for implementation 2026-06-13 (both §4 design calls confirmed). Build discipline: per-step commits, two-pass on existing code, halt before each push with the diff; full suite green (zero failures) before any push; the production migration run + old-key retirement (§8) is a separately gated step, not automatic.


DUNIN7 — Done In Seven LLC — Miami, Florida Change Request — Key-custody hardening: rotation + envelope encryption (Levels A+B) — CR-2026-102 — v0.1 — 2026-06-13