//! `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, app_id: AppId, encrypted: Option>, nonce: Option>, version: i16, ) -> Result>, AppSecretsRepoError> { decode_signing_key(&self.master_key, app_id, encrypted, nonce, version) } } /// Audit 2026-06-11 H-D1 — AAD binding the realtime signing key to its /// owning app, so a Postgres-write attacker can't swap one app's /// ciphertext under another app's row. fn signing_key_aad(app_id: AppId) -> Vec { format!("app_secret:{app_id}:realtime_signing_key").into_bytes() } /// Current AAD-bound envelope version for the realtime signing key. const SIGNING_KEY_ENVELOPE_V1: i16 = 1; /// 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). /// /// Audit 2026-06-11 H-D1: dispatches on `version`. v0 = legacy no-AAD /// (pre-audit rows); v1 = AAD bound to `app_secret:{app_id}:realtime_signing_key`. fn decode_signing_key( master_key: &MasterKey, app_id: AppId, encrypted: Option>, nonce: Option>, version: i16, ) -> Result>, AppSecretsRepoError> { match (encrypted, nonce) { (Some(ct), Some(n)) => { let key = match version { 0 => crypto::decrypt(&ct, &n, master_key.as_bytes()), SIGNING_KEY_ENVELOPE_V1 => { let aad = signing_key_aad(app_id); crypto::decrypt_with_aad(&ct, &n, &aad, master_key.as_bytes()) } _ => return Err(AppSecretsRepoError::Crypto), } .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); // Audit 2026-06-11 H-D1 — new keys are AAD-bound (v1). let aad = signing_key_aad(app_id); let enc = crypto::encrypt_with_aad(&fresh, &aad, 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, \ realtime_signing_key_version) \ VALUES ($1, $2, $3, $4) ON CONFLICT (app_id) DO NOTHING", ) .bind(app_id.into_inner()) .bind(&enc.ciphertext) .bind(&enc.nonce[..]) .bind(SIGNING_KEY_ENVELOPE_V1) .execute(&self.pool) .await?; let row: (Option>, Option>, i16) = sqlx::query_as( "SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \ realtime_signing_key_version \ 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(app_id, row.0, row.1, row.2)? .ok_or(AppSecretsRepoError::Crypto) } async fn signing_key(&self, app_id: AppId) -> Result>, AppSecretsRepoError> { let row: Option<(Option>, Option>, i16)> = sqlx::query_as( "SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \ realtime_signing_key_version \ FROM app_secrets WHERE app_id = $1", ) .bind(app_id.into_inner()) .fetch_optional(&self.pool) .await?; match row { Some((e, n, v)) => self.decode(app_id, e, n, v), None => Ok(None), } } } #[cfg(test)] mod tests { use super::*; fn key() -> MasterKey { MasterKey::from_bytes([9u8; 32]) } fn app() -> AppId { AppId::new() } #[test] fn legacy_v0_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, app(), Some(enc.ciphertext), Some(enc.nonce.to_vec()), 0, ) .unwrap(); assert_eq!(got, Some(secret)); } #[test] fn v1_aad_round_trips() { let mk = key(); let a = app(); let secret = vec![7u8; 32]; let aad = signing_key_aad(a); let enc = crypto::encrypt_with_aad(&secret, &aad, mk.as_bytes()); let got = decode_signing_key( &mk, a, Some(enc.ciphertext), Some(enc.nonce.to_vec()), SIGNING_KEY_ENVELOPE_V1, ) .unwrap(); assert_eq!(got, Some(secret)); } #[test] fn v1_decode_under_wrong_app_fails() { // Audit 2026-06-11 H-D1 — a row swapped to a different app_id // can't be decrypted: the AAD no longer matches. let mk = key(); let a = app(); let b = app(); let secret = vec![7u8; 32]; let aad = signing_key_aad(a); let enc = crypto::encrypt_with_aad(&secret, &aad, mk.as_bytes()); let err = decode_signing_key( &mk, b, Some(enc.ciphertext), Some(enc.nonce.to_vec()), SIGNING_KEY_ENVELOPE_V1, ) .unwrap_err(); assert!(matches!(err, AppSecretsRepoError::Crypto)); } #[test] fn missing_columns_is_none() { let got = decode_signing_key(&key(), app(), None, None, 0).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, app(), Some(enc.ciphertext), Some(enc.nonce.to_vec()), 0, ) .unwrap_err(); assert!(matches!(err, AppSecretsRepoError::Crypto)); } }