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

@@ -189,7 +189,23 @@ pub async fn build_app(
let broadcaster_concrete = Arc::new(InProcessBroadcaster::from_env());
let broadcaster: Arc<dyn RealtimeBroadcaster> = broadcaster_concrete.clone();
let topic_repo: Arc<dyn TopicRepo> = Arc::new(PostgresTopicRepo::new(pool.clone()));
let app_secrets_repo = Arc::new(PostgresAppSecretsRepo::new(pool.clone()));
let app_secrets_repo = Arc::new(PostgresAppSecretsRepo::new(
pool.clone(),
master_key.clone(),
));
// v1.1.7 two-phase migration: encrypt any plaintext realtime signing
// keys at rest. Idempotent — only touches rows not yet encrypted. The
// plaintext column is dropped in v1.1.8.
match app_secrets_repo.migrate_plaintext_keys().await {
Ok(0) => {}
Ok(n) => tracing::info!(
migrated = n,
"encrypted plaintext realtime signing keys at rest"
),
Err(e) => {
tracing::error!(error = %e, "failed to encrypt realtime signing keys (continuing)")
}
}
let realtime_authority: Arc<dyn RealtimeAuthority> = Arc::new(RealtimeAuthorityImpl::new(
topic_repo.clone(),
app_secrets_repo.clone(),