feat(crypto): generate the master key on first launch, never replacing an existing one - #109
Merged
Merged
Conversation
…g an existing one A new deployment could not start until the operator had produced a master key by hand. Because Compose validates a file-backed `secrets:` entry before it reads the rest of the stack, a missing `secrets/app_secret_key` failed `docker compose up` outright rather than producing a startup error anyone could act on — a first-run blocker rather than a misconfiguration. With no key supplied, Scrye now mints one from the OS CSPRNG (48 random bytes base64-encoded — the documented `openssl rand -base64 48` equivalent, so it clears the SEC-3 entropy floor without the weak-key opt-out), writes it `O_CREAT|O_EXCL` + fsync, chmods it 0600, re-stats it to verify mode and owner, and logs one INFO line naming the path and warning that the file must be backed up. Two new settings: SCRYE_APP_SECRET_KEY_AUTOGENERATE (default true) and SCRYE_APP_SECRET_KEY_AUTOGEN_FILE (default /data/app_secret_key). Precedence is preserved with generation strictly last: the Docker secret at SCRYE_APP_SECRET_KEY_FILE still wins, then a previously generated key file, then generation. No key is ever read from an env var or an image layer, and the KDF and token format are unchanged, so existing ciphertext keeps decrypting. The invariants matter more than the convenience, since a second key silently orphans every field-encrypted secret: - An existing key file is used, never replaced. Unreadable, empty, non-base64, too short or malformed fails startup; generation follows only from a proven absent file. Absence itself is proven — only ENOENT/ENOTDIR count, any other stat error refuses. - An explicitly configured SCRYE_APP_SECRET_KEY_FILE pointing at a missing file refuses to start rather than substituting a generated key. - A supplied secret alongside a previously generated key it does not cover (version-aware, since tokens name their key version) refuses to start. - Concurrent starts cannot both generate: the O_EXCL loser reads the winner's key, and a key file caught empty mid-write is retried (only that case, via a dedicated MasterKeyFileEmptyError) instead of failing. - A generated key that fails its permission check is removed by the call that created it, so the next start cannot adopt the file this one refused. Compose and the README stack no longer require the secret; the Docker-secret blocks are kept commented out for deployments that want the key and the data on separate mounts. Docs cover where the key lives, that it is generated on first run, that it must be backed up, what is lost if it isn't, and how this differs from a passphrase backup bundle (which does not need the key). See docs/ARCHIVE.md § Deviations (2026-07-29) for the full rationale, including the amended CLAUDE.md master-key sourcing rule.
… ownership errors actionable An unwritable /data surfaced only as `sqlite3.OperationalError: unable to open database file` from `alembic upgrade head`, which names neither the path nor the cause. That is the same first-run-blocker class as a missing master key — and the one a bind-mounted NAS volume actually hits, since a bind mount keeps the host directory's ownership while a named volume inherits the image's. The entrypoint now checks that the database directory exists and is writable before Alembic runs, and fails with the directory, the container uid:gid, a literal `chown -R <uid>:<gid> <host path>`, the `user:`-matching alternative, and the note that a named volume gets this right automatically. A dozen lines of sh: a probe file created and removed, no validation framework. Tracing the same scenario through key generation showed the ordinary NAS bind mount is fine — on Linux a new file always belongs to the creating euid, so a foreign-owned but writable directory yields a 0600 key owned by the app uid. The owner check only fires where the filesystem synthesizes ownership (CIFS/SMB `uid=`, NFS squashing), where 0600 protects nothing. Both master-key messages in this class are now as actionable as the preflight: the unwritable-directory error carries the same uid/chown guidance, and the synthesized-ownership error states that chown cannot help and points at matching `user:` or a Docker secret. Tests execute the shipped entrypoint with sh against real directory permissions, stubbing alembic/uvicorn on PATH so "did the boot stop before migrations?" is observable, and rewriting only `cd /app/backend` (a path that exists solely in the image). Two cases skip under root, which bypasses permission bits; they run in CI, which is non-root. Message content is asserted for both key errors too. See docs/ARCHIVE.md § Deviations (2026-07-29) for the full trace table.
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
A new deployment could not start until the operator had produced a master key by hand (
openssl rand -base64 48 > docker/secrets/app_secret_key). Because Compose validates a file-backedsecrets:entry before it reads the rest of the stack, a missing file faileddocker compose upoutright rather than producing a startup error anyone could act on — a first-run blocker, which a user hit. The key is now generated on first launch when none is supplied.The convenience is the small part; the invariants are the substance, because a second master key silently orphans every field-encrypted secret (registry credentials, git tokens, the OIDC client secret, TOTP seeds, scheduled-backup passphrases) while the app looks perfectly healthy.
Precedence (was one source; generation is strictly last)
First match wins:
SCRYE_APP_SECRET_KEY_FILE(default/run/secrets/app_secret_key) — the Docker secret. Unchanged, still highest.SCRYE_APP_SECRET_KEY_AUTOGEN_FILE(new, default/data/app_secret_key) — the key a previous start generated.SCRYE_APP_SECRET_KEY_AUTOGENERATE(new, defaulttrue) is on.No key is read from an env var or an image layer, as before.
SCRYE_ALLOW_WEAK_MASTER_KEYremains a validation opt-out, not a key source. The KDF and token format are untouched, so existing ciphertext keeps decrypting (regression-tested), and existing deployments resolve their key exactly as they did.Invariants enforced
ENOENT/ENOTDIRcount as absence, any otherstaterror refuses, so an unreadable parent directory can never read as "no key here".SCRYE_APP_SECRET_KEY_FILEwith no file there refuses to start rather than substituting a generated key — on an existing deployment that state means an unmounted secret, not a fresh install.O_CREAT|O_EXCL; the loser reads the winner's key. A key file caught empty mid-write is retried — and only that case, via a dedicatedMasterKeyFileEmptyError, so a malformed key still fails immediately.Generation itself:
os.urandom(48)base64-encoded — the documentedopenssl rand -base64 48form, clearing the SEC-3 entropy floor with no weak-key opt-out — writtenO_CREAT|O_EXCL+fsync,chmod0600 (theO_CREATmode is a umask-masked ceiling), then re-stat verified for mode 0600 and owner uid before use, plus one INFO line naming the path and warning that the file must be backed up.What changed
backend/app/core/crypto.py—resolve_master_keys()and the generation/verification/race machinery;MasterKeyFileEmptyError.backend/app/core/config.py— the two new settings andapp_secret_key_file_is_explicit.backend/app/main.py— startup resolves the key (so the generation notice lands in the startup log) and reports its source.docker/docker-compose.yml, README paste-in stack — the secret is no longer required; the Docker-secret blocks are kept, commented out, for deployments that want the key and data on separate mounts.README.md— where the key lives, the precedence order, auto-generation, back it up, what is lost if you don't, expanded first-run troubleshooting, the config table, and the security-model bullet. Also documents the trade-off that the generated key sits on the same volume as the database it protects.CONTRIBUTING.md,.env.example(regenerated),CHANGELOG.md,docs/ROADMAP.md.CLAUDE.md§ Hard security rules — amended in the same commit rather than left contradicting the code (the Docker secret file remains the recommended production mechanism and keeps its precedence).One premise corrected
The scoping said a backup bundle without the master key is useless for the encrypted fields. That is not true of Scrye's bundles: §8's design re-wraps each secret under the user's passphrase, so a bundle restores onto a host with a different master key — and now onto a fresh deployment that generated its own. The master key is required for a volume/file-level backup, where the rows are master-key ciphertext, so README § Backup & restore draws that line explicitly instead.
Testing
backend/tests/test_master_key_autogeneration.py— 35 cases: generation on a clean volume, entropy floor without the opt-out, mode 0600 and owner, the one-time INFO backup notice, existing keys reused verbatim, every malformed/unreadable/undeterminable case failing rather than regenerating, the explicit-but-missing refusal, the two-key interlock (including the same key under a different version and the accepted carry-forward), eight-thread concurrent startup producing exactly one key, permission-check cleanup, and a generated key still decrypting its data across a restart.ruff+blackclean;.env.examplein sync./healthzreported healthy, a registry secret was stored, and after a restart the same key was reused and a backup bundle built successfully — which decrypts every stored secret to re-wrap it. The image build /docker compose upcould not be exercised in this environment (no Docker daemon available); CI's image job covers it.Note that the concurrency test earned its place: it caught a real race where a third process that merely saw the path exist read the winner's zero-length file, and then a TOCTOU in the first fix for it. See
docs/ARCHIVE.md§ Deviations (2026-07-29) for the full rationale.