feat(config): consolidate the at-rest keys into APP_ENCRYPTION_KEY #483
Loading…
Reference in a new issue
No description provided.
Delete branch "chore/BUNYIP-483-single-app-encryption-key"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 thestripe_configsecrets and theemail_configSMTP 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, withAPP_ENCRYPTION_KEY_PREV(comma-separated hex list) andAPP_KEY_VERSION.Config::app_key_set()builds the singleAppKeySetthat main.rs hands toTotpService, the Stripe config path andEmailConfig::from_db_rowalike. 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
EncryptionKeySetmodels exactly one previous key, which cannot hold BOTH retired keys at once, sobunyip_domain::services::AppKeySetwraps 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-secretssubcommand (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-355pending_*staging columns the admin rotation endpoint used to miss, and is idempotent:AppKeySet::rewritereturns 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 anemailkey id; key health reports "healthy" when any key in the set reads the value and flagsneeds_reencryptwhen it is not the current one.Infrastructure and docs follow: compose.yml mounts one
app_encryption_keysecret (plus anAPP_ENCRYPTION_KEY_PREVpassthrough), compose.dev.yml and the justfile'sensure-envhandle one key, init-secrets.sh generates one, and.env.example, CLAUDE.md and the runbooks are updated.docs/encryption-key-rotation.mdis the new operator runbook for both an ordinary rotation and the two-key migration.scripts/check-no-legacy-key-env.sh(wired intojust checkand 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