//! `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 //! `pubsub::subscriber_token` call for an app and never changes //! 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.** 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; /// Length of a freshly-generated realtime signing key. pub const SIGNING_KEY_LEN: usize = 32; #[derive(Debug, thiserror::Error)] 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, 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, ) -> Result, AppSecretsRepoError>; /// Fetch the signing key if it exists, WITHOUT creating one. The SSE /// verify path uses this: a missing key means no token was ever /// minted for the app, so any presented token must be rejected. async fn signing_key(&self, app_id: AppId) -> Result>, AppSecretsRepoError>; } pub struct PostgresAppSecretsRepo { pool: PgPool, master_key: MasterKey, } impl PostgresAppSecretsRepo { #[must_use] pub fn new(pool: PgPool, master_key: MasterKey) -> Self { Self { pool, master_key } } fn decode( &self, encrypted: Option>, nonce: Option>, ) -> Result>, AppSecretsRepoError> { decode_signing_key(&self.master_key, encrypted, nonce) } } /// 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>, ) -> 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(None), } } #[async_trait] impl AppSecretsRepo for PostgresAppSecretsRepo { async fn get_or_create_signing_key( &self, app_id: AppId, ) -> 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 (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_encrypted, realtime_signing_key_nonce) \ VALUES ($1, $2, $3) ON CONFLICT (app_id) DO NOTHING", ) .bind(app_id.into_inner()) .bind(&enc.ciphertext) .bind(&enc.nonce[..]) .execute(&self.pool) .await?; 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)? .ok_or(AppSecretsRepoError::Crypto) } async fn signing_key(&self, app_id: AppId) -> Result>, AppSecretsRepoError> { 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)) => self.decode(e, n), None => Ok(None), } } } #[cfg(test)] mod tests { use super::*; fn key() -> MasterKey { MasterKey::from_bytes([9u8; 32]) } #[test] fn encrypted_decodes() { let mk = key(); let secret = vec![1u8, 2, 3, 4]; let enc = crypto::encrypt(&secret, mk.as_bytes()); let got = decode_signing_key(&mk, Some(enc.ciphertext), Some(enc.nonce.to_vec())).unwrap(); assert_eq!(got, Some(secret)); } #[test] fn missing_columns_is_none() { let got = decode_signing_key(&key(), 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())).unwrap_err(); assert!(matches!(err, AppSecretsRepoError::Crypto)); } }