Skip to content

Commit e95a398

Browse files
authored
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.
1 parent 514aaa7 commit e95a398

28 files changed

Lines changed: 537 additions & 60 deletions

.env.example

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,22 @@ DATABASE_URL=postgresql+asyncpg://helprs:helprs@db:5432/helprs
1414
# Generate: python -c "import secrets; print(secrets.token_urlsafe(48))"
1515
SECRET_KEY=
1616

17-
# Encryption key for stored Claude credentials (Fernet symmetric encryption)
17+
# Encryption key for stored credentials (Fernet symmetric encryption)
1818
# Generate: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
1919
FERNET_KEY=
2020

21-
# Admin panel password (required when ENVIRONMENT=production)
22-
# In development mode, any password is accepted.
21+
# Previously-active keys, JSON list, newest first. Values encrypted under them
22+
# stay readable, so FERNET_KEY can be replaced without a downtime window:
23+
# 1. set the new key as FERNET_KEY and move the old one here
24+
# 2. deploy - new writes use the new key, old values still decrypt
25+
# 3. uv run python -m helprs.scripts.rotate_credentials
26+
# 4. empty this list and deploy again
27+
# Leave unset when no rotation is in flight: a retired key is exactly as
28+
# sensitive as a live one for as long as it appears here.
29+
# FERNET_KEY_FALLBACKS=["<previous-key>"]
30+
31+
# Admin panel password. Required when ENVIRONMENT=production; when it is unset
32+
# the panel is not mounted at all, in any environment.
2333
ADMIN_PASSWORD=
2434

2535
# --- GitHub App --------------------------------------------------------------

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ infra/
6161
- **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.
6262
- **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.
6363
- **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.
6465
- **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.
6566
- **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.
6667
- **Production env validation**: `Settings` has a `model_validator` that enforces non-empty secrets when `ENVIRONMENT=production`. Tests use `ENVIRONMENT=test` to skip this.

apps/api/src/helprs/core/config.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@
99
from pydantic_settings import BaseSettings, SettingsConfigDict
1010

1111

