feat(config): consolidate the at-rest keys into APP_ENCRYPTION_KEY #483

Merged
David merged 3 commits from chore/BUNYIP-483-single-app-encryption-key into main 2026-08-06 12:20:40 +02:00
Member

Bunyip encrypted three kinds of secret at rest with two independent key sets whose names no longer described what they protected: the TOTP family guarded user_totp, and the Stripe family guarded BOTH the stripe_config secrets and the email_config SMTP password. Same primitive, same rotation mechanism, twice the provisioning and rotation cost, and one of the two names was already a misnomer.

There is now ONE at-rest key: APP_ENCRYPTION_KEY, with APP_ENCRYPTION_KEY_PREV (comma-separated hex list) and APP_KEY_VERSION. Config::app_key_set() builds the single AppKeySet that main.rs hands to TotpService, the Stripe config path and EmailConfig::from_db_row alike. Production still panics when the key is unset and non-production still falls back to the all-zero DEVELOPMENT key with a warning naming the new variable.

Choice for the transitional decrypt (the issue left it open): a bunyip-side wrapper, not a widened kernel type. dunite-core's EncryptionKeySet models exactly one previous key, which cannot hold BOTH retired keys at once, so bunyip_domain::services::AppKeySet wraps it and takes a LIST of previous keys. Decrypt tries the current key then every previous key, so a database populated under the two old keys reads correctly for the whole migration window; encryption still goes through the kernel's AES-256-GCM path, so ciphertexts are unchanged.

The re-encrypt pass is the bunyip-api reencrypt-secrets subcommand (operator-controlled, so a backup can be taken first; it does not start the server or run migrations). It covers every at-rest column, including the BUNYIP-355 pending_* staging columns the admin rotation endpoint used to miss, and is idempotent: AppKeySet::rewrite returns None for a value already on the current key and version, so a second run rewrites nothing. A value no key decrypts is reported by name and left untouched, never cleared, and the command exits non-zero. The admin key-rotation endpoints now delegate to the same pass and gained an email key id; key health reports "healthy" when any key in the set reads the value and flags needs_reencrypt when it is not the current one.

Infrastructure and docs follow: compose.yml mounts one app_encryption_key secret (plus an APP_ENCRYPTION_KEY_PREV passthrough), compose.dev.yml and the justfile's ensure-env handle one key, init-secrets.sh generates one, and .env.example, CLAUDE.md and the runbooks are updated. docs/encryption-key-rotation.md is the new operator runbook for both an ordinary rotation and the two-key migration. scripts/check-no-legacy-key-env.sh (wired into just check and CI) fails the build if a retired per-consumer key name reappears, and the BUNYIP-482 Stripe gate lost its encryption-key allowlist. Committed migrations are excluded from both gates because they are immutable.

#BUNYIP-483

Bunyip encrypted three kinds of secret at rest with two independent key sets whose names no longer described what they protected: the TOTP family guarded `user_totp`, and the Stripe family guarded BOTH the `stripe_config` secrets and the `email_config` SMTP password. Same primitive, same rotation mechanism, twice the provisioning and rotation cost, and one of the two names was already a misnomer. There is now ONE at-rest key: `APP_ENCRYPTION_KEY`, with `APP_ENCRYPTION_KEY_PREV` (comma-separated hex list) and `APP_KEY_VERSION`. `Config::app_key_set()` builds the single `AppKeySet` that main.rs hands to `TotpService`, the Stripe config path and `EmailConfig::from_db_row` alike. Production still panics when the key is unset and non-production still falls back to the all-zero DEVELOPMENT key with a warning naming the new variable. Choice for the transitional decrypt (the issue left it open): a bunyip-side wrapper, not a widened kernel type. dunite-core's `EncryptionKeySet` models exactly one previous key, which cannot hold BOTH retired keys at once, so `bunyip_domain::services::AppKeySet` wraps it and takes a LIST of previous keys. Decrypt tries the current key then every previous key, so a database populated under the two old keys reads correctly for the whole migration window; encryption still goes through the kernel's AES-256-GCM path, so ciphertexts are unchanged. The re-encrypt pass is the `bunyip-api reencrypt-secrets` subcommand (operator-controlled, so a backup can be taken first; it does not start the server or run migrations). It covers every at-rest column, including the BUNYIP-355 `pending_*` staging columns the admin rotation endpoint used to miss, and is idempotent: `AppKeySet::rewrite` returns None for a value already on the current key and version, so a second run rewrites nothing. A value no key decrypts is reported by name and left untouched, never cleared, and the command exits non-zero. The admin key-rotation endpoints now delegate to the same pass and gained an `email` key id; key health reports "healthy" when any key in the set reads the value and flags `needs_reencrypt` when it is not the current one. Infrastructure and docs follow: compose.yml mounts one `app_encryption_key` secret (plus an `APP_ENCRYPTION_KEY_PREV` passthrough), compose.dev.yml and the justfile's `ensure-env` handle one key, init-secrets.sh generates one, and `.env.example`, CLAUDE.md and the runbooks are updated. `docs/encryption-key-rotation.md` is the new operator runbook for both an ordinary rotation and the two-key migration. `scripts/check-no-legacy-key-env.sh` (wired into `just check` and CI) fails the build if a retired per-consumer key name reappears, and the BUNYIP-482 Stripe gate lost its encryption-key allowlist. Committed migrations are excluded from both gates because they are immutable. #BUNYIP-483
feat(config): consolidate the at-rest keys into APP_ENCRYPTION_KEY
Some checks failed
Check / fmt + clippy + build + tests (pull_request) Failing after 4s
E2E PR gate / E2E / PR gate (pull_request) Successful in 12s
8a010e728f
Bunyip encrypted three kinds of secret at rest with two independent key sets whose names no longer described what they protected: the TOTP family guarded `user_totp`, and the Stripe family guarded BOTH the `stripe_config` secrets and the `email_config` SMTP password. Same primitive, same rotation mechanism, twice the provisioning and rotation cost, and one of the two names was already a misnomer.

