You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(security): support Fernet key rotation via MultiFernet (#69)
FERNET_KEY encrypts the GitHub and Claude credentials in the database, and
replacing it made every one of them unreadable — so in practice it could
not be replaced, which is a bad property for a key whose exposure is the
whole reason it exists.
The crypto helpers now take an ordered keyset and go through MultiFernet:
encrypt with the first key, decrypt with whichever one matches. A new
FERNET_KEY_FALLBACKS setting holds retired keys, so a rotation is a deploy
rather than an outage.
Fallbacks are validated at boot alongside the primary key. A malformed
retired key must not surface at first decrypt — the row that would break is
exactly the credential the rotation is trying not to lose.
`helprs.scripts.rotate_credentials` finishes the job by re-encrypting stored
rows under the primary key, using MultiFernet.rotate (no plaintext needed).
Without it the fallback list grows forever and a "retired" key stays as
sensitive as the live one. It rewrites in a single transaction, is safe to
re-run, and a row no configured key can read is reported and left intact
rather than overwritten.
The keyset is typed `list[str]` rather than `Sequence[str]` deliberately:
`str` satisfies `Sequence[str]`, so a caller still passing a single key
would have type-checked cleanly. mypy caught all six call sites this way.
`settings.fernet_keys` is a plain property, not a computed_field — the
latter would put every key back into model_dump() output and undo the
SecretStr work from #65.
Docs: a rotation runbook in self-hosting.md, the new setting in
.env.example, and the stale "in development mode, any password is accepted"
line next to ADMIN_PASSWORD removed — that bypass was fixed in #60.
Copy file name to clipboardExpand all lines: CLAUDE.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -61,6 +61,7 @@ infra/
61
61
-**Auth on all REST routes**: identity and installation routers use `Depends(get_current_user)`, container router uses it too. The webhook handler bypasses REST routes entirely — it calls `create_session()` directly (DB record only, no container start). Container start happens when the authenticated frontend calls the REST endpoint.
62
62
-**No module `__init__` imports**: the four `modules/*/__init__.py` are docstring-only. Re-exporting a router there pulled the whole router graph back through `core.dependencies` (which imports `identity.models`), so `import helprs.core.dependencies` failed on its own and startup depended on `main.py`'s import order. `tests/test_import_graph.py` guards this.
63
63
-**JWT**: PyJWT, not python-jose (unmaintained since 2021, and the source of an unfixable `ecdsa` advisory). `PyJWTError` is the failure type.
64
+
-**Fernet keyset, not a single key**: the crypto helpers take `settings.fernet_keys` (`list[str]`, primary first, then `FERNET_KEY_FALLBACKS`) and go through `MultiFernet` — encrypts with the first, decrypts with any. That is what makes key rotation possible without downtime; `helprs.scripts.rotate_credentials` re-encrypts stored rows so a retired key can actually be dropped. Typed `list[str]` rather than `Sequence[str]` on purpose: `str` satisfies `Sequence[str]`, so a caller passing a bare key would type-check.
64
65
-**Secrets are `SecretStr`**: read them with `.get_secret_value()`. `SecretStr` defines `__len__`, so truthiness checks work unchanged. `repr(Settings())` used to print every credential, and Sentry uploads locals on any unhandled 500.
65
66
-**SSE takes no DB dependency**: FastAPI tears yield-dependencies down only after the streaming body ends, so `Depends(get_db)` — including one behind an auth dependency — pins a pooled connection for the whole stream. The SSE route calls `authenticate_token`/`stream_token` inside a short `get_db_context()` instead.
66
67
-**Production env validation**: `Settings` has a `model_validator` that enforces non-empty secrets when `ENVIRONMENT=production`. Tests use `ENVIRONMENT=test` to skip this.
0 commit comments