12+
def _assert_fernet_key(key: SecretStr, name: str) -> None:
13+
"""Reject anything Fernet could not use, naming the offending setting."""
14+
try:
15+
Fernet(key.get_secret_value().encode())
16+
except (ValueError, InvalidToken) as e:
17+
raise ValueError(
18+
f"{name} must be a valid Fernet key (32 url-safe base64-encoded bytes). "
19+
"Generate with: python -c "
20+
"'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'"
21+
) from e
22+
23+
1224
class Settings(BaseSettings):
1325
"""Runtime configuration.
1426
@@ -28,7 +40,13 @@ class Settings(BaseSettings):
2840

2941
# Security
3042
SECRET_KEY: SecretStr
43+
# The key new credentials are encrypted with.
3144
FERNET_KEY: SecretStr
45+
# Previously-active keys, newest first. Values written under them stay
46+
# readable, so a key can be replaced without a downtime window: set the
47+
# new key as FERNET_KEY, move the old one here, deploy, re-encrypt with
48+
# `python -m helprs.scripts.rotate_credentials`, then empty this list.
49+
FERNET_KEY_FALLBACKS: list[SecretStr] = []
3250
ADMIN_PASSWORD: SecretStr = SecretStr("")
3351

3452
# GitHub App
@@ -93,16 +111,35 @@ def normalize_private_key(cls, v: SecretStr) -> SecretStr:
93111
@classmethod
94112
def validate_fernet_key(cls, v: SecretStr) -> SecretStr:
95113
"""Validate that FERNET_KEY is a valid Fernet key (32 url-safe base64 bytes)."""
96-
try:
97-
Fernet(v.get_secret_value().encode())
98-
except (ValueError, InvalidToken) as e:
99-
raise ValueError(
100-
"FERNET_KEY must be a valid Fernet key (32 url-safe base64-encoded bytes). "
101-
"Generate with: python -c "
102-
"'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'"
103-
) from e
114+
_assert_fernet_key(v, "FERNET_KEY")
104115
return v
105116

117+
@field_validator("FERNET_KEY_FALLBACKS")
118+
@classmethod
119+
def validate_fernet_fallbacks(cls, v: list[SecretStr]) -> list[SecretStr]:
120+
"""A malformed retired key must fail at boot, not at first decrypt.
121+
122+
Without this the app would start happily and only break when it met a
123+
value written under that key -- which is precisely the credential a
124+
rotation is trying not to lose.
125+
"""
126+
for index, key in enumerate(v):
127+
_assert_fernet_key(key, f"FERNET_KEY_FALLBACKS[{index}]")
128+
return v
129+
130+
@property
131+
def fernet_keys(self) -> list[str]:
132+
"""The ordered keyset: primary first, then retired keys.
133+
134+
A plain property rather than a ``computed_field`` on purpose -- the
135+
latter would put every key in ``model_dump()``, undoing the reason the
136+
fields are ``SecretStr`` in the first place.
137+
"""
138+
return [
139+
self.FERNET_KEY.get_secret_value(),
140+
*(key.get_secret_value() for key in self.FERNET_KEY_FALLBACKS),
141+
]
142+
106143
@model_validator(mode="after")
107144
def validate_production_secrets(self) -> "Settings":
108145
"""Enforce that critical secrets are set when ENVIRONMENT is production."""

apps/api/src/helprs/core/security.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,45 @@
66
from datetime import UTC, datetime, timedelta
77

88
import jwt
9-
from cryptography.fernet import Fernet
9+
from cryptography.fernet import Fernet, MultiFernet
1010

1111

12-
def fernet_encrypt(plaintext: str, fernet_key: str) -> str:
13-
"""Encrypt a string using Fernet symmetric encryption."""
14-
f = Fernet(fernet_key.encode())
15-
return f.encrypt(plaintext.encode()).decode()
12+
def _cipher(fernet_keys: list[str]) -> MultiFernet:
13+
"""Build the cipher for an ordered keyset.
1614
15+
``MultiFernet`` encrypts with the FIRST key and decrypts with whichever
16+
one matches, which is exactly what rotation needs: put the new key first,
17+
keep the retired ones behind it until every stored value has been
18+
re-encrypted, then drop them.
19+
"""
20+
if not fernet_keys:
21+
raise ValueError("At least one Fernet key is required")
22+
return MultiFernet([Fernet(key.encode()) for key in fernet_keys])
23+
24+
25+
def fernet_encrypt(plaintext: str, fernet_keys: list[str]) -> str:
26+
"""Encrypt a string with the primary key of the keyset."""
27+
return _cipher(fernet_keys).encrypt(plaintext.encode()).decode()
28+
29+
30+
def fernet_decrypt(ciphertext: str, fernet_keys: list[str]) -> str:
31+
"""Decrypt a value written by any key in the keyset.
1732
18-
def fernet_decrypt(ciphertext: str, fernet_key: str) -> str:
19-
"""Decrypt a Fernet-encrypted string."""
20-
f = Fernet(fernet_key.encode())
21-
return f.decrypt(ciphertext.encode()).decode()
33+
Raises ``InvalidToken`` when no key matches -- which is also what a
34+
tampered ciphertext produces, since Fernet verifies its HMAC before
35+
decrypting anything.
36+
"""
37+
return _cipher(fernet_keys).decrypt(ciphertext.encode()).decode()
38+
39+
40+
def fernet_rotate(ciphertext: str, fernet_keys: list[str]) -> str:
41+
"""Re-encrypt an existing value under the primary key.
42+
43+
Does not need the plaintext: ``MultiFernet.rotate`` decrypts with
44+
whichever key matches and re-encrypts with the first. This is what lets a
45+
retired key actually be retired instead of carried forever.
46+
"""
47+
return _cipher(fernet_keys).rotate(ciphertext.encode()).decode()
2248

2349

2450
def create_app_jwt(app_id: str, private_key: str) -> str:

apps/api/src/helprs/modules/container/router.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ async def create_container_session(
8383
if not byok_config:
8484
raise NotFoundError("No Claude token configured for this installation")
8585

86-
claude_oauth_token = fernet_decrypt(byok_config.encrypted_api_key, settings.FERNET_KEY.get_secret_value())
86+
claude_oauth_token = fernet_decrypt(byok_config.encrypted_api_key, settings.fernet_keys)
8787

8888
# Narrowed to this repo, read-only: the container runs Claude Code over
8989
# untrusted PR content with network egress.

apps/api/src/helprs/modules/identity/repository.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,9 @@ async def add(session: AsyncSession, user: GitHubUser) -> GitHubUser:
2727
session.add(user)
2828
await session.flush()
2929
return user
30+
31+
32+
async def list_all(session: AsyncSession) -> list[GitHubUser]:
33+
"""Every user row. Used by credential rotation, which must miss nobody."""
34+
result = await session.execute(select(GitHubUser).order_by(GitHubUser.created_at))
35+
return list(result.scalars().all())

apps/api/src/helprs/modules/identity/service.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ async def sync_user(
5252
settings: Settings,
5353
) -> GitHubUser:
5454
"""Create the user for this GitHub identity, or refresh the stored one."""
55-
encrypted_token = fernet_encrypt(access_token, settings.FERNET_KEY.get_secret_value())
55+
encrypted_token = fernet_encrypt(access_token, settings.fernet_keys)
5656
user = await repository.get_by_github_id(session, profile.github_id)
5757

5858
if user is None:
@@ -75,10 +75,10 @@ async def sync_user(
7575
return user
7676

7777

78-
def get_decrypted_github_token(user: GitHubUser, fernet_key: str) -> str:
78+
def get_decrypted_github_token(user: GitHubUser, fernet_keys: list[str]) -> str:
7979
"""Decrypt a user's stored GitHub access token."""
8080
try:
81-
return fernet_decrypt(user.github_access_token_enc, fernet_key)
81+
return fernet_decrypt(user.github_access_token_enc, fernet_keys)
8282
except InvalidToken as e:
8383
raise UnauthorizedError("Stored GitHub token is corrupted") from e
8484

apps/api/src/helprs/modules/installation/repository.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,14 @@ async def add_byok_config(session: AsyncSession, config: BYOKConfig) -> BYOKConf
9595
async def delete_byok_config(session: AsyncSession, config: BYOKConfig) -> None:
9696
await session.delete(config)
9797
await session.flush()
98+
99+
100+
async def list_all_byok_configs(session: AsyncSession) -> list[BYOKConfig]:
101+
"""Every BYOK row, including those of soft-deleted installations.
102+
103+
Deliberately not filtered by ``_active()``: a retired Fernet key cannot be
104+
dropped while any stored ciphertext still needs it, and a soft-deleted
105+
installation's row is still stored ciphertext.
106+
"""
107+
result = await session.execute(select(BYOKConfig).order_by(BYOKConfig.created_at))
108+
return list(result.scalars().all())

apps/api/src/helprs/modules/installation/router.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ async def post_byok(
102102
installation = await _installation_or_404(session, installation_id)
103103
await verify_admin_permission(user, installation, settings)
104104

105-
config = await configure_byok(session, installation.id, body.api_key, settings.FERNET_KEY.get_secret_value())
105+
config = await configure_byok(session, installation.id, body.api_key, settings.fernet_keys)
106106
return BYOKConfigResponse.model_validate(config)
107107

108108

apps/api/src/helprs/modules/installation/service.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ async def mint_installation_token(
195195

196196
def _user_github_token(user: "GitHubUser", settings: Settings) -> str:
197197
try:
198-
return fernet_decrypt(user.github_access_token_enc, settings.FERNET_KEY.get_secret_value())
198+
return fernet_decrypt(user.github_access_token_enc, settings.fernet_keys)
199199
except InvalidToken as e:
200200
raise UnauthorizedError("Stored GitHub token is corrupted") from e
201201

@@ -295,13 +295,13 @@ async def configure_byok(
295295
session: AsyncSession,
296296
installation_id: uuid.UUID,
297297
api_key: str,
298-
fernet_key: str,
298+
fernet_keys: list[str],
299299
) -> BYOKConfig:
300300
"""Validate a Claude credential, then store it encrypted."""
301301
if not await anthropic.is_credential_valid(api_key):
302302
raise BYOKKeyInvalidError("API key validation failed -- check your key and try again")
303303

304-
encrypted_key = fernet_encrypt(api_key, fernet_key)
304+
encrypted_key = fernet_encrypt(api_key, fernet_keys)
305305
key_hint = f"...{api_key[-4:]}"
306306
now = datetime.now(UTC)
307307

@@ -333,10 +333,10 @@ async def get_byok_config(session: AsyncSession, installation_id: uuid.UUID) ->
333333
return await repository.get_byok_config(session, installation_id)
334334

335335

336-
def decrypt_byok_key(byok_config: BYOKConfig, fernet_key: str) -> str:
336+
def decrypt_byok_key(byok_config: BYOKConfig, fernet_keys: list[str]) -> str:
337337
"""Decrypt the stored Claude credential."""
338338
try:
339-
return fernet_decrypt(byok_config.encrypted_api_key, fernet_key)
339+
return fernet_decrypt(byok_config.encrypted_api_key, fernet_keys)
340340
except InvalidToken as e:
341341
raise BYOKKeyInvalidError("Stored API key could not be decrypted") from e
342342

0 commit comments

Comments
 (0)