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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
@@ -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
|
//! Holds the HMAC signing key for realtime subscriber tokens. The key is
|
||||||
//! tokens. The key is generated lazily (32 random bytes) on the first
|
//! generated lazily (32 random bytes) on the first
|
||||||
//! `pubsub::subscriber_token` call for an app and never changes
|
//! `pubsub::subscriber_token` call for an app and never changes
|
||||||
//! thereafter in v1.1.6 (no rotation API yet — rotation is the
|
//! thereafter (no rotation API yet). The key is never exposed to
|
||||||
//! key-invalidation mechanism, deferred). The key is never exposed to
|
|
||||||
//! scripts: the SDK mints tokens, it never returns the key.
|
//! scripts: the SDK mints tokens, it never returns the key.
|
||||||
//!
|
//!
|
||||||
//! This table is the natural home for v1.1.7's encrypted per-app
|
//! **v1.1.7 at-rest encryption (two-phase).** The key is now sealed with
|
||||||
//! secrets work.
|
//! 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 async_trait::async_trait;
|
||||||
use picloud_shared::AppId;
|
use picloud_shared::{crypto, AppId, MasterKey};
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Length of a freshly-generated realtime signing key.
|
/// Length of a freshly-generated realtime signing key.
|
||||||
pub const SIGNING_KEY_LEN: usize = 32;
|
pub const SIGNING_KEY_LEN: usize = 32;
|
||||||
@@ -22,14 +26,19 @@ pub const SIGNING_KEY_LEN: usize = 32;
|
|||||||
pub enum AppSecretsRepoError {
|
pub enum AppSecretsRepoError {
|
||||||
#[error("database error: {0}")]
|
#[error("database error: {0}")]
|
||||||
Db(#[from] sqlx::Error),
|
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]
|
#[async_trait]
|
||||||
pub trait AppSecretsRepo: Send + Sync {
|
pub trait AppSecretsRepo: Send + Sync {
|
||||||
/// Fetch the app's realtime signing key, generating + persisting one
|
/// Fetch the app's realtime signing key, generating + persisting one
|
||||||
/// (32 random bytes) if absent. Idempotent under concurrency: a
|
/// (32 random bytes, encrypted) if absent. Idempotent under
|
||||||
/// racing creator's `ON CONFLICT DO NOTHING` insert is a no-op and
|
/// concurrency: a racing creator's `ON CONFLICT DO NOTHING` insert is
|
||||||
/// the existing key is returned.
|
/// a no-op and the existing key is returned.
|
||||||
async fn get_or_create_signing_key(
|
async fn get_or_create_signing_key(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
@@ -43,12 +52,78 @@ pub trait AppSecretsRepo: Send + Sync {
|
|||||||
|
|
||||||
pub struct PostgresAppSecretsRepo {
|
pub struct PostgresAppSecretsRepo {
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
|
master_key: MasterKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PostgresAppSecretsRepo {
|
impl PostgresAppSecretsRepo {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(pool: PgPool) -> Self {
|
pub fn new(pool: PgPool, master_key: MasterKey) -> Self {
|
||||||
Self { pool }
|
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<usize, AppSecretsRepoError> {
|
||||||
|
let rows: Vec<(Uuid, Vec<u8>)> = 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<Vec<u8>>,
|
||||||
|
nonce: Option<Vec<u8>>,
|
||||||
|
plaintext: Option<Vec<u8>>,
|
||||||
|
) -> Result<Option<Vec<u8>>, 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<Vec<u8>>,
|
||||||
|
nonce: Option<Vec<u8>>,
|
||||||
|
plaintext: Option<Vec<u8>>,
|
||||||
|
) -> Result<Option<Vec<u8>>, 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<Vec<u8>, AppSecretsRepoError> {
|
) -> Result<Vec<u8>, AppSecretsRepoError> {
|
||||||
let mut fresh = vec![0u8; SIGNING_KEY_LEN];
|
let mut fresh = vec![0u8; SIGNING_KEY_LEN];
|
||||||
rand::thread_rng().fill_bytes(&mut fresh);
|
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
|
// Insert-if-absent (encrypted-only). The racing-creator's insert
|
||||||
// no-op, and the SELECT always returns the winning key.
|
// is a no-op; the SELECT always returns the winning row.
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO app_secrets (app_id, realtime_signing_key) \
|
"INSERT INTO app_secrets \
|
||||||
VALUES ($1, $2) ON CONFLICT (app_id) DO NOTHING",
|
(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(app_id.into_inner())
|
||||||
.bind(&fresh)
|
.bind(&enc.ciphertext)
|
||||||
|
.bind(&enc.nonce[..])
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let key: (Vec<u8>,) =
|
let row: (Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>) = sqlx::query_as(
|
||||||
sqlx::query_as("SELECT realtime_signing_key FROM app_secrets WHERE app_id = $1")
|
"SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \
|
||||||
|
realtime_signing_key \
|
||||||
|
FROM app_secrets WHERE app_id = $1",
|
||||||
|
)
|
||||||
.bind(app_id.into_inner())
|
.bind(app_id.into_inner())
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(key.0)
|
// 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<Option<Vec<u8>>, AppSecretsRepoError> {
|
async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
|
||||||
let row: Option<(Vec<u8>,)> =
|
let row: Option<(Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>)> = sqlx::query_as(
|
||||||
sqlx::query_as("SELECT realtime_signing_key FROM app_secrets WHERE app_id = $1")
|
"SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \
|
||||||
|
realtime_signing_key \
|
||||||
|
FROM app_secrets WHERE app_id = $1",
|
||||||
|
)
|
||||||
.bind(app_id.into_inner())
|
.bind(app_id.into_inner())
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row.map(|r| r.0))
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,7 +189,23 @@ pub async fn build_app(
|
|||||||
let broadcaster_concrete = Arc::new(InProcessBroadcaster::from_env());
|
let broadcaster_concrete = Arc::new(InProcessBroadcaster::from_env());
|
||||||
let broadcaster: Arc<dyn RealtimeBroadcaster> = broadcaster_concrete.clone();
|
let broadcaster: Arc<dyn RealtimeBroadcaster> = broadcaster_concrete.clone();
|
||||||
let topic_repo: Arc<dyn TopicRepo> = Arc::new(PostgresTopicRepo::new(pool.clone()));
|
let topic_repo: Arc<dyn TopicRepo> = 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<dyn RealtimeAuthority> = Arc::new(RealtimeAuthorityImpl::new(
|
let realtime_authority: Arc<dyn RealtimeAuthority> = Arc::new(RealtimeAuthorityImpl::new(
|
||||||
topic_repo.clone(),
|
topic_repo.clone(),
|
||||||
app_secrets_repo.clone(),
|
app_secrets_repo.clone(),
|
||||||
|
|||||||
Reference in New Issue
Block a user