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) <noreply@anthropic.com>
175 lines
6.1 KiB
Rust
175 lines
6.1 KiB
Rust
//! `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<Vec<u8>, 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<Option<Vec<u8>>, 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<Vec<u8>>,
|
|
nonce: Option<Vec<u8>>,
|
|
) -> Result<Option<Vec<u8>>, 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<Vec<u8>>,
|
|
nonce: 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(None),
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AppSecretsRepo for PostgresAppSecretsRepo {
|
|
async fn get_or_create_signing_key(
|
|
&self,
|
|
app_id: AppId,
|
|
) -> Result<Vec<u8>, 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<Vec<u8>>, Option<Vec<u8>>) = 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<Option<Vec<u8>>, AppSecretsRepoError> {
|
|
let row: Option<(Option<Vec<u8>>, Option<Vec<u8>>)> = 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));
|
|
}
|
|
}
|