There is now ONE at-rest key: `APP_ENCRYPTION_KEY`, with `APP_ENCRYPTION_KEY_PREV` (comma-separated hex list) and `APP_KEY_VERSION`. `Config::app_key_set()` builds the single `AppKeySet` that main.rs hands to `TotpService`, the Stripe config path and `EmailConfig::from_db_row` alike. Production still panics when the key is unset and non-production still falls back to the all-zero DEVELOPMENT key with a warning naming the new variable.

Choice for the transitional decrypt (the issue left it open): a bunyip-side wrapper, not a widened kernel type. dunite-core's `EncryptionKeySet` models exactly one previous key, which cannot hold BOTH retired keys at once, so `bunyip_domain::services::AppKeySet` wraps it and takes a LIST of previous keys. Decrypt tries the current key then every previous key, so a database populated under the two old keys reads correctly for the whole migration window; encryption still goes through the kernel's AES-256-GCM path, so ciphertexts are unchanged.

The re-encrypt pass is the `bunyip-api reencrypt-secrets` subcommand (operator-controlled, so a backup can be taken first; it does not start the server or run migrations). It covers every at-rest column, including the BUNYIP-355 `pending_*` staging columns the admin rotation endpoint used to miss, and is idempotent: `AppKeySet::rewrite` returns None for a value already on the current key and version, so a second run rewrites nothing. A value no key decrypts is reported by name and left untouched, never cleared, and the command exits non-zero. The admin key-rotation endpoints now delegate to the same pass and gained an `email` key id; key health reports "healthy" when any key in the set reads the value and flags `needs_reencrypt` when it is not the current one.

Infrastructure and docs follow: compose.yml mounts one `app_encryption_key` secret (plus an `APP_ENCRYPTION_KEY_PREV` passthrough), compose.dev.yml and the justfile's `ensure-env` handle one key, init-secrets.sh generates one, and `.env.example`, CLAUDE.md and the runbooks are updated. `docs/encryption-key-rotation.md` is the new operator runbook for both an ordinary rotation and the two-key migration. `scripts/check-no-legacy-key-env.sh` (wired into `just check` and CI) fails the build if a retired per-consumer key name reappears, and the BUNYIP-482 Stripe gate lost its encryption-key allowlist. Committed migrations are excluded from both gates because they are immutable.

#BUNYIP-483
Merge branch 'main' into chore/BUNYIP-483-single-app-encryption-key
Some checks failed
Check / fmt + clippy + build + tests (pull_request) Failing after 8s
E2E PR gate / E2E / PR gate (pull_request) Successful in 37s
651732e1f3
fix(ci): unblock the Stripe-env gate and point the transitional key layer at its removal
All checks were successful
E2E PR gate / E2E / PR gate (pull_request) Successful in 23s
Check / fmt + clippy + build + tests (pull_request) Successful in 14m19s
Create release / Create release from merged PR (pull_request) Has been skipped
827fefbd27
The gate greps every SCREAMING_SNAKE token containing STRIPE_ across all tracked files, not env reads, so it flagged eight non-env hits: the `LEGACY_STRIPE_KEY` test constant in `app_key.rs` (a 32-byte AES fixture standing in for the retired at-rest key, six sites) and two comment lines in the sibling gate `check-no-legacy-key-env.sh`, which excludes itself but was invisible to its neighbour.

Rename the constant to `LEGACY_CONFIG_KEY`, which is also more accurate: the retired `STRIPE_ENCRYPTION_KEY` protected `email_config.smtp_password` as well as `stripe_config`, so it was never Stripe-only. Widen both gates' self-exclusion to `scripts/check-no-*-env.sh` so each env-name gate skips both, since a gate has to spell out the variables it forbids.

`AppKeySet`'s list of previous keys (rather than dunite's single previous key) is the one genuinely transitional piece of BUNYIP-483: it exists so rows written under both retired key families decrypt at once, and becomes unreachable once every deployment has run `reencrypt-secrets`. That removal is now tracked as BUNYIP-491 and referenced from the module doc, the `previous` field doc, the rotation runbook's migration section, and the CLAUDE.md at-rest-encryption bullet, so "legacy" names code with a scheduled exit rather than code with no future action.

#BUNYIP-483
David scheduled this pull request to auto merge when all checks succeed 2026-08-06 12:07:28 +02:00
David merged commit 5230768d6f into main 2026-08-06 12:20:40 +02:00
David deleted branch chore/BUNYIP-483-single-app-encryption-key 2026-08-06 12:20:40 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!483
No description provided.