8 files needed re-wrapping after the F2 clippy-fix pass landed un-fmt'd. Pure cosmetic — no behavioral change. Re-runs of `cargo fmt --all -- --check` now exit clean. Reviewer-supplied per REVIEW.md §7; folded onto the branch to avoid a NEEDS-CHANGES round-trip on a purely mechanical fix.
176 lines
6.1 KiB
Rust
176 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));
|
|
}
|
|
}
|