From 3c2c4a376775d51b51f78d2729e2b872e06f8e2b Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 6 Jun 2026 14:56:27 +0200 Subject: [PATCH] feat(v1.1.8): F1 drop plaintext realtime_signing_key (migration 0032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.1.7 added at-rest encryption for app_secrets.realtime_signing_key plus a startup task that backfilled encryption over the plaintext column. v1.1.7's CHANGELOG committed v1.1.8 to dropping the plaintext column; this commit follows through. Migration 0032: * Guard query: refuses to apply if any row still has realtime_signing_key IS NOT NULL but realtime_signing_key_encrypted IS NULL. Forces operators who skipped v1.1.7 to apply it first. * ALTER TABLE app_secrets DROP COLUMN IF EXISTS realtime_signing_key. app_secrets_repo: * decode_signing_key now reads encrypted+nonce only; the plaintext fallback is gone. (The schema still allows it via DROP IF EXISTS semantics on replay; once dropped, the column doesn't exist — the SELECT no longer requests it.) * Removed migrate_plaintext_keys (the v1.1.7 startup sweep). * Tests for the falls-back-to-plaintext path are gone with it; the remaining tests cover the encrypted-only happy path, the missing-columns None case, and the wrong-master-key Crypto error. picloud/lib.rs: removed the migrate_plaintext_keys startup call + replaced with a comment explaining the upgrade-path requirement. LOAD-BEARING: v1.1.8 requires v1.1.7 to have been applied first. Operators upgrading directly from v1.1.6 or earlier must apply v1.1.7 (which performs the encryption pass) before applying v1.1.8. This is enforced both by the migration guard and by the CHANGELOG (in a later commit). Brief mentioned dropping a "realtime_signing_key_nonce_LEGACY_IF_EXISTS" column — recon confirmed migration 0025 only added the plaintext column + the encrypted/nonce pair, so no legacy nonce column exists to drop. Documented in HANDBACK §7. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...32_drop_plaintext_realtime_signing_key.sql | 33 +++++ crates/manager-core/src/app_secrets_repo.rs | 119 ++++-------------- crates/picloud/src/lib.rs | 20 +-- 3 files changed, 64 insertions(+), 108 deletions(-) create mode 100644 crates/manager-core/migrations/0032_drop_plaintext_realtime_signing_key.sql 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(),