// Copyright 2024 RustFS 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. use crate::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ENV_RPC_SECRET, IAM_POLICY_CLAIM_NAME_SA, INHERITED_POLICY_TYPE}; use base64_simd::URL_SAFE_NO_PAD; use hmac::{Hmac, KeyInit, Mac}; use rand::{Rng, RngExt}; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::Sha256; use std::collections::HashMap; use std::env; use std::fmt; use std::io::Error; use std::sync::{LazyLock, OnceLock}; use time::OffsetDateTime; /// Global active credentials static GLOBAL_ACTIVE_CRED: OnceLock = OnceLock::new(); /// Global RPC authentication token static GLOBAL_RUSTFS_RPC_SECRET: OnceLock = OnceLock::new(); /// Public error returned when RPC authentication is not safely configured. pub const RPC_SECRET_REQUIRED_MESSAGE: &str = "RPC authentication secret is not configured"; /// Operator-facing guidance for configuring RPC authentication safely. pub const RPC_SECRET_REQUIRED_OPERATOR_MESSAGE: &str = "RUSTFS_RPC_SECRET can be set explicitly; otherwise the RPC secret is derived from the active access/secret key pair"; type HmacSha256 = Hmac; const RPC_SECRET_DERIVATION_CONTEXT: &[u8] = b"rustfs-rpc-secret:v1"; /// Error type for credentials operations #[derive(Debug)] pub enum CredentialsError { /// Credentials already initialized AlreadyInitialized, /// Failed to generate access key AccessKeyGenerationFailed(Error), /// Failed to generate secret key SecretKeyGenerationFailed(Error), } impl fmt::Display for CredentialsError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { CredentialsError::AlreadyInitialized => write!(f, "Credentials already initialized"), CredentialsError::AccessKeyGenerationFailed(e) => write!(f, "Failed to generate access key: {}", e), CredentialsError::SecretKeyGenerationFailed(e) => write!(f, "Failed to generate secret key: {}", e), } } } impl std::error::Error for CredentialsError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { CredentialsError::AccessKeyGenerationFailed(e) => Some(e), CredentialsError::SecretKeyGenerationFailed(e) => Some(e), _ => None, } } } /// Initialize the global action credentials /// /// # Arguments /// * `ak` - Optional access key /// * `sk` - Optional secret key /// /// # Returns /// * `Result<(), CredentialsError>` - Ok if successful, Err if already initialized or generation failed pub fn init_global_action_credentials(ak: Option, sk: Option) -> Result<(), CredentialsError> { let ak = match ak { Some(k) => k, None => gen_access_key(20).map_err(CredentialsError::AccessKeyGenerationFailed)?, }; let sk = match sk { Some(k) => k, None => gen_secret_key(32).map_err(CredentialsError::SecretKeyGenerationFailed)?, }; let cred = Credentials { access_key: ak, secret_key: sk, ..Default::default() }; GLOBAL_ACTIVE_CRED.set(cred).map_err(|_| CredentialsError::AlreadyInitialized) } /// Get the global action credentials pub fn get_global_action_cred() -> Option { GLOBAL_ACTIVE_CRED.get().cloned() } /// Get the global secret key /// /// # Returns /// * `Option` - The global secret key, if set /// pub fn get_global_secret_key_opt() -> Option { GLOBAL_ACTIVE_CRED.get().map(|cred| cred.secret_key.clone()) } /// Get the global secret key /// /// # Returns /// * `String` - The global secret key, or empty string if not set /// pub fn get_global_secret_key() -> String { GLOBAL_ACTIVE_CRED .get() .map(|cred| cred.secret_key.clone()) .unwrap_or_default() } /// Get the global access key /// /// # Returns /// * `Option` - The global access key, if set /// pub fn get_global_access_key_opt() -> Option { GLOBAL_ACTIVE_CRED.get().map(|cred| cred.access_key.clone()) } /// Get the global access key /// /// # Returns /// * `String` - The global access key, or empty string if not set /// pub fn get_global_access_key() -> String { GLOBAL_ACTIVE_CRED .get() .map(|cred| cred.access_key.clone()) .unwrap_or_default() } /// Generates a random access key of the specified length. /// /// # Arguments /// * `length` - The length of the access key to generate /// /// # Returns /// * `Result` - A result containing the generated access key or an error if the length is too short /// /// # Errors /// This function will return an error if the specified length is less than 3. /// /// Examples /// ```no_run /// use rustfs_credentials::gen_access_key; /// /// let access_key = gen_access_key(16).unwrap(); /// println!("Generated access key: {}", access_key); /// ``` /// pub fn gen_access_key(length: usize) -> std::io::Result { const ALPHA_NUMERIC_TABLE: [char; 36] = [ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', ]; if length < 3 { return Err(Error::other("access key length is too short")); } let mut result = String::with_capacity(length); let mut rng = rand::rng(); for _ in 0..length { result.push(ALPHA_NUMERIC_TABLE[rng.random_range(0..ALPHA_NUMERIC_TABLE.len())]); } Ok(result) } /// Generates a random secret key of the specified length. /// /// # Arguments /// * `length` - The length of the secret key to generate /// /// # Returns /// * `Result` - A result containing the generated secret key or an error if the length is too short /// /// # Errors /// This function will return an error if the specified length is less than 8. /// /// # Examples /// ```no_run /// use rustfs_credentials::gen_secret_key; /// /// let secret_key = gen_secret_key(32).unwrap(); /// println!("Generated secret key: {}", secret_key); /// ``` /// pub fn gen_secret_key(length: usize) -> std::io::Result { use base64_simd::URL_SAFE_NO_PAD; if length < 8 { return Err(Error::other("secret key length is too short")); } let mut rng = rand::rng(); let mut key = vec![0u8; URL_SAFE_NO_PAD.estimated_decoded_length(length)]; rng.fill_bytes(&mut key); // URL_SAFE_NO_PAD uses "-" and "_" instead of "+" and "/", so "/" never // appears in the output. The .replace("/", "+") was a dead no-op. let encoded = URL_SAFE_NO_PAD.encode_to_string(&key); Ok(encoded) } /// Get the RPC authentication token from the environment or derive it from the active credentials. /// /// # Returns /// * `String` - The RPC authentication token /// fn normalize_rpc_secret(secret: &str) -> Option { let secret = secret.trim(); (!secret.is_empty() && secret != DEFAULT_SECRET_KEY && secret != DEFAULT_ACCESS_KEY).then(|| secret.to_string()) } fn derive_rpc_secret(access_key: &str, secret_key: &str) -> Option { let access_key = access_key.trim(); let secret_key = secret_key.trim(); if access_key.is_empty() || secret_key.is_empty() { return None; } let Ok(mut mac) = ::new_from_slice(secret_key.as_bytes()) else { return None; }; mac.update(RPC_SECRET_DERIVATION_CONTEXT); mac.update(&[0]); mac.update(access_key.as_bytes()); Some(URL_SAFE_NO_PAD.encode_to_string(mac.finalize().into_bytes())) } fn resolve_rpc_secret(env_secret: Option<&str>, global_access: Option<&str>, global_secret: Option<&str>) -> Option { if let Some(secret) = env_secret.map(str::trim).filter(|secret| !secret.is_empty()) { return normalize_rpc_secret(secret); } match (global_access, global_secret) { (Some(access_key), Some(secret_key)) => { // Fail closed when either half of the active credential pair still // uses the public default. Operators must configure both custom // credentials or provide RUSTFS_RPC_SECRET explicitly. if access_key.trim() == DEFAULT_ACCESS_KEY || secret_key.trim() == DEFAULT_SECRET_KEY { return None; } derive_rpc_secret(access_key, secret_key) } _ => None, } } pub fn set_global_rpc_secret(secret: String) -> Result<(), String> { GLOBAL_RUSTFS_RPC_SECRET.set(secret) } pub fn try_get_rpc_token() -> std::io::Result { if let Some(secret) = GLOBAL_RUSTFS_RPC_SECRET.get() { return normalize_rpc_secret(secret).ok_or_else(|| Error::other(RPC_SECRET_REQUIRED_MESSAGE)); } let env_secret = env::var(ENV_RPC_SECRET).ok(); let global_access = get_global_access_key_opt(); let global_secret = get_global_secret_key_opt(); let secret = resolve_rpc_secret(env_secret.as_deref(), global_access.as_deref(), global_secret.as_deref()) .ok_or_else(|| Error::other(RPC_SECRET_REQUIRED_MESSAGE))?; match GLOBAL_RUSTFS_RPC_SECRET.set(secret.clone()) { Ok(()) => Ok(secret), Err(_) => GLOBAL_RUSTFS_RPC_SECRET .get() .and_then(|stored| normalize_rpc_secret(stored)) .ok_or_else(|| Error::other(RPC_SECRET_REQUIRED_MESSAGE)), } } #[deprecated(note = "use try_get_rpc_token to handle missing RPC secrets explicitly")] pub fn get_rpc_token() -> String { try_get_rpc_token().unwrap_or_default() } /// A wrapper struct for masking sensitive strings in Debug implementations. /// It avoids allocating a new String just for formatting. pub struct Masked<'a>(pub Option<&'a str>); impl<'a> fmt::Debug for Masked<'a> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self.0 { None => Ok(()), Some(s) => { let len = s.chars().count(); if len == 0 { Ok(()) } else if len == 1 { write!(f, "***") } else if len == 2 { let first = s.chars().next().ok_or(fmt::Error)?; write!(f, "{}***|{}", first, len) } else { let first = s.chars().next().ok_or(fmt::Error)?; let last = s.chars().last().ok_or(fmt::Error)?; write!(f, "{}***{}|{}", first, last, len) } } } } } impl<'a> fmt::Display for Masked<'a> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fmt::Debug::fmt(self, f) } } /// Credentials structure /// /// Fields: /// - access_key: Access key string /// - secret_key: Secret key string /// - session_token: Session token string /// - expiration: Optional expiration time as OffsetDateTime /// - status: Status string (e.g., "active", "off") /// - parent_user: Parent user string /// - groups: Optional list of groups /// - claims: Optional map of claims /// - name: Optional name string /// - description: Optional description string /// #[derive(Serialize, Deserialize, Clone, Default)] pub struct Credentials { #[serde(rename = "accessKey", alias = "access_key", default)] pub access_key: String, #[serde(rename = "secretKey", alias = "secret_key", default)] pub secret_key: String, #[serde(rename = "sessionToken", alias = "session_token", default)] pub session_token: String, #[serde(default, with = "crate::serde_datetime::option")] pub expiration: Option, #[serde(default)] pub status: String, #[serde(rename = "parentUser", alias = "parent_user", default)] pub parent_user: String, #[serde(default)] pub groups: Option>, #[serde(default)] pub claims: Option>, #[serde(default)] pub name: Option, #[serde(default)] pub description: Option, } impl fmt::Debug for Credentials { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Credentials") .field("access_key", &self.access_key) .field("secret_key", &Masked(Some(&self.secret_key))) .field("session_token", &Masked(Some(&self.session_token))) .field("expiration", &self.expiration) .field("status", &self.status) .field("parent_user", &self.parent_user) .field("groups", &self.groups) .field("claims", &self.claims) .field("name", &self.name) .field("description", &self.description) .finish() } } impl Credentials { /// Returns a reference to this credential's claims, or a shared empty map /// when the credential has no claims attached. Avoids per-call allocation /// at call sites that need an `&HashMap`. pub fn claims_or_empty(&self) -> &HashMap { static EMPTY: LazyLock> = LazyLock::new(HashMap::new); match &self.claims { Some(c) => c, None => &EMPTY, } } pub fn is_expired(&self) -> bool { if self.expiration.is_none() { return false; } self.expiration .as_ref() .map(|e| OffsetDateTime::now_utc() > *e) .unwrap_or(false) } pub fn is_temp(&self) -> bool { !self.session_token.is_empty() && !self.is_expired() } pub fn is_service_account(&self) -> bool { self.claims .as_ref() .map(|x| x.get(IAM_POLICY_CLAIM_NAME_SA).is_some_and(|_| !self.parent_user.is_empty())) .unwrap_or_default() } pub fn is_implied_policy(&self) -> bool { if self.is_service_account() { return self .claims .as_ref() .map(|x| x.get(IAM_POLICY_CLAIM_NAME_SA).is_some_and(|v| v == INHERITED_POLICY_TYPE)) .unwrap_or_default(); } false } pub fn is_valid(&self) -> bool { if self.status == "off" { return false; } self.access_key.len() >= 3 && self.secret_key.len() >= 8 && !self.is_expired() } pub fn is_owner(&self) -> bool { false } } #[cfg(test)] mod tests { use super::*; use crate::{DEFAULT_ACCESS_KEY, IAM_POLICY_CLAIM_NAME_SA, INHERITED_POLICY_TYPE}; use time::Duration; #[test] fn test_credentials_is_expired() { let mut cred = Credentials::default(); assert!(!cred.is_expired()); cred.expiration = Some(OffsetDateTime::now_utc() + Duration::hours(1)); assert!(!cred.is_expired()); cred.expiration = Some(OffsetDateTime::now_utc() - Duration::hours(1)); assert!(cred.is_expired()); } #[test] fn test_credentials_is_temp() { let mut cred = Credentials::default(); assert!(!cred.is_temp()); cred.session_token = "token".to_string(); assert!(cred.is_temp()); cred.expiration = Some(OffsetDateTime::now_utc() - Duration::hours(1)); assert!(!cred.is_temp()); } #[test] fn test_credentials_is_service_account() { let mut cred = Credentials::default(); assert!(!cred.is_service_account()); let mut claims = HashMap::new(); claims.insert(IAM_POLICY_CLAIM_NAME_SA.to_string(), Value::String("policy".to_string())); cred.claims = Some(claims); cred.parent_user = "parent".to_string(); assert!(cred.is_service_account()); } #[test] fn test_credentials_is_implied_policy() { let mut cred = Credentials::default(); assert!(!cred.is_implied_policy()); let mut claims = HashMap::new(); claims.insert(IAM_POLICY_CLAIM_NAME_SA.to_string(), Value::String(INHERITED_POLICY_TYPE.to_string())); cred.claims = Some(claims); cred.parent_user = "parent".to_string(); assert!(cred.is_implied_policy()); } #[test] fn test_credentials_is_valid() { let mut cred = Credentials::default(); assert!(!cred.is_valid()); cred.access_key = "abc".to_string(); cred.secret_key = "12345678".to_string(); assert!(cred.is_valid()); cred.status = "off".to_string(); assert!(!cred.is_valid()); } #[test] fn test_credentials_is_owner() { let cred = Credentials::default(); assert!(!cred.is_owner()); } #[test] fn test_global_credentials_flow() { // Since OnceLock can only be set once, we put together all globally related tests // If it has already been initialized (possibly from other tests), we verify the results directly if get_global_action_cred().is_none() { // Verify that the initial state is empty assert!(get_global_access_key_opt().is_none()); assert_eq!(get_global_access_key(), ""); assert!(get_global_secret_key_opt().is_none()); assert_eq!(get_global_secret_key(), ""); // Initialize let test_ak = "test_access_key".to_string(); let test_sk = "test_secret_key_123456".to_string(); init_global_action_credentials(Some(test_ak), Some(test_sk)).ok(); } // Verify the state after initialization let cred = get_global_action_cred().expect("Global credentials should be set"); assert!(!cred.access_key.is_empty()); assert!(!cred.secret_key.is_empty()); assert!(get_global_access_key_opt().is_some()); assert!(!get_global_access_key().is_empty()); assert!(get_global_secret_key_opt().is_some()); assert!(!get_global_secret_key().is_empty()); } #[test] fn test_init_global_credentials_auto_gen() { // If it hasn't already been initialized, the test automatically generates logic if get_global_action_cred().is_none() { init_global_action_credentials(None, None).ok(); } let ak = get_global_access_key(); let sk = get_global_secret_key(); assert!(ak.len() >= 3); assert!(sk.len() >= 8); } #[test] fn test_gen_secret_key_uses_url_safe_base64_without_padding() { let key = gen_secret_key(32).expect("secret key should generate"); assert_eq!(key.len(), 32); assert!(!key.contains('/')); assert!(!key.contains('+')); assert!(!key.contains('=')); } #[test] fn test_gen_access_key_length_and_charset() { let err = gen_access_key(2).expect_err("length below 3 should fail"); assert_eq!(err.to_string(), "access key length is too short"); let key = gen_access_key(20).expect("access key should generate"); assert_eq!(key.len(), 20); assert!(key.chars().all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit())); } #[test] fn test_resolve_rpc_secret_rejects_default_credentials_for_derivation() { assert!(resolve_rpc_secret(None, None, None).is_none()); // Fail closed when either half of the credential pair uses the public // default. assert!(resolve_rpc_secret(None, Some(DEFAULT_ACCESS_KEY), Some(DEFAULT_SECRET_KEY)).is_none()); assert!(resolve_rpc_secret(None, Some(DEFAULT_ACCESS_KEY), Some("custom-global-secret")).is_none()); assert!(resolve_rpc_secret(None, Some("custom-access"), Some(DEFAULT_SECRET_KEY)).is_none()); assert!(resolve_rpc_secret(Some(DEFAULT_SECRET_KEY), Some("custom-access"), Some("custom-global-secret")).is_none()); } #[test] fn test_rpc_secret_public_error_omits_configuration_details() { assert!(!RPC_SECRET_REQUIRED_MESSAGE.contains("RUSTFS_")); assert!(!RPC_SECRET_REQUIRED_MESSAGE.contains(DEFAULT_SECRET_KEY)); assert!(RPC_SECRET_REQUIRED_OPERATOR_MESSAGE.contains("RUSTFS_RPC_SECRET")); } #[allow(deprecated)] #[test] fn test_get_rpc_token_preserves_string_return_type() { fn assert_string_return(_: fn() -> String) {} assert_string_return(get_rpc_token); } #[allow(deprecated)] #[test] fn test_get_rpc_token_matches_fallible_api_contract() { match try_get_rpc_token() { Ok(secret) => assert_eq!(get_rpc_token(), secret), Err(_) => assert_eq!(get_rpc_token(), ""), } } #[test] fn test_resolve_rpc_secret_accepts_non_default_secret() { assert_eq!( resolve_rpc_secret(Some("custom-rpc-secret"), None, None).as_deref(), Some("custom-rpc-secret") ); assert_eq!( resolve_rpc_secret(Some("custom-rpc-secret"), Some(DEFAULT_ACCESS_KEY), Some(DEFAULT_SECRET_KEY)).as_deref(), Some("custom-rpc-secret") ); let expected = derive_rpc_secret("custom-access", "custom-global-secret").expect("secret should derive"); assert_eq!( resolve_rpc_secret(None, Some("custom-access"), Some("custom-global-secret")).as_deref(), Some(expected.as_str()) ); } #[test] fn test_resolve_rpc_secret_trims_and_falls_back_from_blank_env() { let expected = derive_rpc_secret("custom-access", "custom-global-secret").expect("secret should derive"); assert_eq!( resolve_rpc_secret(Some(" custom-rpc-secret "), None, None).as_deref(), Some("custom-rpc-secret") ); assert_eq!( resolve_rpc_secret(Some(""), Some("custom-access"), Some("custom-global-secret")).as_deref(), Some(expected.as_str()) ); assert_eq!( resolve_rpc_secret(Some(" "), Some(" custom-access "), Some(" custom-global-secret ")).as_deref(), Some(expected.as_str()) ); assert_eq!( resolve_rpc_secret(Some(" "), Some("custom-access"), Some("custom-global-secret")).as_deref(), Some(expected.as_str()) ); } #[test] fn test_resolve_rpc_secret_returns_none_for_trimmed_default_secret() { let padded_default_secret = format!(" {} ", DEFAULT_SECRET_KEY); assert!( resolve_rpc_secret(Some(padded_default_secret.as_str()), Some("custom-access"), Some("custom-global-secret")) .is_none() ); let padded_default_access = format!(" {} ", DEFAULT_ACCESS_KEY); assert!( resolve_rpc_secret(Some(padded_default_access.as_str()), Some("custom-access"), Some("custom-global-secret")) .is_none() ); } #[test] fn test_derive_rpc_secret_is_stable_and_not_plaintext() { let first = derive_rpc_secret("custom-access", "custom-secret").expect("secret should derive"); let second = derive_rpc_secret("custom-access", "custom-secret").expect("secret should derive"); assert_eq!(first, second); assert_ne!(first, "custom-access"); assert_ne!(first, "custom-secret"); assert_ne!(first, format!("{}{}", "custom-access", "custom-secret")); assert!(!first.contains("custom-access")); assert!(!first.contains("custom-secret")); } #[test] fn test_masked_debug() { // Test None assert_eq!(format!("{:?}", Masked(None)), ""); // Test empty string assert_eq!(format!("{:?}", Masked(Some(""))), ""); // Test length 1 assert_eq!(format!("{:?}", Masked(Some("a"))), "***"); // Test length 2 assert_eq!(format!("{:?}", Masked(Some("ab"))), "a***|2"); // Test length 3 assert_eq!(format!("{:?}", Masked(Some("abc"))), "a***c|3"); // Test length 4 assert_eq!(format!("{:?}", Masked(Some("abcd"))), "a***d|4"); // Test longer string assert_eq!(format!("{:?}", Masked(Some("secretpassword"))), "s***d|14"); // Test Unicode input should not panic and should keep character boundary assert_eq!(format!("{:?}", Masked(Some("中"))), "***"); assert_eq!(format!("{:?}", Masked(Some("中文"))), "中***|2"); assert_eq!(format!("{:?}", Masked(Some("中文测试"))), "中***试|4"); } #[test] fn test_credentials_debug_masks_sensitive_fields() { let cred = Credentials { access_key: "debug-access-key".to_string(), secret_key: "debug-secret-key".to_string(), session_token: "debug-session-token".to_string(), parent_user: "parent-user".to_string(), ..Default::default() }; let output = format!("{cred:?}"); assert!(output.contains("debug-access-key")); assert!(output.contains("parent-user")); assert!(!output.contains("debug-secret-key")); assert!(!output.contains("debug-session-token")); } #[test] fn test_credentials_expiration_serialize_as_rfc3339() { use time::OffsetDateTime; let c = Credentials { access_key: "ak".to_string(), secret_key: "sk12345678".to_string(), expiration: Some(OffsetDateTime::now_utc()), ..Default::default() }; let json = serde_json::to_string(&c).expect("serialize"); assert!( json.contains('T') && (json.contains('Z') || json.contains("+00:00")), "Credentials expiration should be RFC3339; got: {}", json ); } #[test] fn test_credentials_deserialize_minio_style_rfc3339_expiration() { let minio_style = r#"{"accessKey":"ak","secretKey":"sk12345678","expiration":"2025-03-07T12:00:00Z"}"#; let c: Credentials = serde_json::from_str(minio_style).expect("deserialize"); assert_eq!(c.access_key, "ak"); assert!(c.expiration.is_some()); } }