diff --git a/dwctl/src/api/handlers/auth.rs b/dwctl/src/api/handlers/auth.rs index 256e32270..9963489d6 100644 --- a/dwctl/src/api/handlers/auth.rs +++ b/dwctl/src/api/handlers/auth.rs @@ -98,7 +98,12 @@ pub async fn register(State(state): State, Json(request): Json Result { +/// Argon2 hashing parameters. +#[derive(Debug, Clone, Copy)] +pub struct Argon2Params { + pub memory_kib: u32, + pub iterations: u32, + pub parallelism: u32, +} + +impl Argon2Params { + /// Create Argon2 instance with these parameters. + fn to_argon2(self) -> Result, Error> { + let params = Params::new(self.memory_kib, self.iterations, self.parallelism, None).map_err(|e| Error::Internal { + operation: format!("create argon2 params: {e}"), + })?; + + Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params)) + } +} + +impl Default for Argon2Params { + /// Secure defaults for production (Argon2id RFC recommendations) + fn default() -> Self { + Self { + memory_kib: 19456, // 19 MB + iterations: 2, + parallelism: 1, + } + } +} + +/// Hash a string using Argon2 (used for passwords and tokens). +/// +/// Uses the provided parameters or secure defaults if None. +pub fn hash_string_with_params(input: &str, params: Option) -> Result { let salt = SaltString::generate(&mut OsRng); - let argon2 = Argon2::default(); + let argon2 = if let Some(p) = params { + p.to_argon2()? + } else { + Argon2Params::default().to_argon2()? + }; let hash = argon2.hash_password(input.as_bytes(), &salt).map_err(|e| Error::Internal { operation: format!("hash string: {e}"), @@ -21,12 +57,20 @@ pub fn hash_string(input: &str) -> Result { Ok(hash.to_string()) } -/// Verify a string against a hash +/// Hash a string using Argon2 with default secure parameters. +pub fn hash_string(input: &str) -> Result { + hash_string_with_params(input, None) +} + +/// Verify a string against a hash. +/// +/// Note: Verification uses the parameters embedded in the hash itself. pub fn verify_string(input: &str, hash: &str) -> Result { let parsed_hash = PasswordHash::new(hash).map_err(|e| Error::Internal { operation: format!("parse hash: {e}"), })?; + // Verification always uses params from the hash let argon2 = Argon2::default(); Ok(argon2.verify_password(input.as_bytes(), &parsed_hash).is_ok()) } diff --git a/dwctl/src/config.rs b/dwctl/src/config.rs index fde5ec56a..c2d14b3e4 100644 --- a/dwctl/src/config.rs +++ b/dwctl/src/config.rs @@ -483,6 +483,12 @@ pub struct PasswordConfig { pub min_length: usize, /// Maximum password length pub max_length: usize, + /// Argon2 memory cost in KiB (default: 19456 KiB = 19 MB, secure for production) + pub argon2_memory_kib: u32, + /// Argon2 iterations (default: 2, secure for production) + pub argon2_iterations: u32, + /// Argon2 parallelism (default: 1) + pub argon2_parallelism: u32, } /// Security configuration for JWT and CORS. @@ -929,6 +935,10 @@ impl Default for PasswordConfig { Self { min_length: 8, max_length: 64, + // Secure defaults for production (Argon2id RFC recommendations) + argon2_memory_kib: 19456, // 19 MB + argon2_iterations: 2, + argon2_parallelism: 1, } } } diff --git a/dwctl/src/db/handlers/password_reset_tokens.rs b/dwctl/src/db/handlers/password_reset_tokens.rs index 3bf989ec2..0bc9341e8 100644 --- a/dwctl/src/db/handlers/password_reset_tokens.rs +++ b/dwctl/src/db/handlers/password_reset_tokens.rs @@ -35,7 +35,8 @@ impl<'c> Repository for PasswordResetTokens<'c> { #[instrument(skip(self, request), err)] async fn create(&mut self, request: &Self::CreateRequest) -> Result { - let token_hash = password::hash_string(&request.raw_token).map_err(|e| DbError::Other(anyhow::anyhow!(e)))?; + let token_hash = password::hash_string_with_params(&request.raw_token, Some(request.argon2_params)) + .map_err(|e| DbError::Other(anyhow::anyhow!(e)))?; let token = sqlx::query_as!( PasswordResetToken, @@ -153,6 +154,11 @@ impl<'c> PasswordResetTokens<'c> { user_id, raw_token: raw_token.clone(), expires_at, + argon2_params: password::Argon2Params { + memory_kib: config.auth.native.password.argon2_memory_kib, + iterations: config.auth.native.password.argon2_iterations, + parallelism: config.auth.native.password.argon2_parallelism, + }, }; let token = self.create(&request).await?; diff --git a/dwctl/src/db/models/password_reset_tokens.rs b/dwctl/src/db/models/password_reset_tokens.rs index 2d88b49eb..acae2b0ee 100644 --- a/dwctl/src/db/models/password_reset_tokens.rs +++ b/dwctl/src/db/models/password_reset_tokens.rs @@ -24,6 +24,7 @@ pub struct PasswordResetTokenCreateRequest { pub user_id: UserId, pub raw_token: String, pub expires_at: DateTime, + pub argon2_params: crate::auth::password::Argon2Params, } /// Request for updating a password reset token (mark as used) diff --git a/dwctl/src/lib.rs b/dwctl/src/lib.rs index 26233fc46..95f4bfad4 100644 --- a/dwctl/src/lib.rs +++ b/dwctl/src/lib.rs @@ -268,10 +268,18 @@ pub fn migrator() -> sqlx::migrate::Migrator { /// # } /// ``` #[instrument(skip_all)] -pub async fn create_initial_admin_user(email: &str, password: Option<&str>, db: &PgPool) -> Result { +pub async fn create_initial_admin_user( + email: &str, + password: Option<&str>, + argon2_params: password::Argon2Params, + db: &PgPool, +) -> Result { // Hash password if provided let password_hash = if let Some(pwd) = password { - Some(password::hash_string(pwd).map_err(|e| sqlx::Error::Encode(format!("Failed to hash admin password: {e}").into()))?) + Some( + password::hash_string_with_params(pwd, Some(argon2_params)) + .map_err(|e| sqlx::Error::Encode(format!("Failed to hash admin password: {e}").into()))?, + ) } else { None }; @@ -536,7 +544,12 @@ async fn setup_database( }; // Create initial admin user if it doesn't exist - create_initial_admin_user(&config.admin_email, config.admin_password.as_deref(), &pool) + let argon2_params = password::Argon2Params { + memory_kib: config.auth.native.password.argon2_memory_kib, + iterations: config.auth.native.password.argon2_iterations, + parallelism: config.auth.native.password.argon2_parallelism, + }; + create_initial_admin_user(&config.admin_email, config.admin_password.as_deref(), argon2_params, &pool) .await .map_err(|e| anyhow::anyhow!("Failed to create initial admin user: {}", e))?; @@ -1392,6 +1405,7 @@ mod test { use super::{AppState, create_initial_admin_user}; use crate::{ api::models::users::Role, + auth::password, db::handlers::Users, request_logging::{AiRequest, AiResponse}, test_utils::*, @@ -1716,9 +1730,18 @@ mod test { assert!(initial_user.is_err() || initial_user.unwrap().is_none()); // Create the initial admin user - let user_id = create_initial_admin_user(test_email, None, &pool) - .await - .expect("Should create admin user successfully"); + let user_id = create_initial_admin_user( + test_email, + None, + password::Argon2Params { + memory_kib: 128, + iterations: 1, + parallelism: 1, + }, + &pool, + ) + .await + .expect("Should create admin user successfully"); // Verify user was created with correct properties let created_user = users_repo @@ -1750,9 +1773,18 @@ mod test { .expect("Should update user email"); // Call create_initial_admin_user - should be idempotent - let returned_user_id = create_initial_admin_user(test_email, None, &pool) - .await - .expect("Should handle existing user successfully"); + let returned_user_id = create_initial_admin_user( + test_email, + None, + password::Argon2Params { + memory_kib: 128, + iterations: 1, + parallelism: 1, + }, + &pool, + ) + .await + .expect("Should handle existing user successfully"); // Should return the existing user's ID assert_eq!(returned_user_id, existing_user_id); diff --git a/dwctl/src/test_utils.rs b/dwctl/src/test_utils.rs index 6998627c0..21dbb805e 100644 --- a/dwctl/src/test_utils.rs +++ b/dwctl/src/test_utils.rs @@ -1,8 +1,8 @@ //! Test utilities for integration testing (available with `test-utils` feature). use crate::config::{ - BatchConfig, DaemonConfig, DaemonEnabled, FilesConfig, LeaderElectionConfig, NativeAuthConfig, OnwardsSyncConfig, PoolSettings, - ProbeSchedulerConfig, ProxyHeaderAuthConfig, SecurityConfig, + BatchConfig, DaemonConfig, DaemonEnabled, FilesConfig, LeaderElectionConfig, NativeAuthConfig, OnwardsSyncConfig, PasswordConfig, + PoolSettings, ProbeSchedulerConfig, ProxyHeaderAuthConfig, SecurityConfig, }; use crate::db::handlers::inference_endpoints::{InferenceEndpointFilter, InferenceEndpoints}; use crate::db::handlers::repository::Repository; @@ -87,6 +87,14 @@ pub fn create_test_config() -> crate::config::Config { }, ..Default::default() }, + password: PasswordConfig { + min_length: 8, + max_length: 64, + // Ultra-weak params for fast testing (DO NOT USE IN PRODUCTION) + argon2_memory_kib: 128, // 128 KB (vs 19 MB production) + argon2_iterations: 1, // 1 iteration (vs 2 production) + argon2_parallelism: 1, // 1 thread + }, ..Default::default() }, proxy_header: ProxyHeaderAuthConfig {