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
@ -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(),
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user