feat(v1.1.8): F1 drop plaintext realtime_signing_key (migration 0032)

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>
This commit is contained in:
MechaCat02
2026-06-06 14:56:27 +02:00
parent 6449cb6f6a
commit 3c2c4a3767
3 changed files with 64 additions and 108 deletions

View File

@@ -1,4 +1,5 @@
//! `AppSecretsRepo` — per-app secret material (v1.1.6, encrypted v1.1.7).
//! `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
@@ -6,18 +7,17 @@
//! 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 (two-phase).** The key is now sealed with
//! 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).
//! **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;
use uuid::Uuid;
/// Length of a freshly-generated realtime signing key.
pub const SIGNING_KEY_LEN: usize = 32;
@@ -61,61 +61,24 @@ impl PostgresAppSecretsRepo {
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)
decode_signing_key(&self.master_key, encrypted, nonce)
}
}
/// 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).
/// 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>>,
plaintext: Option<Vec<u8>>,
) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
match (encrypted, nonce) {
(Some(ct), Some(n)) => {
@@ -123,7 +86,7 @@ fn decode_signing_key(
.map_err(|_| AppSecretsRepoError::Crypto)?;
Ok(Some(key))
}
_ => Ok(plaintext),
_ => Ok(None),
}
}
@@ -150,30 +113,27 @@ impl AppSecretsRepo for PostgresAppSecretsRepo {
.execute(&self.pool)
.await?;
let row: (Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>) = sqlx::query_as(
"SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \
realtime_signing_key \
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, row.2)?
.ok_or(AppSecretsRepoError::Crypto)
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>>, Option<Vec<u8>>)> = sqlx::query_as(
"SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \
realtime_signing_key \
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, p)) => self.decode(e, n, p),
Some((e, n)) => self.decode(e, n),
None => Ok(None),
}
}
@@ -188,44 +148,17 @@ mod tests {
}
#[test]
fn encrypted_wins_over_plaintext() {
fn encrypted_decodes() {
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();
let got = decode_signing_key(&mk, Some(enc.ciphertext), Some(enc.nonce.to_vec())).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();
fn missing_columns_is_none() {
let got = decode_signing_key(&key(), None, None).unwrap();
assert_eq!(got, None);
}
@@ -234,7 +167,7 @@ mod tests {
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)
let err = decode_signing_key(&other, Some(enc.ciphertext), Some(enc.nonce.to_vec()))
.unwrap_err();
assert!(matches!(err, AppSecretsRepoError::Crypto));
}