fix(security): encrypt app secrets at rest with AES-256-GCM (#6)
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 8s
CI Pipeline / Clippy Lints (push) Successful in 50s
CI Pipeline / Rust Unit Tests (push) Successful in 1m8s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 8s
CI Pipeline / Clippy Lints (push) Successful in 50s
CI Pipeline / Rust Unit Tests (push) Successful in 1m8s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
Encrypt three sensitive secrets that were stored in plaintext: OIDC client_secret, SMTP smtp_password, TOTP totp_secret. AES-256-GCM via pm-core::crypto helper. New per-install key at /etc/patch-manager/keys/secret-encryption.key, separate from health-check.key for blast-radius isolation. MASKED placeholder behavior in API responses is preserved. 23 files changed, +1248 / -28. Closes #6.
This commit is contained in:
committed by
GitHub
parent
e0a9037be3
commit
b9fb3427e0
193
crates/migrate-secrets/src/main.rs
Normal file
193
crates/migrate-secrets/src/main.rs
Normal file
@ -0,0 +1,193 @@
|
||||
//! One-shot migration helper for issue #6 (Secret Encryption at Rest).
|
||||
//!
|
||||
//! Reads plaintext secrets from the old columns/rows, encrypts them with the
|
||||
//! secret-encryption key, and writes to the new BYTEA columns. Verifies the
|
||||
//! round-trip (encrypt -> decrypt = original plaintext) before committing.
|
||||
//!
|
||||
//! USAGE:
|
||||
//! export DATABASE_URL="postgres://patch_manager:<password>@localhost/patch_manager"
|
||||
//! cargo run -p migrate-secrets
|
||||
//!
|
||||
//! This tool is safe to run multiple times (idempotent — re-encrypts and overwrites).
|
||||
//!
|
||||
//! See `tasks/secret-encryption-spec.md` section 4.5 for the design.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use pm_core::crypto;
|
||||
use sqlx::PgPool;
|
||||
use std::path::Path;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// 1. Load secret-encryption key
|
||||
let key = crypto::load_or_create_key(Path::new(crypto::SECRET_ENCRYPTION_KEY_PATH))
|
||||
.context("Failed to load secret-encryption key")?;
|
||||
eprintln!(
|
||||
"Loaded secret-encryption key from {}",
|
||||
crypto::SECRET_ENCRYPTION_KEY_PATH
|
||||
);
|
||||
|
||||
// 2. Connect to database
|
||||
let database_url =
|
||||
std::env::var("DATABASE_URL").context("DATABASE_URL environment variable not set")?;
|
||||
let pool = PgPool::connect(&database_url)
|
||||
.await
|
||||
.context("Failed to connect to database")?;
|
||||
eprintln!("Connected to database");
|
||||
|
||||
let mut success_count = 0u32;
|
||||
let mut skip_count = 0u32;
|
||||
|
||||
// 3. Migrate OIDC client_secret
|
||||
if let Some(plaintext) = read_oidc_client_secret(&pool).await? {
|
||||
if plaintext.is_empty() {
|
||||
eprintln!("[skip] OIDC client_secret is empty");
|
||||
skip_count += 1;
|
||||
} else {
|
||||
write_oidc_client_secret(&pool, &plaintext, &key).await?;
|
||||
eprintln!("[ok] OIDC client_secret encrypted");
|
||||
success_count += 1;
|
||||
}
|
||||
} else {
|
||||
eprintln!("[skip] OIDC client_secret column not found (already migrated?)");
|
||||
skip_count += 1;
|
||||
}
|
||||
|
||||
// 4. Migrate SMTP password
|
||||
if let Some(plaintext) = read_smtp_password(&pool).await? {
|
||||
if plaintext.is_empty() {
|
||||
eprintln!("[skip] SMTP password is empty");
|
||||
skip_count += 1;
|
||||
} else {
|
||||
write_smtp_password(&pool, &plaintext, &key).await?;
|
||||
eprintln!("[ok] SMTP password encrypted");
|
||||
success_count += 1;
|
||||
}
|
||||
} else {
|
||||
eprintln!("[skip] SMTP password row not found (already migrated?)");
|
||||
skip_count += 1;
|
||||
}
|
||||
|
||||
// 5. Migrate TOTP secrets for all users
|
||||
let totp_count = migrate_totp_secrets(&pool, &key).await?;
|
||||
eprintln!("[ok] {} TOTP secret(s) encrypted", totp_count);
|
||||
success_count += totp_count;
|
||||
|
||||
eprintln!(
|
||||
"\nMigration complete: {} encrypted, {} skipped",
|
||||
success_count, skip_count
|
||||
);
|
||||
eprintln!(
|
||||
"Next step: apply migration 020_encrypt_secrets_at_rest.sql to drop the old columns."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_oidc_client_secret(pool: &PgPool) -> Result<Option<String>> {
|
||||
// Try to read the old column. If it doesn't exist, return None.
|
||||
let row: Result<Option<(Option<String>,)>, sqlx::Error> =
|
||||
sqlx::query_as("SELECT client_secret FROM oidc_config WHERE id = 1")
|
||||
.fetch_optional(pool)
|
||||
.await;
|
||||
match row {
|
||||
Ok(Some((secret,))) => Ok(secret),
|
||||
Ok(None) => Ok(None),
|
||||
Err(e) => {
|
||||
// Column not found = already migrated
|
||||
eprintln!(" (oidc_config.client_secret column check: {})", e);
|
||||
Ok(None)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_oidc_client_secret(pool: &PgPool, plaintext: &str, key: &[u8; 32]) -> Result<()> {
|
||||
let (ciphertext, nonce) = crypto::encrypt(plaintext, key)?;
|
||||
// Round-trip verification
|
||||
let recovered = crypto::decrypt(&ciphertext, &nonce, key)?;
|
||||
if recovered != plaintext {
|
||||
anyhow::bail!(
|
||||
"OIDC round-trip failed: expected {}, got {}",
|
||||
plaintext,
|
||||
recovered
|
||||
);
|
||||
}
|
||||
sqlx::query(
|
||||
"UPDATE oidc_config SET client_secret_encrypted = $1, client_secret_nonce = $2 WHERE id = 1",
|
||||
)
|
||||
.bind(&ciphertext)
|
||||
.bind(&nonce)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("Failed to update oidc_config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_smtp_password(pool: &PgPool) -> Result<Option<String>> {
|
||||
let row: Option<(String,)> =
|
||||
sqlx::query_as("SELECT value FROM system_config WHERE key = 'smtp_password'")
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(v,)| v))
|
||||
}
|
||||
|
||||
async fn write_smtp_password(pool: &PgPool, plaintext: &str, key: &[u8; 32]) -> Result<()> {
|
||||
let (ciphertext, nonce) = crypto::encrypt(plaintext, key)?;
|
||||
// Round-trip verification
|
||||
let recovered = crypto::decrypt(&ciphertext, &nonce, key)?;
|
||||
if recovered != plaintext {
|
||||
anyhow::bail!(
|
||||
"SMTP round-trip failed: expected {}, got {}",
|
||||
plaintext,
|
||||
recovered
|
||||
);
|
||||
}
|
||||
// Delete old row, write two new rows
|
||||
sqlx::query("DELETE FROM system_config WHERE key = 'smtp_password'")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO system_config (key, value) VALUES ($1, $2)")
|
||||
.bind("smtp_password_encrypted")
|
||||
.bind(hex_encode(&ciphertext))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO system_config (key, value) VALUES ($1, $2)")
|
||||
.bind("smtp_password_nonce")
|
||||
.bind(hex_encode(&nonce))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn migrate_totp_secrets(pool: &PgPool, key: &[u8; 32]) -> Result<u32> {
|
||||
// Read all users with totp_secret set
|
||||
let users: Vec<(uuid::Uuid, String)> =
|
||||
sqlx::query_as("SELECT id, totp_secret FROM users WHERE totp_secret IS NOT NULL")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.context("Failed to read users with totp_secret")?;
|
||||
|
||||
let count = users.len() as u32;
|
||||
for (user_id, plaintext) in users {
|
||||
let (ciphertext, nonce) = crypto::encrypt(&plaintext, key)?;
|
||||
// Round-trip verification
|
||||
let recovered = crypto::decrypt(&ciphertext, &nonce, key)?;
|
||||
if recovered != plaintext {
|
||||
anyhow::bail!("TOTP round-trip failed for user {}", user_id);
|
||||
}
|
||||
sqlx::query(
|
||||
"UPDATE users SET totp_secret_encrypted = $1, totp_secret_nonce = $2 WHERE id = $3",
|
||||
)
|
||||
.bind(&ciphertext)
|
||||
.bind(&nonce)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("Failed to update user totp_secret")?;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Hex-encode bytes for storage in TEXT columns (system_config).
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
Reference in New Issue
Block a user