From c9062fe0425a6fa112242067795494c6a297895e Mon Sep 17 00:00:00 2001 From: dennis zhuang Date: Fri, 12 Jun 2026 21:28:38 +0800 Subject: [PATCH] feat: add password hash generation command (#8288) * feat: add password hash generation command Signed-off-by: Dennis Zhuang * fix: reject empty password in hash-password command A blank plaintext password is rejected by the user provider before verifier comparison, so a verifier generated from an empty password is unusable. Fail fast on EOF or an empty line from --password-stdin (and on an empty --password) instead of printing a dead verifier. Signed-off-by: Dennis Zhuang --------- Signed-off-by: Dennis Zhuang Co-authored-by: Yingwen --- Cargo.lock | 1 + src/auth/src/common.rs | 78 ++++++++++- src/auth/src/lib.rs | 7 +- src/auth/src/user_provider.rs | 11 +- src/cmd/Cargo.toml | 1 + src/cmd/src/bin/greptime.rs | 7 +- src/cmd/src/lib.rs | 1 + src/cmd/src/user.rs | 237 ++++++++++++++++++++++++++++++++++ 8 files changed, 332 insertions(+), 11 deletions(-) create mode 100644 src/cmd/src/user.rs diff --git a/Cargo.lock b/Cargo.lock index d105168a1b7..73687696f4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2128,6 +2128,7 @@ dependencies = [ "flow", "frontend", "futures", + "hex", "human-panic", "humantime", "lazy_static", diff --git a/src/auth/src/common.rs b/src/auth/src/common.rs index c14c85799e2..3122822a520 100644 --- a/src/auth/src/common.rs +++ b/src/auth/src/common.rs @@ -16,7 +16,9 @@ use std::sync::Arc; use common_base::secrets::SecretString; use digest::Digest; +use pbkdf2::pbkdf2_hmac; use sha1::Sha1; +use sha2::Sha256; use snafu::{OptionExt, ensure}; use crate::error::{IllegalParamSnafu, InvalidConfigSnafu, Result, UserPasswordMismatchSnafu}; @@ -28,6 +30,10 @@ use crate::user_provider::watch_file_user_provider::{ use crate::{UserInfoRef, UserProviderRef}; pub(crate) const DEFAULT_USERNAME: &str = "greptime"; +pub const DEFAULT_PBKDF2_SHA256_ITERATIONS: u32 = 4096; +pub const PBKDF2_SHA256_HASH_LEN: usize = 32; +pub const MAX_PBKDF2_SHA256_ITERATIONS: u32 = 1_000_000; +pub const MAX_PBKDF2_SHA256_SALT_LEN: usize = 1024; /// construct a [`UserInfo`](crate::user_info::UserInfo) impl with name /// use default username `greptime` if None is provided @@ -150,10 +156,50 @@ pub(crate) fn auth_mysql_with_hash_stage_2( } } -pub(crate) fn mysql_native_password_hash(save_pwd: &[u8]) -> Vec { +pub fn mysql_native_password_hash(save_pwd: &[u8]) -> Vec { double_sha1(save_pwd) } +pub fn format_mysql_native_password_verifier(password: &[u8]) -> String { + format!( + "mysql_native_password:{}", + hex::encode(mysql_native_password_hash(password)) + ) +} + +pub fn format_pbkdf2_sha256_password_verifier( + password: &[u8], + salt: &[u8], + iterations: u32, +) -> Result { + ensure!( + iterations > 0 && iterations <= MAX_PBKDF2_SHA256_ITERATIONS, + IllegalParamSnafu { + msg: format!( + "pbkdf2_sha256 iterations must be in 1..={}", + MAX_PBKDF2_SHA256_ITERATIONS + ) + } + ); + ensure!( + !salt.is_empty() && salt.len() <= MAX_PBKDF2_SHA256_SALT_LEN, + IllegalParamSnafu { + msg: format!( + "pbkdf2_sha256 salt length must be in 1..={}", + MAX_PBKDF2_SHA256_SALT_LEN + ) + } + ); + + let mut hash = [0u8; PBKDF2_SHA256_HASH_LEN]; + pbkdf2_hmac::(password, salt, iterations, &mut hash); + Ok(format!( + "pbkdf2_sha256:{iterations}:{}:{}", + hex::encode(salt), + hex::encode(hash) + )) +} + fn sha1_two(input_1: &[u8], input_2: &[u8]) -> Vec { let mut hasher = Sha1::new(); hasher.update(input_1); @@ -198,4 +244,34 @@ mod tests { let sha1_2 = sha1_two("123456".as_bytes(), "654321".as_bytes()); assert_eq!(sha1_2, sha1_2_answer); } + + #[test] + fn test_format_mysql_native_password_verifier() { + let verifier = format_mysql_native_password_verifier("123456".as_bytes()); + assert_eq!( + "mysql_native_password:6bb4837eb74329105ee4568dda7dc67ed2ca2ad9", + verifier + ); + } + + #[test] + fn test_format_pbkdf2_sha256_password_verifier() { + let verifier = + format_pbkdf2_sha256_password_verifier("password".as_bytes(), b"salt", 4096).unwrap(); + assert_eq!( + "pbkdf2_sha256:4096:73616c74:c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a", + verifier + ); + + assert!(format_pbkdf2_sha256_password_verifier(b"password", b"", 4096).is_err()); + assert!(format_pbkdf2_sha256_password_verifier(b"password", b"salt", 0).is_err()); + assert!( + format_pbkdf2_sha256_password_verifier( + b"password", + b"salt", + MAX_PBKDF2_SHA256_ITERATIONS + 1, + ) + .is_err() + ); + } } diff --git a/src/auth/src/lib.rs b/src/auth/src/lib.rs index 1c8e01bca12..180042eefd8 100644 --- a/src/auth/src/lib.rs +++ b/src/auth/src/lib.rs @@ -22,8 +22,11 @@ mod user_provider; pub mod tests; pub use common::{ - HashedPassword, Identity, Password, auth_mysql, static_user_provider_from_option, - user_provider_from_option, userinfo_by_name, + DEFAULT_PBKDF2_SHA256_ITERATIONS, HashedPassword, Identity, MAX_PBKDF2_SHA256_ITERATIONS, + MAX_PBKDF2_SHA256_SALT_LEN, PBKDF2_SHA256_HASH_LEN, Password, auth_mysql, + format_mysql_native_password_verifier, format_pbkdf2_sha256_password_verifier, + mysql_native_password_hash, static_user_provider_from_option, user_provider_from_option, + userinfo_by_name, }; pub use permission::{DefaultPermissionChecker, PermissionChecker, PermissionReq, PermissionResp}; pub use user_info::UserInfo; diff --git a/src/auth/src/user_provider.rs b/src/auth/src/user_provider.rs index 8e86bc8ba61..2ff61bbe9bc 100644 --- a/src/auth/src/user_provider.rs +++ b/src/auth/src/user_provider.rs @@ -27,7 +27,10 @@ use sha2::Sha256; use snafu::{OptionExt, ResultExt, ensure}; use subtle::ConstantTimeEq; -use crate::common::{Identity, Password, auth_mysql_with_hash_stage_2}; +use crate::common::{ + Identity, MAX_PBKDF2_SHA256_ITERATIONS, MAX_PBKDF2_SHA256_SALT_LEN, PBKDF2_SHA256_HASH_LEN, + Password, auth_mysql_with_hash_stage_2, +}; use crate::error::{ IllegalParamSnafu, InvalidConfigSnafu, IoSnafu, Result, UnsupportedPasswordTypeSnafu, UserNotFoundSnafu, UserPasswordMismatchSnafu, @@ -35,12 +38,6 @@ 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; diff --git a/src/cmd/Cargo.toml b/src/cmd/Cargo.toml index df54ea6b2e0..093d9600c13 100644 --- a/src/cmd/Cargo.toml +++ b/src/cmd/Cargo.toml @@ -67,6 +67,7 @@ etcd-client.workspace = true flow.workspace = true frontend = { workspace = true, default-features = false } futures.workspace = true +hex.workspace = true human-panic = "2.0" humantime.workspace = true lazy_static.workspace = true diff --git a/src/cmd/src/bin/greptime.rs b/src/cmd/src/bin/greptime.rs index 05ae7900556..e0f326908a2 100644 --- a/src/cmd/src/bin/greptime.rs +++ b/src/cmd/src/bin/greptime.rs @@ -19,7 +19,7 @@ use clap::{Parser, Subcommand}; use cmd::datanode::builder::InstanceBuilder; use cmd::error::{InitTlsProviderSnafu, Result}; use cmd::options::GlobalOptions; -use cmd::{App, cli, datanode, flownode, frontend, metasrv, standalone}; +use cmd::{App, cli, datanode, flownode, frontend, metasrv, standalone, user}; use common_base::Plugins; use common_version::{product_name, verbose_version, version}; use servers::install_default_crypto_provider; @@ -60,6 +60,10 @@ enum SubCommand { /// Execute the cli tools. #[clap(name = "cli")] Cli(cli::Command), + + /// Manage user credentials. + #[clap(name = "user")] + User(user::Command), } #[cfg(not(windows))] @@ -146,6 +150,7 @@ async fn start(cli: Command) -> Result<()> { .run() .await } + SubCommand::User(cmd) => cmd.run(), } } diff --git a/src/cmd/src/lib.rs b/src/cmd/src/lib.rs index 27564597d7d..22d818b8630 100644 --- a/src/cmd/src/lib.rs +++ b/src/cmd/src/lib.rs @@ -31,6 +31,7 @@ pub mod frontend; pub mod metasrv; pub mod options; pub mod standalone; +pub mod user; lazy_static::lazy_static! { static ref APP_VERSION: prometheus::IntGaugeVec = diff --git a/src/cmd/src/user.rs b/src/cmd/src/user.rs new file mode 100644 index 00000000000..6f7e28f9687 --- /dev/null +++ b/src/cmd/src/user.rs @@ -0,0 +1,237 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#![allow(clippy::print_stdout)] + +use std::io; + +use auth::{ + DEFAULT_PBKDF2_SHA256_ITERATIONS, MAX_PBKDF2_SHA256_SALT_LEN, + format_mysql_native_password_verifier, format_pbkdf2_sha256_password_verifier, +}; +use clap::{ArgGroup, Parser, Subcommand, ValueEnum}; +use rand::RngCore; +use snafu::ResultExt; + +use crate::error::{self, Result}; + +#[derive(Debug, Parser)] +pub struct Command { + #[clap(subcommand)] + pub subcmd: SubCommand, +} + +#[derive(Debug, Subcommand)] +pub enum SubCommand { + /// Generate a password verifier for user_provider. + HashPassword(HashPasswordCommand), +} + +impl Command { + pub fn run(self) -> Result<()> { + match self.subcmd { + SubCommand::HashPassword(cmd) => cmd.run(), + } + } +} + +#[derive(Debug, Parser)] +#[clap(group( + ArgGroup::new("password-input") + .required(true) + .args(["password", "password_stdin"]) +))] +pub struct HashPasswordCommand { + /// Password verifier format to generate. + #[clap(long, value_enum, default_value = "pbkdf2_sha256")] + format: PasswordFormat, + + /// Plaintext password. Prefer --password-stdin to avoid shell history leaks. + #[clap(long)] + password: Option, + + /// Read the plaintext password from stdin. + #[clap(long)] + password_stdin: bool, + + /// PBKDF2-SHA256 iteration count. + #[clap(long, default_value_t = DEFAULT_PBKDF2_SHA256_ITERATIONS)] + iterations: u32, + + /// PBKDF2-SHA256 random salt length in bytes. + #[clap(long, default_value_t = 16)] + salt_len: usize, + + /// PBKDF2-SHA256 salt as hex. Mainly useful for deterministic automation. + #[clap(long)] + salt_hex: Option, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +#[clap(rename_all = "snake_case")] +enum PasswordFormat { + Pbkdf2Sha256, + MysqlNativePassword, +} + +impl HashPasswordCommand { + fn run(self) -> Result<()> { + let password = self.read_password()?; + let verifier = match self.format { + PasswordFormat::Pbkdf2Sha256 => { + let salt = self.pbkdf2_salt()?; + format_pbkdf2_sha256_password_verifier(password.as_bytes(), &salt, self.iterations) + .map_err(common_error::ext::BoxedError::new) + .context(error::OtherSnafu)? + } + PasswordFormat::MysqlNativePassword => { + format_mysql_native_password_verifier(password.as_bytes()) + } + }; + + println!("{verifier}"); + Ok(()) + } + + fn read_password(&self) -> Result { + let password = if let Some(password) = self.password.as_ref() { + password.clone() + } else { + let mut password = String::new(); + io::stdin() + .read_line(&mut password) + .context(error::FileIoSnafu)?; + password.trim_end_matches(['\r', '\n']).to_string() + }; + + // A blank password is rejected by the user provider before verifier + // comparison, so a verifier built from it would be unusable. Fail fast + // instead of emitting a dead verifier (e.g. on EOF or an empty line). + if password.is_empty() { + return error::IllegalConfigSnafu { + msg: "password must not be empty", + } + .fail(); + } + + Ok(password) + } + + fn pbkdf2_salt(&self) -> Result> { + if let Some(salt_hex) = self.salt_hex.as_ref() { + let salt = hex::decode(salt_hex).map_err(|err| { + error::IllegalConfigSnafu { + msg: format!("invalid --salt-hex: {err}"), + } + .build() + })?; + Self::ensure_salt_len(salt.len())?; + return Ok(salt); + } + + Self::ensure_salt_len(self.salt_len)?; + let mut salt = vec![0u8; self.salt_len]; + rand::rng().fill_bytes(&mut salt); + Ok(salt) + } + + fn ensure_salt_len(salt_len: usize) -> Result<()> { + if salt_len == 0 || salt_len > MAX_PBKDF2_SHA256_SALT_LEN { + return error::IllegalConfigSnafu { + msg: format!("salt length must be in 1..={}", MAX_PBKDF2_SHA256_SALT_LEN), + } + .fail(); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use auth::MAX_PBKDF2_SHA256_ITERATIONS; + + use super::*; + + #[test] + fn test_hash_password_command_with_pbkdf2_sha256() { + let cmd = HashPasswordCommand { + format: PasswordFormat::Pbkdf2Sha256, + password: Some("password".to_string()), + password_stdin: false, + iterations: 4096, + salt_len: 16, + salt_hex: Some("73616c74".to_string()), + }; + + let password = cmd.read_password().unwrap(); + let salt = cmd.pbkdf2_salt().unwrap(); + let verifier = + format_pbkdf2_sha256_password_verifier(password.as_bytes(), &salt, cmd.iterations) + .unwrap(); + + assert_eq!( + "pbkdf2_sha256:4096:73616c74:c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a", + verifier + ); + } + + #[test] + fn test_hash_password_command_with_mysql_native_password() { + let verifier = format_mysql_native_password_verifier("123456".as_bytes()); + + assert_eq!( + "mysql_native_password:6bb4837eb74329105ee4568dda7dc67ed2ca2ad9", + verifier + ); + } + + #[test] + fn test_reject_empty_salt() { + let cmd = HashPasswordCommand { + format: PasswordFormat::Pbkdf2Sha256, + password: Some("password".to_string()), + password_stdin: false, + iterations: 4096, + salt_len: 0, + salt_hex: None, + }; + + assert!(cmd.pbkdf2_salt().is_err()); + } + + #[test] + fn test_reject_empty_password() { + let cmd = HashPasswordCommand { + format: PasswordFormat::Pbkdf2Sha256, + password: Some(String::new()), + password_stdin: false, + iterations: 4096, + salt_len: 16, + salt_hex: Some("73616c74".to_string()), + }; + + assert!(cmd.read_password().is_err()); + } + + #[test] + fn test_reject_too_many_iterations() { + let result = format_pbkdf2_sha256_password_verifier( + b"password", + b"salt", + MAX_PBKDF2_SHA256_ITERATIONS + 1, + ); + + assert!(result.is_err()); + } +}