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:
@@ -0,0 +1,33 @@
|
|||||||
|
-- v1.1.8 F1 — drop the plaintext realtime_signing_key column.
|
||||||
|
--
|
||||||
|
-- v1.1.7 added at-rest encryption (migration 0025) and a startup task
|
||||||
|
-- that backfilled encryption over the plaintext column. Once every
|
||||||
|
-- existing row has an encrypted counterpart, the plaintext column is
|
||||||
|
-- pure dead weight (and a footgun: anyone with DB-read access can
|
||||||
|
-- still read the signing key for any app that lived through the
|
||||||
|
-- migration).
|
||||||
|
--
|
||||||
|
-- The guard refuses to drop if any row still has a plaintext value
|
||||||
|
-- without an encrypted counterpart. This makes the migration safe to
|
||||||
|
-- replay and forces operators who skipped v1.1.7 to apply it first:
|
||||||
|
-- the v1.1.7 startup task is the only thing that knows how to
|
||||||
|
-- encrypt the existing plaintext rows.
|
||||||
|
--
|
||||||
|
-- Operators upgrading from v1.1.6 or earlier MUST apply v1.1.7
|
||||||
|
-- first (and let its startup encryption sweep complete) before
|
||||||
|
-- applying this migration. The CHANGELOG and HANDBACK make this
|
||||||
|
-- mandatory; the guard below is the belt-and-suspenders.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM app_secrets
|
||||||
|
WHERE realtime_signing_key IS NOT NULL
|
||||||
|
AND realtime_signing_key_encrypted IS NULL
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'v1.1.8 migration 0032 refused: unencrypted realtime_signing_key rows still present. Apply v1.1.7 first and ensure its startup encryption sweep has completed.';
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
ALTER TABLE app_secrets DROP COLUMN IF EXISTS realtime_signing_key;
|
||||||
@@ -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
|
//! Holds the HMAC signing key for realtime subscriber tokens. The key is
|
||||||
//! generated lazily (32 random bytes) on the first
|
//! generated lazily (32 random bytes) on the first
|
||||||
@@ -6,18 +7,17 @@
|
|||||||
//! thereafter (no rotation API yet). The key is never exposed to
|
//! thereafter (no rotation API yet). 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.
|
||||||
//!
|
//!
|
||||||
//! **v1.1.7 at-rest encryption (two-phase).** The key is now sealed with
|
//! **v1.1.7 at-rest encryption.** The key is sealed with the process
|
||||||
//! the process master key (AES-256-GCM). New keys are written
|
//! master key (AES-256-GCM). v1.1.8 (this commit) removes the
|
||||||
//! encrypted-only; the startup task [`PostgresAppSecretsRepo::migrate_plaintext_keys`]
|
//! plaintext fallback column — the read path consults only the
|
||||||
//! encrypts any pre-existing plaintext rows. The read path prefers the
|
//! encrypted columns now. The 0032 migration enforces this by
|
||||||
//! encrypted columns and falls back to the plaintext column during the
|
//! refusing to drop the plaintext column unless every existing row
|
||||||
//! compat window (migration 0025 made it NULL-able; v1.1.8 drops it).
|
//! has been encrypted by the v1.1.7 startup sweep first.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{crypto, AppId, MasterKey};
|
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;
|
||||||
@@ -61,61 +61,24 @@ impl PostgresAppSecretsRepo {
|
|||||||
Self { pool, master_key }
|
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(
|
fn decode(
|
||||||
&self,
|
&self,
|
||||||
encrypted: Option<Vec<u8>>,
|
encrypted: Option<Vec<u8>>,
|
||||||
nonce: Option<Vec<u8>>,
|
nonce: Option<Vec<u8>>,
|
||||||
plaintext: Option<Vec<u8>>,
|
|
||||||
) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
|
) -> 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**
|
/// Resolve the signing key from a row's encrypted columns. The
|
||||||
/// when present; otherwise fall back to the plaintext column (compat for
|
/// plaintext fallback that existed in v1.1.7 is gone — v1.1.8 F1
|
||||||
/// un-migrated rows / the post-v1.1.8 dropped-plaintext state).
|
/// 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(
|
fn decode_signing_key(
|
||||||
master_key: &MasterKey,
|
master_key: &MasterKey,
|
||||||
encrypted: Option<Vec<u8>>,
|
encrypted: Option<Vec<u8>>,
|
||||||
nonce: Option<Vec<u8>>,
|
nonce: Option<Vec<u8>>,
|
||||||
plaintext: Option<Vec<u8>>,
|
|
||||||
) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
|
) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
|
||||||
match (encrypted, nonce) {
|
match (encrypted, nonce) {
|
||||||
(Some(ct), Some(n)) => {
|
(Some(ct), Some(n)) => {
|
||||||
@@ -123,7 +86,7 @@ fn decode_signing_key(
|
|||||||
.map_err(|_| AppSecretsRepoError::Crypto)?;
|
.map_err(|_| AppSecretsRepoError::Crypto)?;
|
||||||
Ok(Some(key))
|
Ok(Some(key))
|
||||||
}
|
}
|
||||||
_ => Ok(plaintext),
|
_ => Ok(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,30 +113,27 @@ impl AppSecretsRepo for PostgresAppSecretsRepo {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let row: (Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>) = sqlx::query_as(
|
let row: (Option<Vec<u8>>, Option<Vec<u8>>) = sqlx::query_as(
|
||||||
"SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \
|
"SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce \
|
||||||
realtime_signing_key \
|
|
||||||
FROM app_secrets WHERE app_id = $1",
|
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?;
|
||||||
// A row exists by construction, so a key must decode.
|
// A row exists by construction, so a key must decode.
|
||||||
self.decode(row.0, row.1, row.2)?
|
self.decode(row.0, row.1)?.ok_or(AppSecretsRepoError::Crypto)
|
||||||
.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<(Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>)> = sqlx::query_as(
|
let row: Option<(Option<Vec<u8>>, Option<Vec<u8>>)> = sqlx::query_as(
|
||||||
"SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \
|
"SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce \
|
||||||
realtime_signing_key \
|
|
||||||
FROM app_secrets WHERE app_id = $1",
|
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?;
|
||||||
match row {
|
match row {
|
||||||
Some((e, n, p)) => self.decode(e, n, p),
|
Some((e, n)) => self.decode(e, n),
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,44 +148,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn encrypted_wins_over_plaintext() {
|
fn encrypted_decodes() {
|
||||||
let mk = key();
|
let mk = key();
|
||||||
let secret = vec![1u8, 2, 3, 4];
|
let secret = vec![1u8, 2, 3, 4];
|
||||||
let enc = crypto::encrypt(&secret, mk.as_bytes());
|
let enc = crypto::encrypt(&secret, mk.as_bytes());
|
||||||
// Both present → the encrypted value is returned (not the bogus
|
let got = decode_signing_key(&mk, Some(enc.ciphertext), Some(enc.nonce.to_vec())).unwrap();
|
||||||
// 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));
|
assert_eq!(got, Some(secret));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn falls_back_to_plaintext_when_encrypted_absent() {
|
fn missing_columns_is_none() {
|
||||||
let mk = key();
|
let got = decode_signing_key(&key(), None, None).unwrap();
|
||||||
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);
|
assert_eq!(got, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +167,7 @@ mod tests {
|
|||||||
let secret = vec![3u8; 32];
|
let secret = vec![3u8; 32];
|
||||||
let enc = crypto::encrypt(&secret, key().as_bytes());
|
let enc = crypto::encrypt(&secret, key().as_bytes());
|
||||||
let other = MasterKey::from_bytes([1u8; 32]);
|
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();
|
.unwrap_err();
|
||||||
assert!(matches!(err, AppSecretsRepoError::Crypto));
|
assert!(matches!(err, AppSecretsRepoError::Crypto));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,21 +196,11 @@ pub async fn build_app(
|
|||||||
pool.clone(),
|
pool.clone(),
|
||||||
master_key.clone(),
|
master_key.clone(),
|
||||||
));
|
));
|
||||||
// v1.1.7 two-phase migration: encrypt any plaintext realtime signing
|
// v1.1.7's migrate_plaintext_keys startup sweep was deleted in
|
||||||
// keys at rest. Idempotent — only touches rows not yet encrypted. The
|
// v1.1.8 F1 alongside migration 0032, which drops the plaintext
|
||||||
// plaintext column is dropped in v1.1.8.
|
// column. Operators upgrading from v1.1.6 or earlier MUST apply
|
||||||
match app_secrets_repo.migrate_plaintext_keys().await {
|
// v1.1.7 first; the 0032 migration's guard refuses to run
|
||||||
Ok(0) => {}
|
// otherwise.
|
||||||
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