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.)
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:
Fernet → MultiFernet (an ordered key array: encrypt under the first, decrypt-attempt across all), so a key can be retired without a flag-day.KeyEncryptionKeyProvider seam with an environment-backed implementation now, so Level C (KMS) later swaps the implementation, not the call sites.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.
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.
7159870 — no material drift)credentials/encryption.py — encrypt_key (:9), decrypt_key (:15), both plain Fernet(secret_key.encode()). Direct Fernet(...) at persons/session.py (:56 encrypt / :67 decrypt). No MultiFernet anywhere.api_keys/store.py (:104/:177/:226 → engagement_api_keys.encrypted_key), system_config/store.py (:90/:147 → system_config.encrypted_value, incl. the founder UUID), credentials/store.py (:39/:92/:106/:119 → credentials.encrypted_key), contributors/totp.py (:138/:176/:210 → contributors.totp_secret), persons/signup.py (:258) + persons/login.py (:405 → persons.totp_secret), persons/session.py (:56/:67 → the session cookie token). All resolve to settings.loomworks_secret_key.config.py:17 — loomworks_secret_key: str = "" on Settings(BaseSettings), from .env. Read via get_secret_key() (api/deps.py:109); _resolve_secret(...) fallbacks at api_keys/store.py:57 and system_config/store.py:58; threaded as secret_key= through the call graph (all tracing to the one settings value).api_keys/store.py / system_config/store.py refer to updating stored values, not rotating the encryption key. The migration helper is net-new.object_store_secret_key (config.py:61 → storage/factory.py:57/76/77) is an AWS-style object-store access secret (aws_secret_access_key) read from env — not DB-stored ciphertext. This bears on design call 2 (§4).
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).
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).
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:
data_encryption_keys: scope, wrapped_dek, kek_key_id, created_at, retired_at) and no new column on the encrypted tables; per-record needs a wrapped_dek (or dek_id) column on every encrypted table plus the registry.CONFIRMED (2026-06-13): per-scope DEKs now — per-record flagged for the content-at-rest (enclave-rung) revisit. Build to per-scope.
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:
object_store_secret_key is an environment-resident access credential (aws_secret_access_key for the object-store backend, storage/factory.py:77) — a secret the app holds in env to authenticate outward, not DB-stored ciphertext the envelope wraps. It never passes through encrypt_key/decrypt_key.LOOMWORKS_SECRET_KEY itself.object_store_secret_key — are managed by the Level C KMS/secret-management seam, not that an access secret gets DEK-wrapped. Folding it into the envelope conflates "ciphertext we store" with "secrets we hold."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.
Session cookies (persons/session.py) are stateless Fernet tokens with no server-side row to hold a wrapped DEK. They therefore take Level A only — MultiFernet 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.
MultiFernet rotation. Encryption uses MultiFernet([Fernet(k) for k in keks]): encrypt under keks[0], decrypt-attempt across all. A key is rotated by prepending the new key (new writes use it) while the old key remains in the array so existing ciphertext still decrypts; once the migration helper has re-encrypted everything forward, the old key is dropped.MultiFernet material from Level A, obtained via the provider) and the wrapped DEK is stored in the key registry (granularity per Call 1). Read = unwrap DEK with KEK → decrypt ciphertext with DEK. KEK rotation re-wraps only the DEK blobs — bulk ciphertext is untouched.KeyEncryptionKeyProvider seam. A single interface (current_kek(), all_keks() for the MultiFernet decrypt set, kek_id()); an environment-backed implementation reads the current key material (LOOMWORKS_SECRET_KEY, plus an optional ordered list for rotation). All encrypt/decrypt obtains the KEK through this provider — never settings.loomworks_secret_key directly. Level C swaps the implementation (environment → KMS); call sites do not 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).
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).
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.
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.
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):
monkeypatch.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, …).conftest.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).
The migration must run such that the system reads both old and new ciphertext during the transition:
MultiFernet array / the discriminator routes legacy reads) AND enveloped ciphertext, and it WRITES enveloped (new writes use the envelope).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.
object_store_secret_key inclusion (per design call 2 — confirmed excluded; routed to Level C secret-management).
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.
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