From fffcdf6169b8e480788ad27096d0df85b3f3b406 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 4 Jun 2026 22:33:23 +0200 Subject: [PATCH] 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) --- .../migrations/0025_encrypt_realtime_keys.sql | 24 ++ crates/manager-core/src/app_secrets_repo.rs | 210 +++++++++++++++--- crates/picloud/src/lib.rs | 18 +- 3 files changed, 221 insertions(+), 31 deletions(-) create mode 100644 crates/manager-core/migrations/0025_encrypt_realtime_keys.sql diff --git a/crates/manager-core/migrations/0025_encrypt_realtime_keys.sql b/crates/manager-core/migrations/0025_encrypt_realtime_keys.sql new file mode 100644 index 0000000..92a10d2 --- /dev/null +++ b/crates/manager-core/migrations/0025_encrypt_realtime_keys.sql @@ -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; diff --git a/crates/manager-core/src/app_secrets_repo.rs b/crates/manager-core/src/app_secrets_repo.rs index ad0648c..6ae0dee 100644 --- a/crates/manager-core/src/app_secrets_repo.rs +++ b/crates/manager-core/src/app_secrets_repo.rs @@ -1,19 +1,23 @@ -//! `AppSecretsRepo` — per-app secret material (v1.1.6). +//! `AppSecretsRepo` — per-app secret material (v1.1.6, encrypted v1.1.7). //! -//! Today this holds only the HMAC signing key for realtime subscriber -//! tokens. The key is generated lazily (32 random bytes) on the first +//! Holds the HMAC signing key for realtime subscriber tokens. The key is +//! generated lazily (32 random bytes) on the first //! `pubsub::subscriber_token` call for an app and never changes -//! thereafter in v1.1.6 (no rotation API yet — rotation is the -//! key-invalidation mechanism, deferred). The key is never exposed to +//! thereafter (no rotation API yet). The key is never exposed to //! scripts: the SDK mints tokens, it never returns the key. //! -//! This table is the natural home for v1.1.7's encrypted per-app -//! secrets work. +//! **v1.1.7 at-rest encryption (two-phase).** The key is now sealed with +//! the process master key (AES-256-GCM). New keys are written +//! encrypted-only; the startup task [`PostgresAppSecretsRepo::migrate_plaintext_keys`] +//! encrypts any pre-existing plaintext rows. The read path prefers the +//! encrypted columns and falls back to the plaintext column during the +//! compat window (migration 0025 made it NULL-able; v1.1.8 drops it). use async_trait::async_trait; -use picloud_shared::AppId; +use picloud_shared::{crypto, AppId, MasterKey}; use rand::RngCore; use sqlx::PgPool; +use uuid::Uuid; /// Length of a freshly-generated realtime signing key. pub const SIGNING_KEY_LEN: usize = 32; @@ -22,14 +26,19 @@ pub const SIGNING_KEY_LEN: usize = 32; pub enum AppSecretsRepoError { #[error("database error: {0}")] Db(#[from] sqlx::Error), + + /// A stored encrypted signing key could not be decrypted — corrupted + /// row or a master-key mismatch (e.g. `PICLOUD_SECRET_KEY` changed). + #[error("realtime signing key could not be decrypted (corrupted row or master-key mismatch)")] + Crypto, } #[async_trait] pub trait AppSecretsRepo: Send + Sync { /// Fetch the app's realtime signing key, generating + persisting one - /// (32 random bytes) if absent. Idempotent under concurrency: a - /// racing creator's `ON CONFLICT DO NOTHING` insert is a no-op and - /// the existing key is returned. + /// (32 random bytes, encrypted) if absent. Idempotent under + /// concurrency: a racing creator's `ON CONFLICT DO NOTHING` insert is + /// a no-op and the existing key is returned. async fn get_or_create_signing_key( &self, app_id: AppId, @@ -43,12 +52,78 @@ pub trait AppSecretsRepo: Send + Sync { pub struct PostgresAppSecretsRepo { pool: PgPool, + master_key: MasterKey, } impl PostgresAppSecretsRepo { #[must_use] - pub fn new(pool: PgPool) -> Self { - Self { pool } + pub fn new(pool: PgPool, master_key: MasterKey) -> Self { + Self { pool, master_key } + } + + /// Startup task (v1.1.7): encrypt every row that still has a + /// plaintext key but no encrypted key. Plaintext is left in place + /// (the read path prefers the encrypted columns); the plaintext + /// column is dropped in v1.1.8. Returns the number of rows migrated. + /// + /// # Errors + /// + /// Propagates database errors. + pub async fn migrate_plaintext_keys(&self) -> Result { + let rows: Vec<(Uuid, Vec)> = sqlx::query_as( + "SELECT app_id, realtime_signing_key FROM app_secrets \ + WHERE realtime_signing_key_encrypted IS NULL \ + AND realtime_signing_key IS NOT NULL", + ) + .fetch_all(&self.pool) + .await?; + + let mut migrated = 0; + for (app_id, plaintext) in rows { + let enc = crypto::encrypt(&plaintext, self.master_key.as_bytes()); + sqlx::query( + "UPDATE app_secrets \ + SET realtime_signing_key_encrypted = $2, \ + realtime_signing_key_nonce = $3, \ + updated_at = NOW() \ + WHERE app_id = $1 AND realtime_signing_key_encrypted IS NULL", + ) + .bind(app_id) + .bind(&enc.ciphertext) + .bind(&enc.nonce[..]) + .execute(&self.pool) + .await?; + migrated += 1; + } + Ok(migrated) + } + + fn decode( + &self, + encrypted: Option>, + nonce: Option>, + plaintext: Option>, + ) -> Result>, AppSecretsRepoError> { + decode_signing_key(&self.master_key, encrypted, nonce, plaintext) + } +} + +/// Resolve the signing key from a row's three columns. **Encrypted wins** +/// when present; otherwise fall back to the plaintext column (compat for +/// un-migrated rows / the post-v1.1.8 dropped-plaintext state). +fn decode_signing_key( + master_key: &MasterKey, + encrypted: Option>, + nonce: Option>, + plaintext: Option>, +) -> Result>, AppSecretsRepoError> { + match (encrypted, nonce) { + (Some(ct), Some(n)) => { + let key = crypto::decrypt(&ct, &n, master_key.as_bytes()) + .map_err(|_| AppSecretsRepoError::Crypto)?; + Ok(Some(key)) + } + _ => Ok(plaintext), } } @@ -60,32 +135,107 @@ impl AppSecretsRepo for PostgresAppSecretsRepo { ) -> Result, AppSecretsRepoError> { let mut fresh = vec![0u8; SIGNING_KEY_LEN]; rand::thread_rng().fill_bytes(&mut fresh); + let enc = crypto::encrypt(&fresh, self.master_key.as_bytes()); - // Insert-if-absent then read: the racing-creator's insert is a - // no-op, and the SELECT always returns the winning key. + // Insert-if-absent (encrypted-only). The racing-creator's insert + // is a no-op; the SELECT always returns the winning row. sqlx::query( - "INSERT INTO app_secrets (app_id, realtime_signing_key) \ - VALUES ($1, $2) ON CONFLICT (app_id) DO NOTHING", + "INSERT INTO app_secrets \ + (app_id, realtime_signing_key_encrypted, realtime_signing_key_nonce) \ + VALUES ($1, $2, $3) ON CONFLICT (app_id) DO NOTHING", ) .bind(app_id.into_inner()) - .bind(&fresh) + .bind(&enc.ciphertext) + .bind(&enc.nonce[..]) .execute(&self.pool) .await?; - let key: (Vec,) = - sqlx::query_as("SELECT realtime_signing_key FROM app_secrets WHERE app_id = $1") - .bind(app_id.into_inner()) - .fetch_one(&self.pool) - .await?; - Ok(key.0) + let row: (Option>, Option>, Option>) = sqlx::query_as( + "SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \ + realtime_signing_key \ + FROM app_secrets WHERE app_id = $1", + ) + .bind(app_id.into_inner()) + .fetch_one(&self.pool) + .await?; + // A row exists by construction, so a key must decode. + self.decode(row.0, row.1, row.2)? + .ok_or(AppSecretsRepoError::Crypto) } async fn signing_key(&self, app_id: AppId) -> Result>, AppSecretsRepoError> { - let row: Option<(Vec,)> = - sqlx::query_as("SELECT realtime_signing_key FROM app_secrets WHERE app_id = $1") - .bind(app_id.into_inner()) - .fetch_optional(&self.pool) - .await?; - Ok(row.map(|r| r.0)) + let row: Option<(Option>, Option>, Option>)> = sqlx::query_as( + "SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \ + realtime_signing_key \ + FROM app_secrets WHERE app_id = $1", + ) + .bind(app_id.into_inner()) + .fetch_optional(&self.pool) + .await?; + match row { + Some((e, n, p)) => self.decode(e, n, p), + None => Ok(None), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key() -> MasterKey { + MasterKey::from_bytes([9u8; 32]) + } + + #[test] + fn encrypted_wins_over_plaintext() { + let mk = key(); + let secret = vec![1u8, 2, 3, 4]; + let enc = crypto::encrypt(&secret, mk.as_bytes()); + // Both present → the encrypted value is returned (not the bogus + // plaintext). + let got = decode_signing_key( + &mk, + Some(enc.ciphertext), + Some(enc.nonce.to_vec()), + Some(vec![0xff; 32]), + ) + .unwrap(); + assert_eq!(got, Some(secret)); + } + + #[test] + fn falls_back_to_plaintext_when_encrypted_absent() { + let mk = key(); + let plaintext = vec![7u8; 32]; + let got = decode_signing_key(&mk, None, None, Some(plaintext.clone())).unwrap(); + assert_eq!(got, Some(plaintext)); + } + + #[test] + fn encrypted_present_plaintext_null_works() { + // Post-v1.1.8 state: only the encrypted columns are populated. + let mk = key(); + let secret = vec![5u8; 32]; + let enc = crypto::encrypt(&secret, mk.as_bytes()); + let got = + decode_signing_key(&mk, Some(enc.ciphertext), Some(enc.nonce.to_vec()), None).unwrap(); + assert_eq!(got, Some(secret)); + } + + #[test] + fn missing_everything_is_none() { + let got = decode_signing_key(&key(), None, None, None).unwrap(); + assert_eq!(got, None); + } + + #[test] + fn wrong_master_key_is_crypto_error() { + let secret = vec![3u8; 32]; + let enc = crypto::encrypt(&secret, key().as_bytes()); + let other = MasterKey::from_bytes([1u8; 32]); + let err = decode_signing_key(&other, Some(enc.ciphertext), Some(enc.nonce.to_vec()), None) + .unwrap_err(); + assert!(matches!(err, AppSecretsRepoError::Crypto)); } } diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index bb717db..e6d38f9 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -189,7 +189,23 @@ pub async fn build_app( let broadcaster_concrete = Arc::new(InProcessBroadcaster::from_env()); let broadcaster: Arc = broadcaster_concrete.clone(); let topic_repo: Arc = 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 = Arc::new(RealtimeAuthorityImpl::new( topic_repo.clone(), app_secrets_repo.clone(),