fix(secrets): generate LIMSX_FIELD_ENCRYPTION_KEY on install - #47
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 receivesFIELD_ENCRYPTION_KEY=""→omnibioai-lims/lab_data_manager/settings.py:66raisesRuntimeError: FIELD_ENCRYPTION_KEY must be set in non-debug environments→ crash-loop.Three compounding defects, all in this repo
951ad25wired the variable into compose without a:?guard.fad98ff(security(studio): close release-compose datastore exposure and credential defaults #44) builtSECRET_DEFAULTSby 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.writeEnvFile()rewrites.envwholesale 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.DEPLOYMENT.mdinstructed operators to setFIELD_ENCRYPTION_KEY, but every compose file readsLIMSX_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_DEFAULTSalone emitsrandomBytes(32).toString("hex")— 64 chars, which Fernet rejects. Verified empirically against realcryptography:RuntimeError(crash-loop)ValueError: Fernet key must be 32 url-safe base64-encoded bytesThe 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 silentNone, so encrypted data would read back blank with no error. HenceSECRET_GENERATORS: a per-key format override, defaulting to existing hex behavior for every other secret.The key's
SECRET_DEFAULTSentry isnull— 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
generateSecrets()→ compose interpolation → real LIMS production settings import (DEBUG=False) → real Fernet encrypt/decrypt roundtrip. All green; zero "variable is not set" warnings remain.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 thatwriteEnvFile()carries every generated secret through.diffvs cleanorigin/main(d809a0a) → exit 0, byte-identical (73 pre-existing IDs, all requiring live services). Zero regressions.omnibioai-limsfull suite: 437 passed, 0 failures — that repo is unmodified by this PR..envvalue absent from all changed files,.envgitignored, no secret logging. Tests assert on properties and never print a generated value. All throwaway test keys destroyed.Residual risks (not addressed here)
None. Needs a migration/rotation decision — product call, not a code fix.core/fields.py's broadexcept ... : return Noneturns key errors into silent data loss. Real hardening candidate inomnibioai-lims, deliberately out of scope.LIMSX_FIELD_ENCRYPTION_KEYstill has no:?compose guard — adding one would break the dev stack; the LIMS-side guard covers it.🤖 Generated with Claude Code