diff --git a/crates/manager-core/migrations/0032_drop_plaintext_realtime_signing_key.sql b/crates/manager-core/migrations/0032_drop_plaintext_realtime_signing_key.sql new file mode 100644 index 0000000..9501532 --- /dev/null +++ b/crates/manager-core/migrations/0032_drop_plaintext_realtime_signing_key.sql @@ -0,0 +1,33 @@ +-- v1.1.8 F1 — drop the plaintext realtime_signing_key column. +-- +-- v1.1.7 added at-rest encryption (migration 0025) and a startup task +-- that backfilled encryption over the plaintext column. Once every +-- existing row has an encrypted counterpart, the plaintext column is +-- pure dead weight (and a footgun: anyone with DB-read access can +-- still read the signing key for any app that lived through the +-- migration). +-- +-- The guard refuses to drop if any row still has a plaintext value +-- without an encrypted counterpart. This makes the migration safe to +-- replay and forces operators who skipped v1.1.7 to apply it first: +-- the v1.1.7 startup task is the only thing that knows how to +-- encrypt the existing plaintext rows. +-- +-- Operators upgrading from v1.1.6 or earlier MUST apply v1.1.7 +-- first (and let its startup encryption sweep complete) before +-- applying this migration. The CHANGELOG and HANDBACK make this +-- mandatory; the guard below is the belt-and-suspenders. + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM app_secrets + WHERE realtime_signing_key IS NOT NULL + AND realtime_signing_key_encrypted IS NULL + ) THEN + RAISE EXCEPTION + 'v1.1.8 migration 0032 refused: unencrypted realtime_signing_key rows still present. Apply v1.1.7 first and ensure its startup encryption sweep has completed.'; + END IF; +END$$; + +ALTER TABLE app_secrets DROP COLUMN IF EXISTS realtime_signing_key; diff --git a/crates/manager-core/src/app_secrets_repo.rs b/crates/manager-core/src/app_secrets_repo.rs index 6ae0dee..b35e169 100644 --- a/crates/manager-core/src/app_secrets_repo.rs +++ b/crates/manager-core/src/app_secrets_repo.rs @@ -1,4 +1,5 @@ -//! `AppSecretsRepo` — per-app secret material (v1.1.6, encrypted v1.1.7). +//! `AppSecretsRepo` — per-app secret material (v1.1.6, encrypted v1.1.7, +//! plaintext column dropped v1.1.8 F1). //! //! Holds the HMAC signing key for realtime subscriber tokens. The key is //! generated lazily (32 random bytes) on the first @@ -6,18 +7,17 @@ //! thereafter (no rotation API yet). The key is never exposed to //! scripts: the SDK mints tokens, it never returns the key. //! -//! **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). +//! **v1.1.7 at-rest encryption.** The key is sealed with the process +//! master key (AES-256-GCM). v1.1.8 (this commit) removes the +//! plaintext fallback column — the read path consults only the +//! encrypted columns now. The 0032 migration enforces this by +//! refusing to drop the plaintext column unless every existing row +//! has been encrypted by the v1.1.7 startup sweep first. use async_trait::async_trait; 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; @@ -61,61 +61,24 @@ impl PostgresAppSecretsRepo { 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) + decode_signing_key(&self.master_key, encrypted, nonce) } } -/// 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). +/// Resolve the signing key from a row's encrypted columns. The +/// plaintext fallback that existed in v1.1.7 is gone — v1.1.8 F1 +/// drops the column outright (migration 0032 refuses to apply if any +/// row still needs encryption, guaranteeing the read path can't see +/// a row that only has a plaintext value). fn decode_signing_key( master_key: &MasterKey, encrypted: Option>, nonce: Option>, - plaintext: Option>, ) -> Result>, AppSecretsRepoError> { match (encrypted, nonce) { (Some(ct), Some(n)) => { @@ -123,7 +86,7 @@ fn decode_signing_key( .map_err(|_| AppSecretsRepoError::Crypto)?; Ok(Some(key)) } - _ => Ok(plaintext), + _ => Ok(None), } } @@ -150,30 +113,27 @@ impl AppSecretsRepo for PostgresAppSecretsRepo { .execute(&self.pool) .await?; - let row: (Option>, Option>, Option>) = sqlx::query_as( - "SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \ - realtime_signing_key \ + let row: (Option>, Option>) = sqlx::query_as( + "SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce \ 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) + self.decode(row.0, row.1)?.ok_or(AppSecretsRepoError::Crypto) } async fn signing_key(&self, app_id: AppId) -> Result>, AppSecretsRepoError> { - let row: Option<(Option>, Option>, Option>)> = sqlx::query_as( - "SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \ - realtime_signing_key \ + let row: Option<(Option>, Option>)> = sqlx::query_as( + "SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce \ 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), + Some((e, n)) => self.decode(e, n), None => Ok(None), } } @@ -188,44 +148,17 @@ mod tests { } #[test] - fn encrypted_wins_over_plaintext() { + fn encrypted_decodes() { 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(); + let got = decode_signing_key(&mk, Some(enc.ciphertext), Some(enc.nonce.to_vec())).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(); + fn missing_columns_is_none() { + let got = decode_signing_key(&key(), None, None).unwrap(); assert_eq!(got, None); } @@ -234,7 +167,7 @@ mod tests { 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) + let err = decode_signing_key(&other, Some(enc.ciphertext), Some(enc.nonce.to_vec())) .unwrap_err(); assert!(matches!(err, AppSecretsRepoError::Crypto)); } diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 5eb792c..74b2cbe 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -196,21 +196,11 @@ pub async fn build_app( 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)"); - } - } + // v1.1.7's migrate_plaintext_keys startup sweep was deleted in + // v1.1.8 F1 alongside migration 0032, which drops the plaintext + // column. Operators upgrading from v1.1.6 or earlier MUST apply + // v1.1.7 first; the 0032 migration's guard refuses to run + // otherwise. let realtime_authority: Arc = Arc::new(RealtimeAuthorityImpl::new( topic_repo.clone(), app_secrets_repo.clone(),