Private
Public Access
1
0
Files
linux_patch_manager/crates/migrate-secrets/src/main.rs
Draco-Lunaris-Echo b9fb3427e0
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
fix(security): encrypt app secrets at rest with AES-256-GCM (#6)
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.
2026-06-03 15:08:25 -05:00

194 lines
6.6 KiB
Rust

//! 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()
}