Skip to content

fix(secrets): generate LIMSX_FIELD_ENCRYPTION_KEY on install - #47

Merged
man4ish merged 1 commit into
mainfrom
security/lims-encryption-key-install-path
Aug 14, 2026
Merged

fix(secrets): generate LIMSX_FIELD_ENCRYPTION_KEY on install#47
man4ish merged 1 commit into
mainfrom
security/lims-encryption-key-install-path

Conversation

@man4ish

@man4ish man4ish commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fresh Studio installations cannot start LIMS. The Electron app never provisioned LIMSX_FIELD_ENCRYPTION_KEY, so the LIMS container crash-loops on first launch.

Found while auditing a residual risk flagged in #46. Independent of that PR — this branch is cut from main, touches no compose file, and can merge in either order.

Why it breaks

.env (key absent) → compose interpolates empty string, warning only, exit 0 (the var is a bare ${LIMSX_FIELD_ENCRYPTION_KEY} with no :? guard) → container receives FIELD_ENCRYPTION_KEY=""omnibioai-lims/lab_data_manager/settings.py:66 raises RuntimeError: FIELD_ENCRYPTION_KEY must be set in non-debug environmentscrash-loop.

Three compounding defects, all in this repo

  1. Never generated. 951ad25 wired the variable into compose without a :? guard. fad98ff (security(studio): close release-compose datastore exposure and credential defaults #44) built SECRET_DEFAULTS by enumerating the ${VAR:?...} required vars — so this key was structurally invisible to that audit. The 18-day ordering (07-26 → 08-13) confirms accidental drift.
  2. Actively erased. writeEnvFile() rewrites .env wholesale from a fixed list of lines that omitted the key — so even a hand-added value was destroyed on the next settings save. Independent second bug.
  3. Misdocumented. DEPLOYMENT.md instructed operators to set FIELD_ENCRYPTION_KEY, but every compose file reads LIMSX_FIELD_ENCRYPTION_KEY. Following the runbook verbatim had no effect.

Why the obvious one-line fix would have been wrong

Adding the key to SECRET_DEFAULTS alone emits randomBytes(32).toString("hex") — 64 chars, which Fernet rejects. Verified empirically against real cryptography:

Scenario Startup guard Actual encryption
absent / empty RuntimeError (crash-loop)
64-char hex passes ValueError: Fernet key must be 32 url-safe base64-encoded bytes
44-char url-safe base64 ✅ passes ✅ works

The hex key clears LIMS's non-empty guard and fails later — and core/fields.py's read path (except (InvalidToken, Exception): return None) swallows that into a silent None, so encrypted data would read back blank with no error. Hence SECRET_GENERATORS: a per-key format override, defaulting to existing hex behavior for every other secret.

The key's SECRET_DEFAULTS entry is null — unlike the other credentials it never had a weak literal to rotate away from, so only a genuinely unset value is generated and an operator-supplied key is preserved verbatim.

Explicitly not changed

LIMS's fail-fast guard is untouched and not weakened — it was always correct; nothing ever satisfied it. No compose file, authentication, org-isolation, IAM, or database change. No secret value is hardcoded anywhere.

Test plan

  • End-to-end verified: generateSecrets() → compose interpolation → real LIMS production settings import (DEBUG=False) → real Fernet encrypt/decrypt roundtrip. All green; zero "variable is not set" warnings remain.
  • All four install scenarios exercised against real LIMS settings (absent / valid / malformed-hex / empty).
  • npm run test:secrets: 8 → 13 pass, 0 fail (+5 new). New tests cover Fernet shape (44 chars, -_ alphabet, decodes to 32 bytes), an explicit not-hex regression guard, no-rotation-on-relaunch, per-install distinctness, operator-key preservation, and a static check that writeEnvFile() carries every generated secret through.
  • Studio Python suite: exact failure-ID diff vs clean origin/main (d809a0a) → exit 0, byte-identical (73 pre-existing IDs, all requiring live services). Zero regressions.
  • omnibioai-lims full suite: 437 passed, 0 failures — that repo is unmodified by this PR.
  • Secret-exposure check: no key literal in the diff, local .env value absent from all changed files, .env gitignored, no secret logging. Tests assert on properties and never print a generated value. All throwaway test keys destroyed.

Residual risks (not addressed here)

  1. Existing installations that already ran LIMS have no key; they will now be issued a new one, and any data encrypted under a prior key reads back None. Needs a migration/rotation decision — product call, not a code fix.
  2. core/fields.py's broad except ... : return None turns key errors into silent data loss. Real hardening candidate in omnibioai-lims, deliberately out of scope.
  3. LIMSX_FIELD_ENCRYPTION_KEY still has no :? compose guard — adding one would break the dev stack; the LIMS-side guard covers it.

🤖 Generated with Claude Code

Fresh Studio installations could not start LIMS. The Electron app never
provisioned LIMSX_FIELD_ENCRYPTION_KEY, so compose interpolated it to the
empty string (it is wired as a bare ${VAR}, with no `:?` guard, so compose
only warns and exits 0), and omnibioai-lims' settings.py then raised
"FIELD_ENCRYPTION_KEY must be set in non-debug environments" at import --
crash-looping the container.

Three compounding defects, all in this repo:

1. Never generated. 951ad25 wired the variable into compose without a `:?`
   guard; fad98ff built SECRET_DEFAULTS by enumerating the `${VAR:?...}`
   required vars, so this key was structurally invisible to that audit.
2. Actively erased. writeEnvFile() rewrites .env wholesale from a fixed
   list of lines that omitted the key, so even a hand-added value was
   destroyed on the next settings save.
3. Misdocumented. DEPLOYMENT.md told operators to set FIELD_ENCRYPTION_KEY,
   but every compose file reads LIMSX_FIELD_ENCRYPTION_KEY -- following the
   runbook verbatim had no effect.

Generating it as plain hex like every other secret would NOT have worked:
LIMS builds a `cryptography` Fernet from this value, which requires exactly
32 url-safe-base64-encoded bytes (44 chars). A 64-char hex key satisfies
LIMS's own startup guard (which only checks non-emptiness) and then throws
ValueError on the first encrypted-field write -- and core/fields.py's read
path swallows that into a silent None, so encrypted data would read back
blank. Hence SECRET_GENERATORS: a per-key format override, defaulting to
the existing hex behavior for every other secret.

The key's SECRET_DEFAULTS entry is `null` -- unlike the other credentials
it never had a weak literal to rotate away from, so only a genuinely unset
value is generated and an operator-supplied key is preserved verbatim.

LIMS's fail-fast guard is deliberately unchanged: it was always correct,
nothing ever satisfied it.

Verified end to end -- generateSecrets() -> compose -> real LIMS production
settings import -> real Fernet encrypt/decrypt roundtrip. Tests assert on
key shape/properties only and never print a generated value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@man4ish
man4ish merged commit 55ce1c1 into main Aug 14, 2026
7 checks passed
@man4ish
man4ish deleted the security/lims-encryption-key-install-path branch August 14, 2026 01:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant