Skip to content

feat(security): support Fernet key rotation via MultiFernet - #69

Merged
mariuspruvot merged 1 commit into
mainfrom
feat/fernet-key-rotation
Aug 1, 2026
Merged

feat(security): support Fernet key rotation via MultiFernet#69
mariuspruvot merged 1 commit into
mainfrom
feat/fernet-key-rotation

Conversation

@mariuspruvot

Copy link
Copy Markdown
Owner

FERNET_KEY encrypts the GitHub OAuth tokens and Claude BYOK credentials stored in the database. Replacing it made all of them unreadable at once — so in practice it could not be replaced, which is an unfortunate property for a key whose possible exposure is the entire reason it exists.

What changed

The crypto helpers 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 rotation is a deploy rather than an outage:

FERNET_KEY=<new>
FERNET_KEY_FALLBACKS=["<old>"]

Fallbacks are validated at boot alongside the primary key, and the error names the offending index. A malformed retired key must not surface at first decrypt — the row that would break is precisely the credential the rotation is trying not to lose.

The script is the half that makes it finish

uv run python -m helprs.scripts.rotate_credentials re-encrypts every stored credential under the primary key, using MultiFernet.rotate so it never needs the plaintext. Without it the fallback list grows forever and a "retired" key stays exactly as sensitive as the live one — the key is only genuinely retired once nothing is still encrypted with it.

  • One transaction: a partial rewrite would leave the operator unable to tell which key each row is under.
  • Safe to re-run.
  • A row that no configured key can read is named in the report and left intact, exiting non-zero. Overwriting it would destroy a recoverable credential; leaving it lets a later run recover it once the missing key is supplied.
  • list_all_byok_configs deliberately ignores the _active() soft-delete filter — a soft-deleted installation's ciphertext still pins the old key.

Two design notes worth the review

The keyset is list[str], not Sequence[str]. I wrote Sequence[str] first and the test suite exploded while mypy stayed silent — because str satisfies Sequence[str], so every call site still passing a single key type-checked cleanly and only failed at runtime, inside Fernet(key.encode()) on a one-character string. list[str] closes that: mypy found all six call sites itself.

settings.fernet_keys is a plain @property, not a computed_field. A computed field would put every key back into model_dump() output and quietly undo the SecretStr work from #65. There is a test asserting a fallback key does not appear in repr / str / model_dump.

Verification

  • 411 tests pass (was 398), ruff + ruff format + mypy clean
  • New coverage: a value written by a retired key stays readable; a value written after rotation needs only the new key; rotate is idempotent; rotate fails loudly when no key matches; an empty keyset is rejected
  • Three end-to-end tests of the script against a real Postgres, including the orphan-row case
  • The operator flow was run for real, not just unit-tested: seeded a credential under an old key, ran the CLI with the new key primary and the old as fallback, confirmed the value decrypts with the new key alone afterwards

Docs

A rotation runbook in docs/self-hosting.md, the new setting in .env.example, and the keyset invariant in CLAUDE.md.

Also removed a stale line sitting next to ADMIN_PASSWORD in .env.example: "In development mode, any password is accepted." That was true, it was the auth bypass fixed in #60, and it was still telling self-hosters the panel is open in development.

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.
@helprs-prod

helprs-prod Bot commented Aug 1, 2026

Copy link
Copy Markdown

helPRs session created for this PR.

Skill: challenge-me | Open session

@mariuspruvot
mariuspruvot merged commit e95a398 into main Aug 1, 2026
12 checks passed
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