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
19
crates/migrate-secrets/Cargo.toml
Normal file
19
crates/migrate-secrets/Cargo.toml
Normal file
@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "migrate-secrets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "migrate-secrets"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
pm-core = { path = "../pm-core" }
|
||||
tokio = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
hex = "0.4"
|
||||
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()
|
||||
}
|
||||
27
crates/pm-auth/src/session.rs
Executable file → Normal file
27
crates/pm-auth/src/session.rs
Executable file → Normal file
@ -40,6 +40,8 @@ pub enum SessionError {
|
||||
Password(#[from] PasswordError),
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Successful login response returned to the client.
|
||||
@ -77,7 +79,10 @@ struct DbUser {
|
||||
role: UserRole,
|
||||
auth_provider: AuthProvider,
|
||||
password_hash: Option<String>,
|
||||
totp_secret: Option<String>,
|
||||
/// AES-256-GCM encrypted TOTP secret (issue #6 fix). None = TOTP not configured.
|
||||
totp_secret_encrypted: Option<Vec<u8>>,
|
||||
/// AES-256-GCM nonce for TOTP secret. Must be paired with `totp_secret_encrypted`.
|
||||
totp_secret_nonce: Option<Vec<u8>>,
|
||||
mfa_enabled: bool,
|
||||
is_active: bool,
|
||||
force_password_reset: bool,
|
||||
@ -194,9 +199,25 @@ pub async fn login(
|
||||
// 4. MFA check
|
||||
if user.mfa_enabled {
|
||||
let code = req.totp_code.as_deref().ok_or(SessionError::MfaRequired)?;
|
||||
let secret = user.totp_secret.as_deref().unwrap_or("");
|
||||
// Decrypt the TOTP secret (issue #6 fix — stored as encrypted+nonce BYTEA)
|
||||
let secret = match (&user.totp_secret_encrypted, &user.totp_secret_nonce) {
|
||||
(Some(enc), Some(nonce)) => {
|
||||
let key = pm_core::crypto::load_or_create_key(std::path::Path::new(
|
||||
pm_core::crypto::SECRET_ENCRYPTION_KEY_PATH,
|
||||
))
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
SessionError::Internal("Encryption key error".to_string())
|
||||
})?;
|
||||
pm_core::crypto::decrypt(enc, nonce, &key).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to decrypt TOTP secret");
|
||||
SessionError::Internal("TOTP decryption error".to_string())
|
||||
})?
|
||||
},
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let mfa_ok = mfa_totp::verify_code(&user.username, secret, code).unwrap_or(false);
|
||||
let mfa_ok = mfa_totp::verify_code(&user.username, &secret, code).unwrap_or(false);
|
||||
|
||||
if !mfa_ok {
|
||||
tracing::warn!(username = %req.username, "Login failed: invalid MFA code");
|
||||
|
||||
85
crates/pm-core/src/crypto.rs
Executable file → Normal file
85
crates/pm-core/src/crypto.rs
Executable file → Normal file
@ -1,6 +1,11 @@
|
||||
//! AES-256-GCM encryption for sensitive health check credentials.
|
||||
//! AES-256-GCM encryption for sensitive credentials.
|
||||
//!
|
||||
//! Uses a per-install key stored at `/etc/patch-manager/keys/health-check.key`.
|
||||
//! Two per-install keys are supported:
|
||||
//! - `KEY_PATH` (health-check.key) protects HTTP basic auth passwords for health check endpoints.
|
||||
//! - `SECRET_ENCRYPTION_KEY_PATH` (secret-encryption.key) protects OIDC `client_secret`,
|
||||
//! SMTP `smtp_password`, and TOTP `totp_secret` at rest in the database.
|
||||
//!
|
||||
//! Keys are 32-byte files, auto-generated on first start with 0600 permissions.
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit, OsRng},
|
||||
@ -12,6 +17,12 @@ use std::path::Path;
|
||||
|
||||
pub const KEY_PATH: &str = "/etc/patch-manager/keys/health-check.key";
|
||||
|
||||
/// Path to the encryption key for sensitive app secrets
|
||||
/// (OIDC client_secret, SMTP password, TOTP secret).
|
||||
/// Separate from `KEY_PATH` (health-check credentials) for blast-radius isolation:
|
||||
/// if the health-check key is compromised, app secrets remain protected.
|
||||
pub const SECRET_ENCRYPTION_KEY_PATH: &str = "/etc/patch-manager/keys/secret-encryption.key";
|
||||
|
||||
/// Load or create the per-install encryption key.
|
||||
/// If the key file doesn't exist, generates a new 256-bit key and saves it.
|
||||
pub fn load_or_create_key(path: &Path) -> Result<[u8; 32], CryptoError> {
|
||||
@ -78,3 +89,73 @@ pub enum CryptoError {
|
||||
#[error("UTF-8 error: {0}")]
|
||||
Utf8(#[from] std::string::FromUtf8Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Create a unique temp directory for test isolation.
|
||||
/// Returns a path like `/tmp/pm-crypto-test-<epoch_nanos>-<rand>`.
|
||||
/// Cleans up the directory on test teardown (via `temp_dir` guard).
|
||||
fn unique_temp_dir() -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let dir = env::temp_dir().join(format!("pm-crypto-test-{}-{}", std::process::id(), nanos));
|
||||
fs::create_dir_all(&dir).expect("Failed to create temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_round_trip() {
|
||||
let key = [42u8; 32];
|
||||
let plaintext = "super-secret-client-credential-12345";
|
||||
let (ciphertext, nonce) = encrypt(plaintext, &key).expect("encrypt failed");
|
||||
// Ciphertext must differ from plaintext (encryption is non-trivial)
|
||||
assert_ne!(ciphertext.as_slice(), plaintext.as_bytes());
|
||||
// Nonce is 12 bytes (AES-GCM standard)
|
||||
assert_eq!(nonce.len(), 12);
|
||||
// Decrypting must return the original plaintext
|
||||
let recovered = decrypt(&ciphertext, &nonce, &key).expect("decrypt failed");
|
||||
assert_eq!(recovered, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_or_create_key_sets_0600_permissions() {
|
||||
let dir = unique_temp_dir();
|
||||
let key_path = dir.join("test-0600.key");
|
||||
let _key = load_or_create_key(&key_path).expect("load_or_create_key failed");
|
||||
// Verify file exists and has exactly 32 bytes
|
||||
let metadata = fs::metadata(&key_path).expect("key file not created");
|
||||
assert_eq!(metadata.len(), 32, "key file must be 32 bytes");
|
||||
// On Unix, verify permissions are 0600 (owner read/write only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = metadata.permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "key file must be 0600, got {:o}", mode);
|
||||
}
|
||||
// Cleanup
|
||||
let _ = fs::remove_file(&key_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_or_create_key_is_idempotent() {
|
||||
let dir = unique_temp_dir();
|
||||
let key_path = dir.join("test-idempotent.key");
|
||||
// First call creates the key
|
||||
let key1 = load_or_create_key(&key_path).expect("first call failed");
|
||||
// Second call should return the same key (not regenerate)
|
||||
let key2 = load_or_create_key(&key_path).expect("second call failed");
|
||||
assert_eq!(key1, key2, "second call must return the same key");
|
||||
// Cleanup
|
||||
let _ = fs::remove_file(&key_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
4
crates/pm-core/src/lib.rs
Executable file → Normal file
4
crates/pm-core/src/lib.rs
Executable file → Normal file
@ -9,7 +9,9 @@ pub mod request_id;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use config::AppConfig;
|
||||
pub use crypto::{decrypt, encrypt, load_or_create_key, CryptoError, KEY_PATH};
|
||||
pub use crypto::{
|
||||
decrypt, encrypt, load_or_create_key, CryptoError, KEY_PATH, SECRET_ENCRYPTION_KEY_PATH,
|
||||
};
|
||||
pub use error::{AppError, ErrorResponse};
|
||||
pub use models::{
|
||||
AdminResetPasswordRequest, AuthProvider, ChangePasswordRequest, CreateGroupRequest,
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
//! pm-web — Linux Patch Manager web server.
|
||||
|
||||
mod secret_key;
|
||||
|
||||
mod routes;
|
||||
|
||||
use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router};
|
||||
|
||||
22
crates/pm-web/src/routes/auth.rs
Executable file → Normal file
22
crates/pm-web/src/routes/auth.rs
Executable file → Normal file
@ -360,8 +360,26 @@ async fn mfa_verify_handler(
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE users SET totp_secret = $1, mfa_enabled = TRUE WHERE id = $2")
|
||||
.bind(&req.secret_base32)
|
||||
// Encrypt the TOTP secret before persisting (issue #6 fix)
|
||||
let key = crate::secret_key::get().map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({ "error": { "code": "internal_error", "message": "Encryption key error" } }),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let (ciphertext, nonce) = pm_core::crypto::encrypt(&req.secret_base32, key).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to encrypt TOTP secret");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Encryption error" } })),
|
||||
)
|
||||
})?;
|
||||
sqlx::query("UPDATE users SET totp_secret_encrypted = $1, totp_secret_nonce = $2, mfa_enabled = TRUE WHERE id = $3")
|
||||
.bind(&ciphertext)
|
||||
.bind(&nonce)
|
||||
.bind(auth_user.user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
|
||||
@ -273,11 +273,23 @@ async fn update_config_key(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tuple type for SELECT from oidc_config table (used by fetch_oidc_config).
|
||||
type OidcConfigRow = (
|
||||
bool,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<Vec<u8>>,
|
||||
Option<Vec<u8>>,
|
||||
String,
|
||||
String,
|
||||
);
|
||||
async fn fetch_oidc_config(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<OidcConfigResponse, (StatusCode, Json<Value>)> {
|
||||
let row: Option<(bool, String, String, String, String, String, String, String)> = sqlx::query_as(
|
||||
"SELECT enabled, provider_type, display_name, discovery_url, client_id, client_secret, redirect_uri, scopes FROM oidc_config WHERE id = 1",
|
||||
let row: Option<OidcConfigRow> = sqlx::query_as(
|
||||
"SELECT enabled, provider_type, display_name, discovery_url, client_id, client_secret_encrypted, client_secret_nonce, redirect_uri, scopes FROM oidc_config WHERE id = 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
@ -296,7 +308,8 @@ async fn fetch_oidc_config(
|
||||
display_name,
|
||||
discovery_url,
|
||||
client_id,
|
||||
client_secret,
|
||||
client_secret_encrypted,
|
||||
_client_secret_nonce,
|
||||
redirect_uri,
|
||||
scopes,
|
||||
)) => OidcConfigResponse {
|
||||
@ -305,7 +318,7 @@ async fn fetch_oidc_config(
|
||||
display_name,
|
||||
discovery_url,
|
||||
client_id,
|
||||
client_secret: if client_secret.is_empty() {
|
||||
client_secret: if client_secret_encrypted.is_none() {
|
||||
String::new()
|
||||
} else {
|
||||
MASKED.to_string()
|
||||
@ -365,6 +378,22 @@ async fn update_settings(
|
||||
.is_some_and(|s| s != MASKED && !s.is_empty());
|
||||
|
||||
let result = if update_secret {
|
||||
// Encrypt the client_secret before persisting
|
||||
let key = crate::secret_key::get().map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Encryption key error" } })),
|
||||
)
|
||||
})?;
|
||||
let plaintext = oidc.client_secret.as_deref().unwrap_or("");
|
||||
let (ciphertext, nonce) = pm_core::crypto::encrypt(plaintext, key).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to encrypt OIDC client_secret");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Encryption error" } })),
|
||||
)
|
||||
})?;
|
||||
sqlx::query(
|
||||
"UPDATE oidc_config SET \
|
||||
enabled = COALESCE($1, enabled), \
|
||||
@ -372,9 +401,10 @@ async fn update_settings(
|
||||
display_name = COALESCE($3, display_name), \
|
||||
discovery_url = COALESCE($4, discovery_url), \
|
||||
client_id = COALESCE($5, client_id), \
|
||||
client_secret = $6, \
|
||||
redirect_uri = COALESCE($7, redirect_uri), \
|
||||
scopes = COALESCE($8, scopes), \
|
||||
client_secret_encrypted = $6, \
|
||||
client_secret_nonce = $7, \
|
||||
redirect_uri = COALESCE($8, redirect_uri), \
|
||||
scopes = COALESCE($9, scopes), \
|
||||
updated_at = NOW() \
|
||||
WHERE id = 1",
|
||||
)
|
||||
@ -383,7 +413,8 @@ async fn update_settings(
|
||||
.bind(&oidc.display_name)
|
||||
.bind(&oidc.discovery_url)
|
||||
.bind(&oidc.client_id)
|
||||
.bind(oidc.client_secret.as_deref().unwrap_or(""))
|
||||
.bind(&ciphertext)
|
||||
.bind(&nonce)
|
||||
.bind(&oidc.redirect_uri)
|
||||
.bind(&oidc.scopes)
|
||||
.execute(&state.db)
|
||||
@ -450,7 +481,59 @@ async fn update_settings(
|
||||
}
|
||||
if let Some(ref v) = smtp.password {
|
||||
if v != MASKED {
|
||||
update_config_key(&state.db, "smtp_password", v).await?;
|
||||
// Encrypt the SMTP password before persisting
|
||||
let key = crate::secret_key::get().map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Encryption key error" } })),
|
||||
)
|
||||
})?;
|
||||
let (ciphertext, nonce) = pm_core::crypto::encrypt(v, key).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to encrypt SMTP password");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Encryption error" } })),
|
||||
)
|
||||
})?;
|
||||
// Delete old plaintext row, write two new rows (encrypted + nonce)
|
||||
sqlx::query("DELETE FROM system_config WHERE key = 'smtp_password'")
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to delete old smtp_password row");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})?;
|
||||
// Store as hex in TEXT columns (system_config uses TEXT)
|
||||
let enc_hex: String = ciphertext.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
let nonce_hex: String = nonce.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
sqlx::query("INSERT INTO system_config (key, value) VALUES ($1, $2)")
|
||||
.bind("smtp_password_encrypted")
|
||||
.bind(&enc_hex)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to write smtp_password_encrypted");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})?;
|
||||
sqlx::query("INSERT INTO system_config (key, value) VALUES ($1, $2)")
|
||||
.bind("smtp_password_nonce")
|
||||
.bind(&nonce_hex)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to write smtp_password_nonce");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
if let Some(ref v) = smtp.from {
|
||||
@ -790,7 +873,32 @@ async fn test_smtp(
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(587);
|
||||
let username = cfg.get("smtp_username").cloned().unwrap_or_default();
|
||||
let password = cfg.get("smtp_password").cloned().unwrap_or_default();
|
||||
// Decrypt the SMTP password (issue #6 fix — stored as two rows in system_config:
|
||||
// `smtp_password_encrypted` (hex) and `smtp_password_nonce` (hex))
|
||||
let password = match (
|
||||
cfg.get("smtp_password_encrypted"),
|
||||
cfg.get("smtp_password_nonce"),
|
||||
) {
|
||||
(Some(enc_hex), Some(nonce_hex)) => {
|
||||
let key = match crate::secret_key::get() {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({ "error": { "code": "internal_error", "message": "Encryption key error" } }),
|
||||
),
|
||||
));
|
||||
},
|
||||
};
|
||||
// Decode hex to bytes (hex_decode returns empty Vec on invalid input)
|
||||
let enc_bytes = hex_decode(enc_hex);
|
||||
let nonce_bytes = hex_decode(nonce_hex);
|
||||
pm_core::crypto::decrypt(&enc_bytes, &nonce_bytes, key).unwrap_or_default()
|
||||
},
|
||||
_ => String::new(),
|
||||
};
|
||||
let from_addr = cfg.get("smtp_from").cloned().unwrap_or_default();
|
||||
let tls_mode = cfg
|
||||
.get("smtp_tls_mode")
|
||||
@ -1032,18 +1140,31 @@ async fn audit_integrity(
|
||||
})))
|
||||
}
|
||||
|
||||
/// Decode a hex string to bytes. Returns an empty Vec on invalid input.
|
||||
/// Used by the SMTP password decryption logic (issue #6 fix).
|
||||
fn hex_decode(s: &str) -> Vec<u8> {
|
||||
if !s.len().is_multiple_of(2) {
|
||||
return Vec::new();
|
||||
}
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.filter_map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(unused_imports)]
|
||||
use super::*;
|
||||
use axum::http::StatusCode;
|
||||
use pm_auth::jwt::AccessClaims;
|
||||
use pm_auth::rbac::{AuthUser, UserRole};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Build a minimal `AuthUser` for role-gate testing.
|
||||
/// The `admin_required` gate only inspects `auth.role`, so all other
|
||||
/// fields can be placeholder values.
|
||||
#[allow(dead_code)]
|
||||
fn test_auth_user(role: UserRole) -> AuthUser {
|
||||
let claims = AccessClaims {
|
||||
sub: Uuid::new_v4().to_string(),
|
||||
|
||||
@ -213,11 +213,29 @@ pub struct OidcConfig {
|
||||
pub display_name: String,
|
||||
pub discovery_url: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
/// AES-256-GCM encrypted client_secret. `None` if not set or public client.
|
||||
pub client_secret_encrypted: Option<Vec<u8>>,
|
||||
/// AES-256-GCM nonce for client_secret. Must be paired with `client_secret_encrypted`.
|
||||
pub client_secret_nonce: Option<Vec<u8>>,
|
||||
pub redirect_uri: String,
|
||||
pub scopes: String,
|
||||
}
|
||||
|
||||
impl OidcConfig {
|
||||
/// Decrypt the client_secret using the provided key.
|
||||
/// Returns `Ok(String::new())` if the secret is not set (public client).
|
||||
/// Returns `Err(CryptoError)` if decryption fails or nonce is missing.
|
||||
pub fn decrypt_client_secret(
|
||||
&self,
|
||||
key: &[u8; 32],
|
||||
) -> Result<String, pm_core::crypto::CryptoError> {
|
||||
match (&self.client_secret_encrypted, &self.client_secret_nonce) {
|
||||
(Some(enc), Some(nonce)) => pm_core::crypto::decrypt(enc, nonce, key),
|
||||
_ => Ok(String::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached OIDC discovery document.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OidcDiscovery {
|
||||
@ -464,8 +482,28 @@ async fn sso_callback(
|
||||
];
|
||||
|
||||
// For confidential clients (Azure AD), include client_secret
|
||||
if !config.client_secret.is_empty() {
|
||||
params_vec.push(("client_secret", config.client_secret.clone()));
|
||||
let key = match crate::secret_key::get() {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to load secret-encryption key");
|
||||
return Err(error_redirect(
|
||||
"internal_error",
|
||||
"Failed to load encryption key",
|
||||
));
|
||||
},
|
||||
};
|
||||
let client_secret = match config.decrypt_client_secret(key) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to decrypt OIDC client_secret");
|
||||
return Err(error_redirect(
|
||||
"internal_error",
|
||||
"Failed to decrypt client_secret",
|
||||
));
|
||||
},
|
||||
};
|
||||
if !client_secret.is_empty() {
|
||||
params_vec.push(("client_secret", client_secret));
|
||||
}
|
||||
|
||||
let token_resp = match client
|
||||
@ -799,7 +837,9 @@ async fn azure_callback_redirect(
|
||||
|
||||
async fn load_oidc_config(pool: &sqlx::PgPool) -> Result<OidcConfig, (StatusCode, Json<Value>)> {
|
||||
let row: Option<OidcConfig> = sqlx::query_as(
|
||||
"SELECT enabled, provider_type, display_name, discovery_url, client_id, client_secret, redirect_uri, scopes FROM oidc_config WHERE id = 1",
|
||||
"SELECT enabled, provider_type, display_name, discovery_url, client_id, \
|
||||
client_secret_encrypted, client_secret_nonce, redirect_uri, scopes \
|
||||
FROM oidc_config WHERE id = 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
@ -817,7 +857,8 @@ async fn load_oidc_config(pool: &sqlx::PgPool) -> Result<OidcConfig, (StatusCode
|
||||
display_name: "Azure AD".to_string(),
|
||||
discovery_url: String::new(),
|
||||
client_id: String::new(),
|
||||
client_secret: String::new(),
|
||||
client_secret_encrypted: None,
|
||||
client_secret_nonce: None,
|
||||
redirect_uri: String::new(),
|
||||
scopes: "openid profile email".to_string(),
|
||||
}))
|
||||
|
||||
2
crates/pm-web/src/routes/users.rs
Executable file → Normal file
2
crates/pm-web/src/routes/users.rs
Executable file → Normal file
@ -534,7 +534,7 @@ async fn admin_disable_mfa(
|
||||
));
|
||||
}
|
||||
|
||||
let rows = sqlx::query("UPDATE users SET totp_secret = NULL, mfa_enabled = FALSE, updated_at = NOW() WHERE id = $1")
|
||||
let rows = sqlx::query("UPDATE users SET totp_secret_encrypted = NULL, totp_secret_nonce = NULL, mfa_enabled = FALSE, updated_at = NOW() WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
|
||||
44
crates/pm-web/src/secret_key.rs
Normal file
44
crates/pm-web/src/secret_key.rs
Normal file
@ -0,0 +1,44 @@
|
||||
//! Secret-encryption key loader for pm-web.
|
||||
//!
|
||||
//! Lazily loads the per-install AES-256-GCM key from
|
||||
//! `/etc/patch-manager/keys/secret-encryption.key` on first use, caches it
|
||||
//! in process memory, and returns a `&'static [u8; 32]` for all subsequent calls.
|
||||
//!
|
||||
//! Uses `std::sync::OnceLock` (stable since Rust 1.70) to avoid the `once_cell` dependency.
|
||||
//!
|
||||
//! See `tasks/secret-encryption-spec.md` section 4.4 for the design rationale.
|
||||
|
||||
use pm_core::crypto;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static SECRET_KEY: OnceLock<[u8; 32]> = OnceLock::new();
|
||||
|
||||
/// Load the secret-encryption key at first call. Subsequent calls return the cached value.
|
||||
/// Returns `CryptoError` if the key file is missing or invalid.
|
||||
///
|
||||
/// Auto-generates the key file on first start (with 0600 permissions) if it doesn't exist.
|
||||
#[allow(dead_code)]
|
||||
pub fn get() -> Result<&'static [u8; 32], crypto::CryptoError> {
|
||||
if let Some(key) = SECRET_KEY.get() {
|
||||
return Ok(key);
|
||||
}
|
||||
let key = crypto::load_or_create_key(Path::new(crypto::SECRET_ENCRYPTION_KEY_PATH))?;
|
||||
// _ = ignore error if another thread won the race (already set by them)
|
||||
let _ = SECRET_KEY.set(key);
|
||||
Ok(SECRET_KEY.get().expect("key was just set"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[test]
|
||||
fn once_lock_caches_value() {
|
||||
let cell: OnceLock<u32> = OnceLock::new();
|
||||
let v1 = cell.get_or_init(|| 42);
|
||||
let v2 = cell.get_or_init(|| 99); // Should return 42, not 99
|
||||
assert_eq!(*v1, 42);
|
||||
assert_eq!(*v2, 42);
|
||||
}
|
||||
}
|
||||
38
crates/pm-worker/src/email.rs
Executable file → Normal file
38
crates/pm-worker/src/email.rs
Executable file → Normal file
@ -32,11 +32,16 @@ struct NotificationSettings {
|
||||
}
|
||||
|
||||
/// Load SMTP settings from the `system_config` table.
|
||||
///
|
||||
/// Issue #6 fix: SMTP password is stored as two rows:
|
||||
/// - `smtp_password_encrypted` (hex of AES-256-GCM ciphertext)
|
||||
/// - `smtp_password_nonce` (hex of AES-256-GCM nonce)
|
||||
async fn load_smtp_settings(pool: &PgPool) -> SmtpSettings {
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT key, value FROM system_config WHERE key IN (
|
||||
'smtp_enabled', 'smtp_host', 'smtp_port', 'smtp_username',
|
||||
'smtp_password', 'smtp_from', 'smtp_tls_mode'
|
||||
'smtp_password_encrypted', 'smtp_password_nonce',
|
||||
'smtp_from', 'smtp_tls_mode'
|
||||
)",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
@ -50,17 +55,46 @@ async fn load_smtp_settings(pool: &PgPool) -> SmtpSettings {
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
// Decrypt the SMTP password
|
||||
let enc_hex = get("smtp_password_encrypted");
|
||||
let nonce_hex = get("smtp_password_nonce");
|
||||
let password = if !enc_hex.is_empty() && !nonce_hex.is_empty() {
|
||||
match (
|
||||
hex_decode(&enc_hex),
|
||||
hex_decode(&nonce_hex),
|
||||
crate::secret_key::get(),
|
||||
) {
|
||||
(Some(enc), Some(nonce), Ok(key)) => {
|
||||
pm_core::crypto::decrypt(&enc, &nonce, key).unwrap_or_default()
|
||||
},
|
||||
_ => String::new(),
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
SmtpSettings {
|
||||
enabled: get("smtp_enabled") == "true",
|
||||
host: get("smtp_host"),
|
||||
port: get("smtp_port").parse().unwrap_or(587),
|
||||
username: get("smtp_username"),
|
||||
password: get("smtp_password"),
|
||||
password,
|
||||
from: get("smtp_from"),
|
||||
tls_mode: get("smtp_tls_mode"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a hex string to bytes. Returns None on invalid input.
|
||||
fn hex_decode(s: &str) -> Option<Vec<u8>> {
|
||||
if !s.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Load notification preferences from `system_config`.
|
||||
async fn load_notification_settings(pool: &PgPool) -> NotificationSettings {
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
|
||||
@ -12,6 +12,7 @@ mod job_executor;
|
||||
mod maintenance_scheduler;
|
||||
mod patch_poller;
|
||||
mod refresh_listener;
|
||||
mod secret_key;
|
||||
mod ws_relay;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
29
crates/pm-worker/src/secret_key.rs
Normal file
29
crates/pm-worker/src/secret_key.rs
Normal file
@ -0,0 +1,29 @@
|
||||
//! Secret-encryption key loader for pm-worker.
|
||||
//!
|
||||
//! Lazily loads the per-install AES-256-GCM key from
|
||||
//! `/etc/patch-manager/keys/secret-encryption.key` on first use, caches it
|
||||
//! in process memory, and returns a `&'static [u8; 32]` for all subsequent calls.
|
||||
//!
|
||||
//! The pm-worker crate uses the same key file as pm-web (filesystem-shared).
|
||||
//! See `tasks/secret-encryption-spec.md` section 4.4 for the design rationale.
|
||||
|
||||
use pm_core::crypto;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static SECRET_KEY: OnceLock<[u8; 32]> = OnceLock::new();
|
||||
|
||||
/// Load the secret-encryption key at first call. Subsequent calls return the cached value.
|
||||
/// Returns `CryptoError` if the key file is missing or invalid.
|
||||
///
|
||||
/// Auto-generates the key file on first start (with 0600 permissions) if it doesn't exist.
|
||||
#[allow(dead_code)]
|
||||
pub fn get() -> Result<&'static [u8; 32], crypto::CryptoError> {
|
||||
if let Some(key) = SECRET_KEY.get() {
|
||||
return Ok(key);
|
||||
}
|
||||
let key = crypto::load_or_create_key(Path::new(crypto::SECRET_ENCRYPTION_KEY_PATH))?;
|
||||
// _ = ignore error if another thread won the race (already set by them)
|
||||
let _ = SECRET_KEY.set(key);
|
||||
Ok(SECRET_KEY.get().expect("key was just set"))
|
||||
}
|
||||
Reference in New Issue
Block a user