feat(v1.1.7-realtime-migration): encrypt signing keys at rest

Two-phase encryption of app_secrets.realtime_signing_key:
- migration 0025 adds NULL-able realtime_signing_key_encrypted +
  _nonce columns and drops NOT NULL on the plaintext column.
- PostgresAppSecretsRepo now holds the master key: new keys are written
  encrypted-only; reads prefer the encrypted columns and fall back to
  plaintext during the compat window.
- Startup task migrate_plaintext_keys() encrypts any pre-existing
  plaintext rows (plaintext left in place for rollback safety).
- v1.1.8 will drop the plaintext column.

The RealtimeAuthority read path is unchanged (it calls signing_key),
so SSE keeps working throughout. Unit tests cover the
encrypted-wins / plaintext-fallback / post-drop precedence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-04 22:33:23 +02:00
parent 02335a8132
commit fffcdf6169
3 changed files with 221 additions and 31 deletions

View File

@@ -0,0 +1,24 @@
-- v1.1.7: encrypt the realtime signing key at rest (two-phase).
--
-- Phase 1 (this migration + the v1.1.7 startup task):
-- * add NULL-able encrypted columns,
-- * drop the NOT NULL on the plaintext column so newly-generated keys
-- can be stored encrypted-only,
-- * the application startup task `migrate_plaintext_keys` encrypts each
-- existing plaintext key into the new columns (plaintext is LEFT in
-- place during the compat window for rollback safety).
--
-- The `RealtimeAuthorityImpl` read path prefers the encrypted columns and
-- falls back to plaintext, so SSE keeps working throughout.
--
-- Phase 2 (v1.1.8): once all rows are migrated, a follow-up migration
-- drops the plaintext `realtime_signing_key` column.
ALTER TABLE app_secrets
ADD COLUMN realtime_signing_key_encrypted BYTEA,
ADD COLUMN realtime_signing_key_nonce BYTEA;
-- New keys (post-v1.1.7) are stored encrypted-only, so the plaintext
-- column must accept NULL.
ALTER TABLE app_secrets
ALTER COLUMN realtime_signing_key DROP NOT NULL;