From 855586255fa1859cc6711b33bbbc22c8547523f4 Mon Sep 17 00:00:00 2001 From: cxymds Date: Thu, 8 Oct 2026 17:37:15 +0800 Subject: [PATCH] feat(auth): allow custom secret keys beyond 40 bytes (#8419) Reuse the existing UTF-8 byte minimum for credential creation so long service account secrets remain intact. Keep generated secrets at 40 bytes and document older receiver and import constraints. --- crates/iam/src/sys.rs | 53 +++++++++++++++++++++++++++ crates/policy/src/auth/credentials.rs | 52 +++++++++++++++++++++++++- docs/operations/two-factor-auth.md | 8 +++- 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/crates/iam/src/sys.rs b/crates/iam/src/sys.rs index 82014deee..7830f9ea4 100644 --- a/crates/iam/src/sys.rs +++ b/crates/iam/src/sys.rs @@ -2888,6 +2888,59 @@ mod tests { } } + #[tokio::test] + async fn service_account_preserves_long_secret_keys_after_rotation_and_reload() { + ensure_test_global_credentials(); + let iam_sys = test_iam_sys().await; + + for length in [41, 90, 128, 256, 257, 4096] { + let access_key = format!("long-secret-{length}"); + let secret_key = format!("{}a", "s".repeat(length - 1)); + let (created, _) = iam_sys + .new_service_account("parent-user", None, service_account_opts(&access_key, &secret_key)) + .await + .expect("create service account with a long secret"); + assert_eq!(created.secret_key, secret_key); + get_claims_from_token_with_secret_allow_missing_exp(&created.session_token, &secret_key) + .expect("the complete secret verifies the created session token"); + + let rotated_secret = format!("{}b", "s".repeat(length - 1)); + iam_sys + .update_service_account( + &access_key, + UpdateServiceAccountOpts { + secret_key: Some(rotated_secret.clone()), + session_policy: None, + status: None, + name: None, + description: None, + expiration: None, + parent_user: None, + allow_site_replicator_account: false, + }, + ) + .await + .expect("rotate the complete long secret"); + + let persisted = iam_sys + .store + .api + .load_user_identity(&access_key, UserType::Svc) + .await + .expect("load the persisted service account identity"); + assert_eq!(persisted.credentials.secret_key, rotated_secret); + get_claims_from_token_with_secret_allow_missing_exp(&persisted.credentials.session_token, &rotated_secret) + .expect("the rotated secret verifies the persisted session token"); + assert!( + get_claims_from_token_with_secret_allow_missing_exp(&persisted.credentials.session_token, &secret_key).is_err() + ); + assert!( + get_claims_from_token_with_secret_allow_missing_exp(&persisted.credentials.session_token, &rotated_secret[..40]) + .is_err() + ); + } + } + #[tokio::test] async fn new_service_account_rejects_parent_access_key() { ensure_test_global_credentials(); diff --git a/crates/policy/src/auth/credentials.rs b/crates/policy/src/auth/credentials.rs index 93773aca1..bd3965db7 100644 --- a/crates/policy/src/auth/credentials.rs +++ b/crates/policy/src/auth/credentials.rs @@ -26,7 +26,6 @@ use tracing::debug; const ACCESS_KEY_MIN_LEN: usize = 3; const ACCESS_KEY_MAX_LEN: usize = 128; const SECRET_KEY_MIN_LEN: usize = 8; -const SECRET_KEY_MAX_LEN: usize = 40; pub const ACCOUNT_ON: &str = "on"; pub const ACCOUNT_OFF: &str = "off"; @@ -122,7 +121,7 @@ pub fn create_new_credentials_with_metadata( return Err(Error::InvalidAccessKeyLength); } - if sk.len() < SECRET_KEY_MIN_LEN || sk.len() > SECRET_KEY_MAX_LEN { + if !is_secret_key_valid(sk) { return Err(Error::InvalidSecretKeyLength); } @@ -450,4 +449,53 @@ mod reserved_chars_tests { assert!(matches!(err, crate::error::Error::InvalidSecretKeyLength)); } + + #[test] + fn credential_creation_preserves_custom_secret_keys() { + for length in [8, 40, 41, 90, 128, 256, 257, 4096] { + let secret_key = "s".repeat(length); + let cred = create_new_credentials_with_metadata("custom-access-key", &secret_key, &HashMap::new(), "") + .expect("custom secret key should be accepted"); + assert_eq!(cred.secret_key, secret_key, "the full {length}-byte secret must be preserved"); + } + } + + #[test] + fn credential_creation_measures_the_minimum_in_utf8_bytes() { + let cred = create_new_credentials_with_metadata("custom-access-key", "éééé", &HashMap::new(), "") + .expect("eight UTF-8 bytes should be accepted"); + assert_eq!(cred.secret_key, "éééé"); + + for secret_key in ["1234567", "ééé"] { + let err = create_new_credentials_with_metadata("custom-access-key", secret_key, &HashMap::new(), "") + .expect_err("fewer than eight UTF-8 bytes must be rejected"); + assert!(matches!(err, crate::error::Error::InvalidSecretKeyLength)); + } + } + + #[test] + fn credential_creation_signs_with_the_entire_custom_secret() { + let mut claims = HashMap::new(); + claims.insert( + "exp".to_owned(), + serde_json::json!((time::OffsetDateTime::now_utc() + time::Duration::hours(1)).unix_timestamp()), + ); + for length in [90, 256, 4096] { + let secret_key = format!("{}a", "s".repeat(length - 1)); + let cred = create_new_credentials_with_metadata("custom-access-key", &secret_key, &claims, &secret_key) + .expect("long secret key should sign a session token"); + crate::utils::extract_claims::(&cred.session_token, &secret_key) + .expect("the original secret should verify the session token"); + let changed_tail = format!("{}b", "s".repeat(length - 1)); + assert!(crate::utils::extract_claims::(&cred.session_token, &changed_tail).is_err()); + assert!(crate::utils::extract_claims::(&cred.session_token, &secret_key[..40]).is_err()); + } + } + + #[test] + fn credential_generation_keeps_the_default_lengths() { + let (access_key, secret_key) = super::generate_credentials().expect("generate default credentials"); + assert_eq!(access_key.len(), 20); + assert_eq!(secret_key.len(), 40); + } } diff --git a/docs/operations/two-factor-auth.md b/docs/operations/two-factor-auth.md index decd6115d..c9653243c 100644 --- a/docs/operations/two-factor-auth.md +++ b/docs/operations/two-factor-auth.md @@ -45,10 +45,16 @@ RustFS is an S3 server. SigV4 requires the server to know the secret key itself | --- | --- | | At rest | `RUSTFS_IAM_MASTER_KEY` + `encrypt_stream_io` (Argon2id -> AES-GCM / ChaCha20-Poly1305) | | Length floor | `is_secret_key_valid` (`SECRET_KEY_MIN_LEN`) | -| Maximum length | None, deliberately; capping password length is an anti-pattern. | +| Maximum length | No credential-specific maximum; request and import resource limits still apply. | | Rotation | `POST /v3/account/password`, requiring the current secret | | Session cleanup | Every STS session minted from the identity is revoked on rotation | +Custom IAM user and service-account secret keys require at least eight UTF-8 bytes. The complete value is used for signing; it is not truncated, pre-hashed, or Unicode-normalized. Automatically generated service-account secret keys remain 40 bytes long. + +Before using service-account secret keys longer than 40 bytes, upgrade the site-replication receivers that may need to create those accounts. A receiver that still enforces the old creation limit can reject a new account even when the source created it successfully. Loading an existing identity and updating an existing service account use different paths and do not imply that account creation is supported. + +Do not import IAM snapshots containing these long service-account secrets into a version that still enforces the 40-byte creation limit. Such versions can delete an existing same-name account before rejecting its replacement. Keep snapshots suitable for the target version when planning a rollback. + ## At-rest protection is mandatory for TOTP secrets A TOTP secret is credential-equivalent: anyone holding it can mint valid codes forever. Enrollment is therefore refused when `RUSTFS_IAM_MASTER_KEY` is not configured, rather than writing the secret in plaintext: