From 91459c16cc627fb793ebe619c49e9a357d0e3a27 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Thu, 16 Apr 2026 17:11:03 +0200 Subject: [PATCH] fix(security): durcir les secrets locaux et valider les credentials closes #4 --- src-tauri/src/commands/credential.rs | 4 + src-tauri/src/lib.rs | 156 ++++++++++++++++++++++++--- src-tauri/src/models/credential.rs | 77 +++++++++++++ 3 files changed, 225 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/commands/credential.rs b/src-tauri/src/commands/credential.rs index 0213eae..16a9bf8 100644 --- a/src-tauri/src/commands/credential.rs +++ b/src-tauri/src/commands/credential.rs @@ -12,6 +12,10 @@ pub fn set_tuleap_credentials( username: String, password: String, ) -> Result { + let (tuleap_url, username, password) = + TuleapCredentials::validate_input(&tuleap_url, &username, &password) + .map_err(AppError::from)?; + let password_encrypted = crypto::encrypt(&state.encryption_key, &password).map_err(AppError::from)?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f55c340..03fc005 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -113,18 +113,150 @@ pub fn run() { fn load_or_generate_key(path: &std::path::Path) -> Result<[u8; 32], Box> { use rand::RngCore; + if path.exists() { - let bytes = std::fs::read(path)?; - if bytes.len() != 32 { - return Err("Invalid key file size".into()); - } - let mut key = [0u8; 32]; - key.copy_from_slice(&bytes); - Ok(key) - } else { - let mut key = [0u8; 32]; - rand::rngs::OsRng.fill_bytes(&mut key); - std::fs::write(path, key)?; - Ok(key) + return read_key(path); + } + + let mut key = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut key); + + match write_new_key(path, &key) { + Ok(()) => Ok(key), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => read_key(path), + Err(err) => Err(err.into()), + } +} + +fn read_key(path: &std::path::Path) -> Result<[u8; 32], Box> { + enforce_key_permissions(path)?; + + let bytes = std::fs::read(path)?; + if bytes.len() != 32 { + return Err("Invalid key file size".into()); + } + + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + Ok(key) +} + +fn write_new_key(path: &std::path::Path, key: &[u8; 32]) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::fs::OpenOptions; + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(path)?; + file.write_all(key)?; + file.sync_all()?; + } + + #[cfg(not(unix))] + { + std::fs::write(path, key)?; + } + + Ok(()) +} + +fn enforce_key_permissions(path: &std::path::Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let metadata = std::fs::metadata(path)?; + let current_mode = metadata.permissions().mode() & 0o777; + if current_mode != 0o600 { + let mut permissions = metadata.permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(path, permissions)?; + } + } + + #[cfg(not(unix))] + { + let _ = path; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_load_or_generate_key_creates_key() { + let dir = tempdir().expect("tempdir should be created"); + let path = dir.path().join("orchai.key"); + + let key = load_or_generate_key(&path).expect("key generation should succeed"); + assert_eq!(key.len(), 32); + + let persisted = fs::read(&path).expect("key file should exist"); + assert_eq!(persisted.len(), 32); + assert_eq!(persisted, key); + } + + #[test] + fn test_load_or_generate_key_reads_existing_key() { + let dir = tempdir().expect("tempdir should be created"); + let path = dir.path().join("orchai.key"); + let expected = [42u8; 32]; + fs::write(&path, expected).expect("existing key should be written"); + + let loaded = load_or_generate_key(&path).expect("existing key should be loaded"); + assert_eq!(loaded, expected); + } + + #[cfg(unix)] + #[test] + fn test_load_or_generate_key_creates_private_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().expect("tempdir should be created"); + let path = dir.path().join("orchai.key"); + + load_or_generate_key(&path).expect("key generation should succeed"); + + let mode = fs::metadata(&path) + .expect("metadata should be readable") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + + #[cfg(unix)] + #[test] + fn test_load_or_generate_key_hardens_existing_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().expect("tempdir should be created"); + let path = dir.path().join("orchai.key"); + fs::write(&path, [7u8; 32]).expect("existing key should be written"); + + let mut permissions = fs::metadata(&path) + .expect("metadata should be readable") + .permissions(); + permissions.set_mode(0o644); + fs::set_permissions(&path, permissions).expect("permissions should be set"); + + load_or_generate_key(&path).expect("existing key should be loaded"); + + let mode = fs::metadata(&path) + .expect("metadata should be readable") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); } } diff --git a/src-tauri/src/models/credential.rs b/src-tauri/src/models/credential.rs index 880bffa..c2f4879 100644 --- a/src-tauri/src/models/credential.rs +++ b/src-tauri/src/models/credential.rs @@ -18,6 +18,39 @@ pub struct TuleapCredentialsSafe { } impl TuleapCredentials { + pub fn validate_input( + tuleap_url: &str, + username: &str, + password: &str, + ) -> std::result::Result<(String, String, String), String> { + let normalized_url = tuleap_url.trim().trim_end_matches('/').to_string(); + if normalized_url.is_empty() { + return Err("Tuleap URL cannot be empty".to_string()); + } + + let parsed_url = reqwest::Url::parse(&normalized_url) + .map_err(|_| "Tuleap URL must be a valid absolute URL".to_string())?; + + if !matches!(parsed_url.scheme(), "http" | "https") { + return Err("Tuleap URL must use http or https".to_string()); + } + + if parsed_url.host_str().is_none() { + return Err("Tuleap URL must include a host".to_string()); + } + + let normalized_username = username.trim().to_string(); + if normalized_username.is_empty() { + return Err("Username cannot be empty".to_string()); + } + + if password.trim().is_empty() { + return Err("Password cannot be empty".to_string()); + } + + Ok((normalized_url, normalized_username, password.to_string())) + } + pub fn upsert( conn: &Connection, tuleap_url: &str, @@ -154,4 +187,48 @@ mod tests { let result = TuleapCredentials::get(&conn).expect("get should succeed"); assert!(result.is_none()); } + + #[test] + fn test_validate_input_trims_url_and_username() { + let (url, username, password) = + TuleapCredentials::validate_input(" https://tuleap.example.com/ ", " alice ", "secret") + .expect("validation should succeed"); + + assert_eq!(url, "https://tuleap.example.com"); + assert_eq!(username, "alice"); + assert_eq!(password, "secret"); + } + + #[test] + fn test_validate_input_rejects_empty_url() { + let result = TuleapCredentials::validate_input(" ", "alice", "secret"); + assert!(result.is_err()); + } + + #[test] + fn test_validate_input_rejects_invalid_url() { + let result = TuleapCredentials::validate_input("not-an-url", "alice", "secret"); + assert!(result.is_err()); + } + + #[test] + fn test_validate_input_rejects_non_http_scheme() { + let result = + TuleapCredentials::validate_input("ftp://tuleap.example.com", "alice", "secret"); + assert!(result.is_err()); + } + + #[test] + fn test_validate_input_rejects_empty_username() { + let result = + TuleapCredentials::validate_input("https://tuleap.example.com", " ", "secret"); + assert!(result.is_err()); + } + + #[test] + fn test_validate_input_rejects_empty_password() { + let result = + TuleapCredentials::validate_input("https://tuleap.example.com", "alice", " "); + assert!(result.is_err()); + } }