diff --git a/Cargo.lock b/Cargo.lock index 776336ba33..ee4d0b82ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -947,9 +947,13 @@ dependencies = [ "common-telemetry", "common-test-util", "digest 0.10.7", + "hex", + "pbkdf2", "sha1 0.10.6", + "sha2 0.10.9", "snafu 0.8.6", "sql", + "subtle", "tokio", ] diff --git a/config/config.md b/config/config.md index 267e16a468..15d431e2af 100644 --- a/config/config.md +++ b/config/config.md @@ -15,7 +15,7 @@ | `default_timezone` | String | Unset | The default timezone of the server. | | `default_column_prefix` | String | Unset | The default column prefix for auto-created time index and value columns. | | `auto_create_table` | Bool | `true` | Server-side global switch for auto table creation on write.
When `false`, a missing table is never auto-created even if the request sets the `auto_create_table` hint to `true`. Default: `true`. | -| `user_provider` | String | Unset | The user provider for authentication.
Examples: "static_user_provider:file:/path/to/users", "static_user_provider:cmd:greptime_user=greptime_pwd" | +| `user_provider` | String | Unset | The user provider for authentication.
Examples: "static_user_provider:file:/path/to/users", "static_user_provider:cmd:greptime_user=greptime_pwd"
Password verifier formats: "plain:", "pbkdf2_sha256:::",
"mysql_native_password:"
"pbkdf2_sha256" protects passwords at rest but is not compatible with mysql_native_password. | | `max_in_flight_write_bytes` | String | Unset | Maximum total memory for all concurrent write request bodies and messages (HTTP, gRPC, Flight).
Set to 0 to disable the limit. Default: "0" (unlimited) | | `write_bytes_exhausted_policy` | String | Unset | Policy when write bytes quota is exhausted.
Options: "wait" (default, 10s timeout), "wait()" (e.g., "wait(30s)"), "fail" | | `init_regions_in_background` | Bool | `false` | Initialize all regions in the background during the startup.
By default, it provides services after all regions have been initialized. | @@ -233,7 +233,7 @@ | `default_timezone` | String | Unset | The default timezone of the server. | | `default_column_prefix` | String | Unset | The default column prefix for auto-created time index and value columns. | | `auto_create_table` | Bool | `true` | Server-side global switch for auto table creation on write.
When `false`, a missing table is never auto-created even if the request sets the `auto_create_table` hint to `true`. Default: `true`. | -| `user_provider` | String | Unset | The user provider for authentication.
Examples: "static_user_provider:file:/path/to/users", "static_user_provider:cmd:greptime_user=greptime_pwd" | +| `user_provider` | String | Unset | The user provider for authentication.
Examples: "static_user_provider:file:/path/to/users", "static_user_provider:cmd:greptime_user=greptime_pwd"
Password verifier formats: "plain:", "pbkdf2_sha256:::",
"mysql_native_password:"
"pbkdf2_sha256" protects passwords at rest but is not compatible with mysql_native_password. | | `max_in_flight_write_bytes` | String | Unset | Maximum total memory for all concurrent write request bodies and messages (HTTP, gRPC, Flight).
Set to 0 to disable the limit. Default: "0" (unlimited) | | `write_bytes_exhausted_policy` | String | Unset | Policy when write bytes quota is exhausted.
Options: "wait" (default, 10s timeout), "wait()" (e.g., "wait(30s)"), "fail" | | `runtime` | -- | -- | The runtime options. | diff --git a/config/frontend.example.toml b/config/frontend.example.toml index a044aebda6..23de91fbb2 100644 --- a/config/frontend.example.toml +++ b/config/frontend.example.toml @@ -12,6 +12,9 @@ default_column_prefix = "greptime" ## The user provider for authentication. ## Examples: "static_user_provider:file:/path/to/users", "static_user_provider:cmd:greptime_user=greptime_pwd" +## Password verifier formats: "plain:", "pbkdf2_sha256:::", +## "mysql_native_password:" +## "pbkdf2_sha256" protects passwords at rest but is not compatible with mysql_native_password. ## @toml2docs:none-default #+ user_provider = "static_user_provider:file:/path/to/users" diff --git a/config/standalone.example.toml b/config/standalone.example.toml index f1f91a9e71..7d9366b6a2 100644 --- a/config/standalone.example.toml +++ b/config/standalone.example.toml @@ -12,6 +12,9 @@ default_column_prefix = "greptime" ## The user provider for authentication. ## Examples: "static_user_provider:file:/path/to/users", "static_user_provider:cmd:greptime_user=greptime_pwd" +## Password verifier formats: "plain:", "pbkdf2_sha256:::", +## "mysql_native_password:" +## "pbkdf2_sha256" protects passwords at rest but is not compatible with mysql_native_password. ## @toml2docs:none-default #+ user_provider = "static_user_provider:file:/path/to/users" diff --git a/src/auth/Cargo.toml b/src/auth/Cargo.toml index 9c91023da5..77a676ea25 100644 --- a/src/auth/Cargo.toml +++ b/src/auth/Cargo.toml @@ -20,9 +20,13 @@ common-error.workspace = true common-macro.workspace = true common-telemetry.workspace = true digest = "0.10" +hex.workspace = true +pbkdf2 = "0.12" sha1 = "0.10" +sha2 = "0.10" snafu.workspace = true sql.workspace = true +subtle = "2.6" tokio.workspace = true [dev-dependencies] diff --git a/src/auth/src/common.rs b/src/auth/src/common.rs index 7dbcd72086..c14c85799e 100644 --- a/src/auth/src/common.rs +++ b/src/auth/src/common.rs @@ -108,6 +108,16 @@ pub fn auth_mysql( salt: Salt, username: &str, save_pwd: &[u8], +) -> Result<()> { + let hash_stage_2 = mysql_native_password_hash(save_pwd); + auth_mysql_with_hash_stage_2(auth_data, salt, username, &hash_stage_2) +} + +pub(crate) fn auth_mysql_with_hash_stage_2( + auth_data: HashedPassword, + salt: Salt, + username: &str, + hash_stage_2: &[u8], ) -> Result<()> { ensure!( auth_data.len() == 20, @@ -115,9 +125,15 @@ pub fn auth_mysql( msg: "Illegal mysql password length" } ); + ensure!( + hash_stage_2.len() == 20, + InvalidConfigSnafu { + value: hash_stage_2.len().to_string(), + msg: "Illegal mysql native password verifier length", + } + ); // ref: https://github.com/mysql/mysql-server/blob/a246bad76b9271cb4333634e954040a970222e0a/sql/auth/password.cc#L62 - let hash_stage_2 = double_sha1(save_pwd); - let tmp = sha1_two(salt, &hash_stage_2); + let tmp = sha1_two(salt, hash_stage_2); // xor auth_data and tmp let mut xor_result = [0u8; 20]; for i in 0..20 { @@ -134,6 +150,10 @@ pub fn auth_mysql( } } +pub(crate) fn mysql_native_password_hash(save_pwd: &[u8]) -> Vec { + double_sha1(save_pwd) +} + fn sha1_two(input_1: &[u8], input_2: &[u8]) -> Vec { let mut hasher = Sha1::new(); hasher.update(input_1); diff --git a/src/auth/src/user_provider.rs b/src/auth/src/user_provider.rs index dd3580ed9a..8e86bc8ba6 100644 --- a/src/auth/src/user_provider.rs +++ b/src/auth/src/user_provider.rs @@ -17,14 +17,17 @@ pub(crate) mod watch_file_user_provider; use std::collections::HashMap; use std::fs::File; -use std::io; use std::io::BufRead; use std::path::Path; +use std::{fmt, io}; use common_base::secrets::ExposeSecret; +use pbkdf2::pbkdf2_hmac; +use sha2::Sha256; use snafu::{OptionExt, ResultExt, ensure}; +use subtle::ConstantTimeEq; -use crate::common::{Identity, Password}; +use crate::common::{Identity, Password, auth_mysql_with_hash_stage_2}; use crate::error::{ IllegalParamSnafu, InvalidConfigSnafu, IoSnafu, Result, UnsupportedPasswordTypeSnafu, UserNotFoundSnafu, UserPasswordMismatchSnafu, @@ -32,6 +35,12 @@ use crate::error::{ use crate::user_info::{DefaultUserInfo, PermissionMode}; use crate::{UserInfoRef, auth_mysql}; +const MAX_PBKDF2_SHA256_ITERATIONS: u32 = 1_000_000; +/// PBKDF2-SHA256 derived key length, fixed to the native SHA-256 output size. +const PBKDF2_SHA256_HASH_LEN: usize = 32; +/// Upper bound on the salt length to reject misconfigured credentials. +const MAX_PBKDF2_SHA256_SALT_LEN: usize = 1024; + #[async_trait::async_trait] pub trait UserProvider: Send + Sync { fn name(&self) -> &str; @@ -64,9 +73,124 @@ pub trait UserProvider: Send + Sync { } } -/// Type alias for user info map -/// Key is username, value is (password, permission_mode) -pub type UserInfoMap = HashMap, PermissionMode)>; +#[derive(Clone, PartialEq, Eq)] +pub(crate) enum PasswordVerifier { + PlainText(String), + Pbkdf2Sha256 { + iterations: u32, + salt: Vec, + hash: Vec, + }, + MysqlNativePassword { + hash_stage_2: Vec, + }, +} + +impl fmt::Debug for PasswordVerifier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PasswordVerifier::PlainText(_) => { + f.debug_tuple("PlainText").field(&"").finish() + } + PasswordVerifier::Pbkdf2Sha256 { iterations, .. } => f + .debug_struct("Pbkdf2Sha256") + .field("iterations", iterations) + .field("salt", &"") + .field("hash", &"") + .finish(), + PasswordVerifier::MysqlNativePassword { .. } => f + .debug_struct("MysqlNativePassword") + .field("hash_stage_2", &"") + .finish(), + } + } +} + +impl PasswordVerifier { + fn parse(input: &str) -> Option { + if let Some(password) = input.strip_prefix("plain:") { + return Some(Self::PlainText(password.to_string())); + } + + if let Some(verifier) = input.strip_prefix("pbkdf2_sha256:") { + let mut parts = verifier.split(':'); + let iterations = parts.next()?.parse::().ok()?; + let salt = hex::decode(parts.next()?).ok()?; + let hash = hex::decode(parts.next()?).ok()?; + if parts.next().is_some() + || iterations == 0 + || iterations > MAX_PBKDF2_SHA256_ITERATIONS + || salt.is_empty() + || salt.len() > MAX_PBKDF2_SHA256_SALT_LEN + || hash.len() != PBKDF2_SHA256_HASH_LEN + { + return None; + } + + return Some(Self::Pbkdf2Sha256 { + iterations, + salt, + hash, + }); + } + + if let Some(verifier) = input.strip_prefix("mysql_native_password:") { + let hash_stage_2 = hex::decode(verifier).ok()?; + if hash_stage_2.len() != 20 { + return None; + } + + return Some(Self::MysqlNativePassword { hash_stage_2 }); + } + + Some(Self::PlainText(input.to_string())) + } + + fn verify_plain_text(&self, password: &str) -> bool { + match self { + PasswordVerifier::PlainText(expected) => { + expected.as_bytes().ct_eq(password.as_bytes()).into() + } + PasswordVerifier::Pbkdf2Sha256 { + iterations, + salt, + hash, + } => { + if hash.len() != PBKDF2_SHA256_HASH_LEN { + return false; + } + let mut actual = [0u8; PBKDF2_SHA256_HASH_LEN]; + pbkdf2_hmac::(password.as_bytes(), salt, *iterations, &mut actual); + hash.as_slice().ct_eq(&actual[..]).into() + } + PasswordVerifier::MysqlNativePassword { .. } => false, + } + } + + fn verify_mysql_native_password( + &self, + auth_data: &[u8], + salt: &[u8], + username: &str, + ) -> Result<()> { + match self { + PasswordVerifier::PlainText(password) => { + auth_mysql(auth_data, salt, username, password.as_bytes()) + } + PasswordVerifier::MysqlNativePassword { hash_stage_2 } => { + auth_mysql_with_hash_stage_2(auth_data, salt, username, hash_stage_2) + } + PasswordVerifier::Pbkdf2Sha256 { .. } => UnsupportedPasswordTypeSnafu { + password_type: "mysql_native_password_with_pbkdf2_sha256_verifier", + } + .fail(), + } + } +} + +/// Type alias for user info map. +/// Key is username, value is (password verifier, permission_mode). +pub type UserInfoMap = HashMap; fn load_credential_from_file(filepath: &str) -> Result { // check valid path @@ -116,8 +240,15 @@ fn load_credential_from_file(filepath: &str) -> Result { Ok(credential) } -/// Parse a line of credential in the format of `username=password` or `username:permission_mode=password` -pub(crate) fn parse_credential_line(line: &str) -> Option<(String, (Vec, PermissionMode))> { +/// Parse a line of credential in the format of `username=password` or `username:permission_mode=password`. +/// +/// The password part accepts legacy plain text and explicit verifier formats: +/// - `plain:` +/// - `pbkdf2_sha256:::` +/// - `mysql_native_password:` +pub(crate) fn parse_credential_line( + line: &str, +) -> Option<(String, (PasswordVerifier, PermissionMode))> { let parts = line.split('=').collect::>(); if parts.len() != 2 { return None; @@ -130,10 +261,9 @@ pub(crate) fn parse_credential_line(line: &str) -> Option<(String, (Vec, Per (username_part, PermissionMode::default()) }; - Some(( - username.to_string(), - (password.as_bytes().to_vec(), permission_mode), - )) + let verifier = PasswordVerifier::parse(password)?; + + Some((username.to_string(), (verifier, permission_mode))) } fn authenticate_with_credential( @@ -149,7 +279,7 @@ fn authenticate_with_credential( msg: "blank username" } ); - let (save_pwd, permission_mode) = users.get(username).context(UserNotFoundSnafu { + let (verifier, permission_mode) = users.get(username).context(UserNotFoundSnafu { username: username.to_string(), })?; @@ -161,7 +291,7 @@ fn authenticate_with_credential( msg: "blank password" } ); - if save_pwd == pwd.expose_secret().as_bytes() { + if verifier.verify_plain_text(pwd.expose_secret()) { Ok(DefaultUserInfo::with_name_and_permission( username, *permission_mode, @@ -173,11 +303,9 @@ fn authenticate_with_credential( .fail() } } - Password::MysqlNativePassword(auth_data, salt) => { - auth_mysql(auth_data, salt, username, save_pwd).map(|_| { - DefaultUserInfo::with_name_and_permission(username, *permission_mode) - }) - } + Password::MysqlNativePassword(auth_data, salt) => verifier + .verify_mysql_native_password(auth_data, salt, username) + .map(|_| DefaultUserInfo::with_name_and_permission(username, *permission_mode)), Password::PgMD5(_, _) => UnsupportedPasswordTypeSnafu { password_type: "pg_md5", } @@ -188,7 +316,36 @@ fn authenticate_with_credential( } #[cfg(test)] mod tests { + use digest::Digest; + use sha1::Sha1; + use super::*; + use crate::common::mysql_native_password_hash; + + fn plain(password: &str) -> PasswordVerifier { + PasswordVerifier::PlainText(password.to_string()) + } + + fn sha1_one(data: &[u8]) -> Vec { + let mut hasher = Sha1::new(); + hasher.update(data); + hasher.finalize().to_vec() + } + + fn mysql_native_password_auth_data(password: &str, salt: &[u8]) -> Vec { + let hash_stage_1 = sha1_one(password.as_bytes()); + let hash_stage_2 = mysql_native_password_hash(password.as_bytes()); + let mut hasher = Sha1::new(); + hasher.update(salt); + hasher.update(hash_stage_2); + let scramble = hasher.finalize(); + + hash_stage_1 + .iter() + .zip(scramble.iter()) + .map(|(lhs, rhs)| lhs ^ rhs) + .collect() + } #[test] fn test_parse_credential_line() { @@ -198,7 +355,7 @@ mod tests { result, Some(( "admin".to_string(), - ("password123".as_bytes().to_vec(), PermissionMode::default()) + (plain("password123"), PermissionMode::default()) )) ); @@ -208,7 +365,7 @@ mod tests { result, Some(( "user".to_string(), - ("secret".as_bytes().to_vec(), PermissionMode::ReadOnly) + (plain("secret"), PermissionMode::ReadOnly) )) ); let result = parse_credential_line("user:ro=secret"); @@ -216,7 +373,7 @@ mod tests { result, Some(( "user".to_string(), - ("secret".as_bytes().to_vec(), PermissionMode::ReadOnly) + (plain("secret"), PermissionMode::ReadOnly) )) ); // Username with WriteOnly permission mode @@ -225,7 +382,7 @@ mod tests { result, Some(( "writer".to_string(), - ("mypass".as_bytes().to_vec(), PermissionMode::WriteOnly) + (plain("mypass"), PermissionMode::WriteOnly) )) ); @@ -235,7 +392,7 @@ mod tests { result, Some(( "writer".to_string(), - ("mypass".as_bytes().to_vec(), PermissionMode::WriteOnly) + (plain("mypass"), PermissionMode::WriteOnly) )) ); @@ -245,10 +402,7 @@ mod tests { result, Some(( "admin".to_string(), - ( - "p@ssw0rd!123".as_bytes().to_vec(), - PermissionMode::ReadWrite - ) + (plain("p@ssw0rd!123"), PermissionMode::ReadWrite) )) ); @@ -258,10 +412,81 @@ mod tests { result, Some(( "user name".to_string(), - ("password".as_bytes().to_vec(), PermissionMode::WriteOnly) + (plain("password"), PermissionMode::WriteOnly) )) ); + let result = parse_credential_line("user=plain:password"); + assert_eq!( + result, + Some(( + "user".to_string(), + (plain("password"), PermissionMode::default()) + )) + ); + + let iterations = 4096; + let salt = b"salt"; + let mut hash = [0u8; 32]; + pbkdf2_hmac::("password".as_bytes(), salt, iterations, &mut hash); + let result = parse_credential_line(&format!( + "user=pbkdf2_sha256:{iterations}:{}:{}", + hex::encode(salt), + hex::encode(hash) + )); + assert_eq!( + result, + Some(( + "user".to_string(), + ( + PasswordVerifier::Pbkdf2Sha256 { + iterations, + salt: salt.to_vec(), + hash: hash.to_vec(), + }, + PermissionMode::default() + ) + )) + ); + + let result = parse_credential_line("user=pbkdf2_sha256:4096:not-hex:abcd"); + assert_eq!(result, None); + + // A well-formed but truncated hash must be rejected: a short hash would let + // many wrong passwords pass by matching only a few derived bytes. + let result = parse_credential_line(&format!( + "user=pbkdf2_sha256:4096:{}:abcd", + hex::encode(salt) + )); + assert_eq!(result, None); + + let result = parse_credential_line(&format!( + "user=pbkdf2_sha256:{}:{}:{}", + MAX_PBKDF2_SHA256_ITERATIONS + 1, + hex::encode(salt), + hex::encode(hash) + )); + assert_eq!(result, None); + + let hash_stage_2 = mysql_native_password_hash("password".as_bytes()); + let result = parse_credential_line(&format!( + "user=mysql_native_password:{}", + hex::encode(&hash_stage_2) + )); + assert_eq!( + result, + Some(( + "user".to_string(), + ( + PasswordVerifier::MysqlNativePassword { hash_stage_2 }, + PermissionMode::default() + ) + )) + ); + + let result = parse_credential_line("user=mysql_native_password:abcd"); + assert_eq!(result, None); + // Invalid format - no equals sign let result = parse_credential_line("invalid_line"); assert_eq!(result, None); @@ -274,10 +499,7 @@ mod tests { let result = parse_credential_line("user="); assert_eq!( result, - Some(( - "user".to_string(), - ("".as_bytes().to_vec(), PermissionMode::default()) - )) + Some(("user".to_string(), (plain(""), PermissionMode::default()))) ); // Empty username @@ -286,8 +508,112 @@ mod tests { result, Some(( "".to_string(), - ("password".as_bytes().to_vec(), PermissionMode::default()) + (plain("password"), PermissionMode::default()) )) ); } + + #[test] + fn test_authenticate_with_mysql_native_password_verifier() { + let password = "password"; + let salt = b"12345678901234567890"; + let hash_stage_2 = mysql_native_password_hash(password.as_bytes()); + let auth_data = mysql_native_password_auth_data(password, salt); + let users = HashMap::from([( + "user".to_string(), + ( + PasswordVerifier::MysqlNativePassword { hash_stage_2 }, + PermissionMode::default(), + ), + )]); + + let result = authenticate_with_credential( + &users, + Identity::UserId("user", None), + Password::MysqlNativePassword(&auth_data, salt), + ); + + assert!(result.is_ok()); + } + + #[test] + fn test_authenticate_with_plain_text_mysql_native_password() { + let password = "password"; + let salt = b"12345678901234567890"; + let auth_data = mysql_native_password_auth_data(password, salt); + let users = HashMap::from([( + "user".to_string(), + ( + PasswordVerifier::PlainText(password.to_string()), + PermissionMode::default(), + ), + )]); + + let result = authenticate_with_credential( + &users, + Identity::UserId("user", None), + Password::MysqlNativePassword(&auth_data, salt), + ); + + assert!(result.is_ok()); + } + + #[test] + fn test_pbkdf2_sha256_rejects_mysql_native_password() { + let password = "password"; + let salt = b"salt"; + let iterations = 4096; + let mut hash = [0u8; 32]; + pbkdf2_hmac::(password.as_bytes(), salt, iterations, &mut hash); + let users = HashMap::from([( + "user".to_string(), + ( + PasswordVerifier::Pbkdf2Sha256 { + iterations, + salt: salt.to_vec(), + hash: hash.to_vec(), + }, + PermissionMode::default(), + ), + )]); + let mysql_salt = b"12345678901234567890"; + let auth_data = mysql_native_password_auth_data(password, mysql_salt); + + let result = authenticate_with_credential( + &users, + Identity::UserId("user", None), + Password::MysqlNativePassword(&auth_data, mysql_salt), + ); + + assert!(result.is_err()); + } + + #[test] + fn test_password_verifier_debug_redacts_secrets() { + let debug = format!("{:?}", PasswordVerifier::PlainText("secret".to_string())); + assert!(debug.contains("")); + assert!(!debug.contains("secret")); + + let debug = format!( + "{:?}", + PasswordVerifier::Pbkdf2Sha256 { + iterations: 4096, + salt: b"super-secret-salt".to_vec(), + hash: b"super-secret-hash".to_vec(), + } + ); + assert!(debug.contains("Pbkdf2Sha256")); + assert!(debug.contains("4096")); + assert!(!debug.contains("super-secret-salt")); + assert!(!debug.contains("super-secret-hash")); + + let debug = format!( + "{:?}", + PasswordVerifier::MysqlNativePassword { + hash_stage_2: b"super-secret-hash".to_vec(), + } + ); + assert!(debug.contains("MysqlNativePassword")); + assert!(!debug.contains("super-secret-hash")); + } } diff --git a/src/auth/src/user_provider/static_user_provider.rs b/src/auth/src/user_provider/static_user_provider.rs index 50cd3ab366..689b962726 100644 --- a/src/auth/src/user_provider/static_user_provider.rs +++ b/src/auth/src/user_provider/static_user_provider.rs @@ -13,9 +13,9 @@ // limitations under the License. use async_trait::async_trait; -use snafu::{OptionExt, ResultExt}; +use snafu::OptionExt; -use crate::error::{FromUtf8Snafu, InvalidConfigSnafu, Result}; +use crate::error::{InvalidConfigSnafu, Result}; use crate::user_provider::{ UserInfoMap, authenticate_with_credential, load_credential_from_file, parse_credential_line, }; @@ -55,18 +55,6 @@ impl StaticUserProvider { .fail(), } } - - /// Return a random username/password pair - /// This is useful for invoking from other components in the cluster - pub fn get_one_user_pwd(&self) -> Result<(String, String)> { - let kv = self.users.iter().next().context(InvalidConfigSnafu { - value: "", - msg: "Expect at least one pair of username and password", - })?; - let username = kv.0; - let pwd = String::from_utf8(kv.1.0.clone()).context(FromUtf8Snafu)?; - Ok((username.clone(), pwd)) - } } #[async_trait] @@ -96,6 +84,8 @@ pub mod test { use std::io::{LineWriter, Write}; use common_test_util::temp_dir::create_temp_dir; + use pbkdf2::pbkdf2_hmac; + use sha2::Sha256; use crate::UserProvider; use crate::user_info::DefaultUserInfo; @@ -112,6 +102,28 @@ pub mod test { let _ = re.unwrap(); } + async fn test_authenticate_fails(provider: &dyn UserProvider, username: &str, password: &str) { + let re = provider + .authenticate( + Identity::UserId(username, None), + Password::PlainText(password.to_string().into()), + ) + .await; + assert!(re.is_err()); + } + + fn pbkdf2_sha256_verifier(password: &str) -> String { + let iterations = 4096; + let salt = b"salt"; + let mut hash = [0u8; 32]; + pbkdf2_hmac::(password.as_bytes(), salt, iterations, &mut hash); + format!( + "pbkdf2_sha256:{iterations}:{}:{}", + hex::encode(salt), + hex::encode(hash) + ) + } + #[tokio::test] async fn test_authorize() { let user_info = DefaultUserInfo::with_name("root"); @@ -129,6 +141,16 @@ pub mod test { test_authenticate(&provider, "admin", "654321").await; } + #[tokio::test] + async fn test_inline_provider_with_pbkdf2_sha256_verifier() { + let provider = + StaticUserProvider::new(&format!("cmd:root={}", pbkdf2_sha256_verifier("123456"))) + .unwrap(); + + test_authenticate(&provider, "root", "123456").await; + test_authenticate_fails(&provider, "root", "654321").await; + } + #[tokio::test] async fn test_file_provider() { let dir = create_temp_dir("test_file_provider");