diff --git a/Cargo.lock b/Cargo.lock index 0eae0e405..f6a207c03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3872,10 +3872,12 @@ dependencies = [ "bytes", "chrono", "clap", + "data-encoding", "flatbuffers", "flate2", "futures", "hex", + "hmac 0.13.0", "hotpath", "http 1.5.0", "http-body-util", @@ -3906,6 +3908,8 @@ dependencies = [ "s3s", "serde", "serde_json", + "serde_urlencoded", + "sha1 0.11.0", "sha2 0.11.0", "suppaftp", "time", @@ -8320,6 +8324,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quad-rand" version = "0.2.3" @@ -9681,13 +9691,17 @@ dependencies = [ "arc-swap", "async-trait", "base64-simd", + "data-encoding", "futures", + "hmac 0.13.0", "hotpath", "http 1.5.0", "jsonwebtoken 11.0.0", "moka", "openidconnect", "pollster", + "qrcode", + "rand 0.10.2", "rcgen", "reqwest", "rustfs-config", @@ -9705,6 +9719,9 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha1 0.11.0", + "sha2 0.11.0", + "subtle", "temp-env", "tempfile", "thiserror 2.0.20", diff --git a/Cargo.toml b/Cargo.toml index b16720722..cb50766f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -257,6 +257,9 @@ glob = "0.3.4" google-cloud-storage = "1.17.0" google-cloud-auth = "1.15.0" hashbrown = { version = "0.17.1" } +# Base32 for RFC 6238 TOTP shared secrets (RFC 4648 unpadded, the alphabet +# every authenticator app expects). Already in the graph transitively. +data-encoding = "2.11.1" hex = "0.4.3" hex-simd = "0.8.0" highway = { version = "1.3.0" } @@ -279,6 +282,10 @@ nvml-wrapper = "0.12.1" parking_lot = "0.12.5" path-absolutize = "4.0.1" percent-encoding = "2.3.2" +# Server-side QR rendering for TOTP enrollment, so neither the console nor the +# CLI needs its own QR encoder. No default features: the image/render backends +# pull in an image stack this only needs SVG and text output from. +qrcode = { version = "0.14.1", default-features = false, features = ["svg"] } pin-project-lite = "0.2.17" pretty_assertions = "1.4.1" rand = { version = "0.10.2" } diff --git a/crates/e2e_test/Cargo.toml b/crates/e2e_test/Cargo.toml index 302cd34d3..81184ad52 100644 --- a/crates/e2e_test/Cargo.toml +++ b/crates/e2e_test/Cargo.toml @@ -109,6 +109,13 @@ hyper = { workspace = true, features = ["http2", "http1", "server"] } hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] } reqwest = { workspace = true, features = ["json", "multipart", "stream"] } rustfs-signer.workspace = true + +# The MFA e2e test computes RFC 6238 codes itself rather than calling the +# server's implementation: a shared helper could agree with a bug on both sides. +data-encoding = { workspace = true } +hmac = { workspace = true } +sha1 = { workspace = true } +serde_urlencoded = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "time"] } uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] } diff --git a/crates/e2e_test/src/admin_mfa_test.rs b/crates/e2e_test/src/admin_mfa_test.rs new file mode 100644 index 000000000..dc84a3484 --- /dev/null +++ b/crates/e2e_test/src/admin_mfa_test.rs @@ -0,0 +1,449 @@ +// 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. + +//! End-to-end coverage for the self-service account and two-factor surface. +//! +//! The unit tests cover the state machine at its edges; what only an end-to-end +//! test can prove is that the pieces are wired together and that the *existing* +//! authentication paths still behave. Specifically: +//! +//! 1. Enrollment is refused when `RUSTFS_IAM_MASTER_KEY` is absent, so a TOTP +//! secret is never written where an attacker could read it off a disk. +//! 2. With a master key, the full flow works: enroll, activate with a real +//! RFC 6238 code, and receive single-use recovery codes. +//! 3. Once a factor is enrolled, `AssumeRole` refuses to mint a session without +//! one, and accepts a valid code — the actual login gate. +//! 4. A direct SigV4 admin request keeps working with a factor enrolled. This is +//! the regression that matters most: gating it would break every script and +//! CLI the moment somebody enabled 2FA. +//! 5. `AssumeRole` for an identity with no enrollment is byte-for-byte the old +//! behaviour, so existing deployments are untouched. +//! 6. Rotating a password through `/account/password` invalidates the sessions +//! minted under the old secret. + +#[cfg(test)] +mod tests { + use crate::common::{RustFSTestEnvironment, init_logging, local_http_client}; + use hmac::{Hmac, KeyInit as _, Mac}; + use http::header::HOST; + use rustfs_signer::constants::UNSIGNED_PAYLOAD; + use rustfs_signer::sign_v4; + use s3s::Body; + use sha1::Sha1; + use std::error::Error; + use std::time::{SystemTime, UNIX_EPOCH}; + + const ACCOUNT_INFO_PATH: &str = "/rustfs/admin/v3/account/info"; + const ACCOUNT_PASSWORD_PATH: &str = "/rustfs/admin/v3/account/password"; + const ACCOUNT_MFA_PATH: &str = "/rustfs/admin/v3/account/mfa"; + const ACCOUNT_MFA_ENROLL_PATH: &str = "/rustfs/admin/v3/account/mfa/enroll"; + const ACCOUNT_MFA_ACTIVATE_PATH: &str = "/rustfs/admin/v3/account/mfa/activate"; + const MFA_CHALLENGE_PATH: &str = "/rustfs/admin/v3/mfa/challenge"; + const ADMIN_INFO_PATH: &str = "/rustfs/admin/v3/info"; + + /// A master key so the server will accept an enrollment. Test-only value. + const TEST_MASTER_KEY: &str = "e2e-mfa-master-key-do-not-reuse"; + + type HmacSha1 = Hmac; + + /// One signed admin request, returning the status and the raw body. + /// + /// Signs with `UNSIGNED_PAYLOAD` so the body does not participate in the + /// hash, matching how the other admin e2e tests drive these routes. + async fn signed_request( + base_url: &str, + method: http::Method, + path: &str, + body: Option<&str>, + access_key: &str, + secret_key: &str, + ) -> Result<(reqwest::StatusCode, String), Box> { + let url = format!("{base_url}{path}"); + let uri = url.parse::()?; + let authority = uri.authority().ok_or("missing authority")?.to_string(); + let body_bytes = body.map(|b| b.as_bytes().to_vec()).unwrap_or_default(); + + let request = http::Request::builder() + .method(method.clone()) + .uri(uri) + .header(HOST, authority) + .header("x-amz-content-sha256", UNSIGNED_PAYLOAD); + let signed = sign_v4(request.body(Body::empty())?, 0, access_key, secret_key, "", "us-east-1"); + + let client = local_http_client(); + let mut builder = client.request(method, url.as_str()); + for (name, value) in signed.headers() { + builder = builder.header(name, value); + } + if !body_bytes.is_empty() { + builder = builder.body(body_bytes); + } + let response = builder.send().await?; + let status = response.status(); + let text = response.text().await?; + Ok((status, text)) + } + + /// A SigV4-signed `AssumeRole` form POST, optionally carrying a second factor. + /// + /// Uses STS's own `SerialNumber`/`TokenCode` fields, which is the point: a + /// script or SDK can present the factor without a RustFS-specific protocol. + async fn assume_role( + base_url: &str, + access_key: &str, + secret_key: &str, + second_factor: Option<(&str, &str)>, + ) -> Result<(reqwest::StatusCode, String), Box> { + let mut form = vec![ + ("Action", "AssumeRole".to_string()), + ("Version", "2011-06-15".to_string()), + ("RoleArn", "arn:aws:iam::*:role/Admin".to_string()), + ("RoleSessionName", "e2e".to_string()), + ("DurationSeconds", "3600".to_string()), + ]; + if let Some((challenge, code)) = second_factor { + form.push(("SerialNumber", challenge.to_string())); + form.push(("TokenCode", code.to_string())); + } + let body = serde_urlencoded::to_string(&form)?; + + let uri = base_url.parse::()?; + let authority = uri.authority().ok_or("missing authority")?.to_string(); + let request = http::Request::builder() + .method(http::Method::POST) + .uri(format!("{base_url}/")) + .header(HOST, authority) + .header("content-type", "application/x-www-form-urlencoded") + .header("x-amz-content-sha256", UNSIGNED_PAYLOAD); + let signed = sign_v4(request.body(Body::empty())?, 0, access_key, secret_key, "", "us-east-1"); + + let client = local_http_client(); + let mut builder = client.request(http::Method::POST, format!("{base_url}/")); + for (name, value) in signed.headers() { + builder = builder.header(name, value); + } + let response = builder.body(body).send().await?; + let status = response.status(); + let text = response.text().await?; + Ok((status, text)) + } + + /// Generate the current RFC 6238 code for a base32 secret. + /// + /// Computed independently of the server implementation: a shared helper + /// could agree with a bug on both sides. + fn totp_now(secret_base32: &str) -> String { + let secret = data_encoding::BASE32_NOPAD + .decode(secret_base32.as_bytes()) + .expect("server must return unpadded base32"); + let step = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after the epoch") + .as_secs() + / 30; + + let mut mac = HmacSha1::new_from_slice(&secret).expect("HMAC accepts any key length"); + mac.update(&step.to_be_bytes()); + let digest = mac.finalize().into_bytes(); + + let offset = (digest[digest.len() - 1] & 0x0f) as usize; + let binary = u32::from_be_bytes([ + digest[offset] & 0x7f, + digest[offset + 1], + digest[offset + 2], + digest[offset + 3], + ]); + format!("{:06}", binary % 1_000_000) + } + + fn json(body: &str) -> serde_json::Value { + serde_json::from_str(body).unwrap_or_else(|error| panic!("expected JSON, got {body}: {error}")) + } + + #[tokio::test] + async fn enrollment_is_refused_without_at_rest_protection() -> Result<(), Box> { + init_logging(); + let mut env = RustFSTestEnvironment::new().await?; + // Deliberately no RUSTFS_IAM_MASTER_KEY. + env.start_rustfs_server(vec![]).await?; + + let (access_key, secret_key) = (env.access_key.clone(), env.secret_key.clone()); + + // The account surface itself works. + let (status, body) = + signed_request(&env.url, http::Method::GET, ACCOUNT_INFO_PATH, None, &access_key, &secret_key).await?; + assert_eq!(status, reqwest::StatusCode::OK, "account info must be reachable, body: {body}"); + let info = json(&body); + assert_eq!(info["access_key"], access_key.as_str()); + assert_eq!(info["identity_type"], "root"); + assert_eq!(info["credentials_source"], "env"); + // Root credentials come from a process-wide OnceLock that also derives + // the internode RPC secret, so they are immutable at runtime. + assert_eq!(info["mutable"]["password"], false); + + // Status reports the refusal rather than pretending enrollment is possible. + let (status, body) = + signed_request(&env.url, http::Method::GET, ACCOUNT_MFA_PATH, None, &access_key, &secret_key).await?; + assert_eq!(status, reqwest::StatusCode::OK, "mfa status must be reachable, body: {body}"); + let mfa = json(&body); + assert_eq!(mfa["enabled"], false); + assert_eq!(mfa["enrollment_available"], false); + assert!( + mfa["enrollment_blocked_reason"] + .as_str() + .is_some_and(|reason| reason.contains("RUSTFS_IAM_MASTER_KEY")), + "the refusal must name the variable an operator has to set, body: {body}" + ); + + // And enrolling actually fails, rather than writing a plaintext secret. + let (status, body) = signed_request( + &env.url, + http::Method::POST, + ACCOUNT_MFA_ENROLL_PATH, + Some("{}"), + &access_key, + &secret_key, + ) + .await?; + assert!( + status.is_client_error() || status.is_server_error(), + "enrollment must fail without a master key, status: {status}, body: {body}" + ); + assert!( + body.contains("RUSTFS_IAM_MASTER_KEY"), + "the failure must explain the remedy, body: {body}" + ); + + env.stop_server(); + Ok(()) + } + + #[tokio::test] + async fn assume_role_is_unchanged_for_an_identity_with_no_second_factor() -> Result<(), Box> { + // The regression that protects every existing deployment: an identity + // with no enrollment must take no new code path. + init_logging(); + let mut env = RustFSTestEnvironment::new().await?; + env.start_rustfs_server_with_env(vec![], &[("RUSTFS_IAM_MASTER_KEY", TEST_MASTER_KEY)]) + .await?; + + let (access_key, secret_key) = (env.access_key.clone(), env.secret_key.clone()); + + let (status, body) = + signed_request(&env.url, http::Method::GET, MFA_CHALLENGE_PATH, None, &access_key, &secret_key).await?; + assert_eq!(status, reqwest::StatusCode::OK, "challenge must be reachable, body: {body}"); + let challenge = json(&body); + assert_eq!(challenge["required"], false, "no enrollment means no challenge"); + assert!(challenge["challenge"].is_null()); + + let (status, body) = assume_role(&env.url, &access_key, &secret_key, None).await?; + assert_eq!(status, reqwest::StatusCode::OK, "AssumeRole must still work, body: {body}"); + assert!(body.contains(""), "expected STS credentials, body: {body}"); + + env.stop_server(); + Ok(()) + } + + #[tokio::test] + async fn the_full_second_factor_lifecycle_gates_only_session_minting() -> Result<(), Box> { + init_logging(); + let mut env = RustFSTestEnvironment::new().await?; + env.start_rustfs_server_with_env(vec![], &[("RUSTFS_IAM_MASTER_KEY", TEST_MASTER_KEY)]) + .await?; + + let (access_key, secret_key) = (env.access_key.clone(), env.secret_key.clone()); + + // --- Enroll --- + let (status, body) = signed_request( + &env.url, + http::Method::POST, + ACCOUNT_MFA_ENROLL_PATH, + Some("{}"), + &access_key, + &secret_key, + ) + .await?; + assert_eq!(status, reqwest::StatusCode::OK, "enrollment must succeed, body: {body}"); + let enrollment = json(&body); + let secret_base32 = enrollment["secret_base32"].as_str().expect("secret").to_string(); + assert!( + enrollment["otpauth_uri"] + .as_str() + .is_some_and(|uri| uri.starts_with("otpauth://totp/RustFS:")), + "body: {body}" + ); + assert!(!enrollment["qr_svg"].as_str().unwrap_or_default().is_empty(), "expected an SVG"); + assert!(!enrollment["qr_utf8"].as_str().unwrap_or_default().is_empty(), "expected block art"); + + // A pending enrollment must not gate anything yet: a mis-scanned QR + // cannot be allowed to lock the operator out. + let (status, body) = + signed_request(&env.url, http::Method::GET, MFA_CHALLENGE_PATH, None, &access_key, &secret_key).await?; + assert_eq!(status, reqwest::StatusCode::OK); + assert_eq!(json(&body)["required"], false, "a pending enrollment must not gate login"); + + // --- Activate --- + let code = totp_now(&secret_base32); + let (status, body) = signed_request( + &env.url, + http::Method::POST, + ACCOUNT_MFA_ACTIVATE_PATH, + Some(&format!(r#"{{"code":"{code}"}}"#)), + &access_key, + &secret_key, + ) + .await?; + assert_eq!(status, reqwest::StatusCode::OK, "activation must succeed, body: {body}"); + let activated = json(&body); + let recovery_codes = activated["recovery_codes"].as_array().expect("recovery codes").clone(); + assert_eq!(recovery_codes.len(), 10, "expected a full recovery set, body: {body}"); + + // --- The gate is now on for session minting --- + let (status, body) = + signed_request(&env.url, http::Method::GET, MFA_CHALLENGE_PATH, None, &access_key, &secret_key).await?; + assert_eq!(status, reqwest::StatusCode::OK); + let challenge_body = json(&body); + assert_eq!(challenge_body["required"], true, "body: {body}"); + let challenge = challenge_body["challenge"].as_str().expect("challenge").to_string(); + + let (status, body) = assume_role(&env.url, &access_key, &secret_key, None).await?; + assert!(status.is_client_error(), "AssumeRole must refuse without a factor, body: {body}"); + assert!( + body.contains("MultiFactorAuthRequired"), + "clients match on this code to prompt instead of reporting a failed login, body: {body}" + ); + + // --- ... but direct SigV4 access is untouched --- + let (status, body) = signed_request(&env.url, http::Method::GET, ADMIN_INFO_PATH, None, &access_key, &secret_key).await?; + assert_eq!( + status, + reqwest::StatusCode::OK, + "a direct admin request must keep working with a factor enrolled, body: {body}" + ); + + // --- A valid factor mints the session --- + // A fresh code: activation consumed the previous time step, so reusing + // that code would be refused as a replay. + let code = wait_for_a_fresh_code(&secret_base32).await; + let (status, body) = assume_role(&env.url, &access_key, &secret_key, Some((&challenge, &code))).await?; + assert_eq!(status, reqwest::StatusCode::OK, "a valid factor must mint a session, body: {body}"); + assert!(body.contains(""), "expected STS credentials, body: {body}"); + + // --- A recovery code also works, once --- + let recovery_code = recovery_codes[0].as_str().expect("recovery code").to_string(); + let (status, body) = assume_role(&env.url, &access_key, &secret_key, Some((&challenge, &recovery_code))).await?; + assert_eq!(status, reqwest::StatusCode::OK, "a recovery code must mint a session, body: {body}"); + + let (status, body) = assume_role(&env.url, &access_key, &secret_key, Some((&challenge, &recovery_code))).await?; + assert!( + status.is_client_error(), + "a spent recovery code must not work twice, status: {status}, body: {body}" + ); + + env.stop_server(); + Ok(()) + } + + #[tokio::test] + async fn an_iam_user_can_rotate_its_own_password_and_lose_its_sessions() -> Result<(), Box> { + init_logging(); + let mut env = RustFSTestEnvironment::new().await?; + env.start_rustfs_server_with_env(vec![], &[("RUSTFS_IAM_MASTER_KEY", TEST_MASTER_KEY)]) + .await?; + + let (root_ak, root_sk) = (env.access_key.clone(), env.secret_key.clone()); + let user_ak = "mfarotationuser"; + let old_sk = "mfarotationsecret"; + let new_sk = "mfarotationsecret2"; + + // Root creates the user. + let (status, body) = signed_request( + &env.url, + http::Method::PUT, + &format!("/rustfs/admin/v3/add-user?accessKey={user_ak}"), + Some(&format!(r#"{{"secretKey":"{old_sk}","status":"enabled"}}"#)), + &root_ak, + &root_sk, + ) + .await?; + assert_eq!(status, reqwest::StatusCode::OK, "user creation must succeed, body: {body}"); + + // The user sees itself as mutable, unlike root. + let (status, body) = signed_request(&env.url, http::Method::GET, ACCOUNT_INFO_PATH, None, user_ak, old_sk).await?; + assert_eq!(status, reqwest::StatusCode::OK, "body: {body}"); + let info = json(&body); + assert_eq!(info["identity_type"], "iam"); + assert_eq!(info["credentials_source"], "iam"); + assert_eq!(info["mutable"]["password"], true); + + // The wrong current secret is refused, so a live session alone cannot + // rewrite the credential. + let (status, body) = signed_request( + &env.url, + http::Method::POST, + ACCOUNT_PASSWORD_PATH, + Some(&format!(r#"{{"current_secret_key":"wrong-secret","new_secret_key":"{new_sk}"}}"#)), + user_ak, + old_sk, + ) + .await?; + assert!(status.is_client_error(), "a wrong current secret must be refused, body: {body}"); + + // The correct one rotates it. + let (status, body) = signed_request( + &env.url, + http::Method::POST, + ACCOUNT_PASSWORD_PATH, + Some(&format!(r#"{{"current_secret_key":"{old_sk}","new_secret_key":"{new_sk}"}}"#)), + user_ak, + old_sk, + ) + .await?; + assert_eq!(status, reqwest::StatusCode::OK, "rotation must succeed, body: {body}"); + + // The new secret works and the old one does not. + let (status, body) = signed_request(&env.url, http::Method::GET, ACCOUNT_INFO_PATH, None, user_ak, new_sk).await?; + assert_eq!(status, reqwest::StatusCode::OK, "the new secret must work, body: {body}"); + + let (status, _) = signed_request(&env.url, http::Method::GET, ACCOUNT_INFO_PATH, None, user_ak, old_sk).await?; + assert!(status.is_client_error(), "the old secret must stop working, status: {status}"); + + env.stop_server(); + Ok(()) + } + + /// Wait until the current time step differs from the one a code was just + /// consumed in, then return a code for it. + /// + /// Anti-replay burns the step, so a test that reuses a code inside its own + /// window would fail for the right reason at the wrong moment. + async fn wait_for_a_fresh_code(secret_base32: &str) -> String { + let step_at_start = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after the epoch") + .as_secs() + / 30; + + loop { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after the epoch") + .as_secs(); + if now / 30 > step_at_start { + return totp_now(secret_base32); + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + } +} diff --git a/crates/e2e_test/src/lib.rs b/crates/e2e_test/src/lib.rs index 6ff615964..ede4268ad 100644 --- a/crates/e2e_test/src/lib.rs +++ b/crates/e2e_test/src/lib.rs @@ -284,6 +284,7 @@ mod console_smoke_test; // plus non-admin 403 probes per endpoint (sec-4 pattern). #[cfg(test)] mod admin_iam_crud_test; +mod admin_mfa_test; #[cfg(test)] mod admin_pools_test; diff --git a/crates/iam/Cargo.toml b/crates/iam/Cargo.toml index ae89dd299..457fb71a9 100644 --- a/crates/iam/Cargo.toml +++ b/crates/iam/Cargo.toml @@ -105,6 +105,15 @@ openidconnect = { workspace = true, default-features = false, features = ["accep http = { workspace = true } url = { workspace = true } +# Two-factor authentication (crates/iam/src/mfa) +data-encoding = { workspace = true } +hmac = { workspace = true } +qrcode = { workspace = true } +rand = { workspace = true } +sha1 = { workspace = true } +sha2 = { workspace = true } +subtle = { workspace = true } + [dev-dependencies] pollster.workspace = true rcgen.workspace = true diff --git a/crates/iam/src/lib.rs b/crates/iam/src/lib.rs index 7588a3495..239cd9c24 100644 --- a/crates/iam/src/lib.rs +++ b/crates/iam/src/lib.rs @@ -31,6 +31,7 @@ pub mod error; pub mod federation; pub mod keyring; pub mod manager; +pub mod mfa; pub mod oidc; pub mod oidc_state; mod root_credentials; diff --git a/crates/iam/src/mfa/challenge.rs b/crates/iam/src/mfa/challenge.rs new file mode 100644 index 000000000..0948c9d52 --- /dev/null +++ b/crates/iam/src/mfa/challenge.rs @@ -0,0 +1,252 @@ +// 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. + +//! Login challenges: the token a client echoes back with its second factor. +//! +//! # Why these are stateless +//! +//! The obvious design is a TTL cache, the way the OIDC flow stores its PKCE +//! verifiers. That store is node-local, which is fine for OIDC because the +//! whole authorization round trip returns to the node that started it. A second +//! factor does not: a cluster behind a load balancer without session affinity +//! will issue the challenge on one node and receive the code on another, and a +//! node-local challenge would fail there for reasons no operator could debug. +//! +//! So a challenge carries its own state and a signature over it. Any node +//! validates one without shared storage, and nothing needs replicating. +//! +//! Statelessness costs nothing here because a challenge is not what makes the +//! exchange single-use — the consumed TOTP time step is +//! ([`super::totp::TotpSecret::verify`]). A replayed challenge with a replayed +//! code is refused by the step check; a replayed challenge with a fresh code is +//! just a normal second attempt inside the window, which the rate limiter +//! bounds. + +use base64_simd::URL_SAFE_NO_PAD; +use hmac::{Hmac, KeyInit as _, Mac}; +use sha2::Sha256; +use subtle::ConstantTimeEq as _; + +type HmacSha256 = Hmac; + +/// How long a challenge stays valid. +/// +/// Long enough to fetch a phone and type a code, short enough that one +/// intercepted from a log or a proxy is stale by the time it is useful. +pub const CHALLENGE_TTL_SECONDS: u64 = 300; + +/// Domain separator, so a challenge signature can never be mistaken for — or +/// produced by — another HMAC over the same key. +const CHALLENGE_DOMAIN: &[u8] = b"rustfs-mfa-challenge:v1"; + +/// Wire format version, so a future change to the payload is a decode failure +/// rather than a misparse. +const CHALLENGE_VERSION: u8 = 1; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ChallengeError { + #[error("the challenge is malformed")] + Malformed, + #[error("the challenge signature is invalid")] + BadSignature, + #[error("the challenge has expired")] + Expired, + #[error("the challenge was issued for a different identity")] + IdentityMismatch, +} + +/// Issue a challenge for `access_key`, valid from `issued_at_unix`. +/// +/// `signing_key` must be a server secret; the deployment's root secret key is +/// what the STS token path already uses for the same purpose. +pub fn issue(access_key: &str, issued_at_unix: u64, signing_key: &[u8]) -> String { + let payload = format!("{CHALLENGE_VERSION}:{issued_at_unix}:{access_key}"); + let signature = sign(&payload, signing_key); + format!( + "{}.{}", + URL_SAFE_NO_PAD.encode_to_string(payload.as_bytes()), + URL_SAFE_NO_PAD.encode_to_string(signature) + ) +} + +/// Validate `challenge` for `access_key` at `now_unix`. +/// +/// Checks the signature before anything else, so a forged challenge cannot +/// reach the expiry or identity comparisons and learn from them. +pub fn validate(challenge: &str, access_key: &str, now_unix: u64, signing_key: &[u8]) -> Result<(), ChallengeError> { + let (payload_b64, signature_b64) = challenge.split_once('.').ok_or(ChallengeError::Malformed)?; + + let payload = URL_SAFE_NO_PAD + .decode_to_vec(payload_b64.as_bytes()) + .map_err(|_| ChallengeError::Malformed)?; + let payload = String::from_utf8(payload).map_err(|_| ChallengeError::Malformed)?; + let signature = URL_SAFE_NO_PAD + .decode_to_vec(signature_b64.as_bytes()) + .map_err(|_| ChallengeError::Malformed)?; + + let expected = sign(&payload, signing_key); + if !bool::from(expected.ct_eq(&signature)) { + return Err(ChallengeError::BadSignature); + } + + let mut parts = payload.splitn(3, ':'); + let version = parts.next().ok_or(ChallengeError::Malformed)?; + let issued_at = parts.next().ok_or(ChallengeError::Malformed)?; + let challenge_access_key = parts.next().ok_or(ChallengeError::Malformed)?; + + if version != CHALLENGE_VERSION.to_string() { + return Err(ChallengeError::Malformed); + } + + let issued_at: u64 = issued_at.parse().map_err(|_| ChallengeError::Malformed)?; + // A challenge stamped in the future is treated as expired rather than + // accepted: it means the issuing clock is wrong, and honouring it would + // extend the window by however far off that clock is. + if now_unix < issued_at || now_unix.saturating_sub(issued_at) > CHALLENGE_TTL_SECONDS { + return Err(ChallengeError::Expired); + } + + if !bool::from(challenge_access_key.as_bytes().ct_eq(access_key.as_bytes())) { + return Err(ChallengeError::IdentityMismatch); + } + + Ok(()) +} + +fn sign(payload: &str, signing_key: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(signing_key).expect("HMAC accepts keys of any length"); + mac.update(CHALLENGE_DOMAIN); + mac.update(&[0]); + mac.update(payload.as_bytes()); + mac.finalize().into_bytes().to_vec() +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: &[u8] = b"root-secret-key-for-tests"; + const NOW: u64 = 1_700_000_000; + + #[test] + fn a_fresh_challenge_validates_for_its_identity() { + let challenge = issue("sinan", NOW, KEY); + assert_eq!(validate(&challenge, "sinan", NOW, KEY), Ok(())); + } + + #[test] + fn a_challenge_validates_anywhere_in_its_window() { + let challenge = issue("sinan", NOW, KEY); + + assert_eq!(validate(&challenge, "sinan", NOW, KEY), Ok(())); + assert_eq!(validate(&challenge, "sinan", NOW + CHALLENGE_TTL_SECONDS, KEY), Ok(())); + } + + #[test] + fn an_expired_challenge_is_rejected() { + let challenge = issue("sinan", NOW, KEY); + assert_eq!( + validate(&challenge, "sinan", NOW + CHALLENGE_TTL_SECONDS + 1, KEY), + Err(ChallengeError::Expired) + ); + } + + #[test] + fn a_challenge_from_the_future_is_rejected_rather_than_honoured() { + // A skewed issuing clock must not silently widen the window. + let challenge = issue("sinan", NOW + 60, KEY); + assert_eq!(validate(&challenge, "sinan", NOW, KEY), Err(ChallengeError::Expired)); + } + + #[test] + fn a_challenge_for_another_identity_is_rejected() { + // Without this, a caller who can authenticate as one identity could + // carry its challenge into another identity's verification. + let challenge = issue("sinan", NOW, KEY); + assert_eq!(validate(&challenge, "someone-else", NOW, KEY), Err(ChallengeError::IdentityMismatch)); + } + + #[test] + fn a_challenge_signed_with_another_key_is_rejected() { + let challenge = issue("sinan", NOW, KEY); + assert_eq!(validate(&challenge, "sinan", NOW, b"different-key"), Err(ChallengeError::BadSignature)); + } + + #[test] + fn tampering_with_the_payload_is_rejected() { + // The point of signing: an attacker must not be able to extend the + // expiry or swap the identity by editing the token. + let forged_payload = format!("{CHALLENGE_VERSION}:{}:{}", NOW, "attacker"); + let genuine = issue("sinan", NOW, KEY); + let (_, signature) = genuine.split_once('.').expect("well-formed challenge"); + let forged = format!("{}.{signature}", URL_SAFE_NO_PAD.encode_to_string(forged_payload.as_bytes())); + + assert_eq!(validate(&forged, "attacker", NOW, KEY), Err(ChallengeError::BadSignature)); + } + + #[test] + fn tampering_with_the_signature_is_rejected() { + let challenge = issue("sinan", NOW, KEY); + let (payload, signature) = challenge.split_once('.').expect("well-formed challenge"); + let mut bytes = URL_SAFE_NO_PAD.decode_to_vec(signature.as_bytes()).expect("decode"); + bytes[0] ^= 0xff; + let forged = format!("{payload}.{}", URL_SAFE_NO_PAD.encode_to_string(&bytes)); + + assert_eq!(validate(&forged, "sinan", NOW, KEY), Err(ChallengeError::BadSignature)); + } + + #[test] + fn malformed_challenges_are_rejected() { + for bad in ["", "no-separator", ".", "a.b", "!!!.!!!"] { + let result = validate(bad, "sinan", NOW, KEY); + assert!( + matches!(result, Err(ChallengeError::Malformed) | Err(ChallengeError::BadSignature)), + "input {bad:?} gave {result:?}" + ); + } + } + + #[test] + fn a_challenge_from_an_unknown_version_is_rejected() { + // Forward compatibility: an older node must refuse a payload shape it + // cannot interpret rather than guess at its fields. + let payload = format!("99:{NOW}:sinan"); + let signature = sign(&payload, KEY); + let challenge = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode_to_string(payload.as_bytes()), + URL_SAFE_NO_PAD.encode_to_string(&signature) + ); + + assert_eq!(validate(&challenge, "sinan", NOW, KEY), Err(ChallengeError::Malformed)); + } + + #[test] + fn any_node_validates_a_challenge_another_node_issued() { + // The reason challenges are stateless: no shared store is consulted, so + // a cluster without session affinity still completes the exchange. + let issuing_node_challenge = issue("sinan", NOW, KEY); + // A second "node" holds only the same signing key. + assert_eq!(validate(&issuing_node_challenge, "sinan", NOW + 10, KEY), Ok(())); + } + + #[test] + fn access_keys_containing_the_separator_still_round_trip() { + // The payload splits on the first two colons only, so a colon in the + // access key cannot truncate the identity. + let challenge = issue("team:sinan", NOW, KEY); + assert_eq!(validate(&challenge, "team:sinan", NOW, KEY), Ok(())); + assert_eq!(validate(&challenge, "team", NOW, KEY), Err(ChallengeError::IdentityMismatch)); + } +} diff --git a/crates/iam/src/mfa/mod.rs b/crates/iam/src/mfa/mod.rs new file mode 100644 index 000000000..d552fe463 --- /dev/null +++ b/crates/iam/src/mfa/mod.rs @@ -0,0 +1,34 @@ +// 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. + +//! Two-factor authentication for RustFS identities. +//! +//! All of the logic lives here rather than in the admin handlers, so the +//! console and the `rc` CLI drive the same state machine through the same API +//! instead of each reimplementing enrollment and verification. + +pub mod challenge; +pub mod qr; +pub mod record; +pub mod recovery; +pub mod service; +pub mod store; +pub mod totp; + +pub use challenge::{CHALLENGE_TTL_SECONDS, ChallengeError}; +pub use qr::{QrError, RenderedQr}; +pub use record::{MfaRecord, MfaRecordError, MfaVerification, MfaVerifyError}; +pub use recovery::{ConsumeOutcome, GeneratedRecoveryCodes, StoredRecoveryCode}; +pub use service::{MFA_ISSUER, MfaServiceError}; +pub use totp::{TOTP_ALGORITHM, TOTP_DIGITS, TOTP_PERIOD_SECONDS, TotpError, TotpSecret, looks_like_totp_code}; diff --git a/crates/iam/src/mfa/qr.rs b/crates/iam/src/mfa/qr.rs new file mode 100644 index 000000000..54772a6d2 --- /dev/null +++ b/crates/iam/src/mfa/qr.rs @@ -0,0 +1,162 @@ +// 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. + +//! Server-side QR rendering for TOTP enrollment. +//! +//! Rendered here, not in the clients, so there is one QR encoder for the whole +//! product instead of a JavaScript one in the console and a Rust one in the +//! CLI. The enrollment response carries both an SVG (for a browser ``) and +//! Unicode block art (for a terminal), and the console needs no new npm +//! dependency to show a code. + +use qrcode::{EcLevel, QrCode, render::svg, render::unicode}; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum QrError { + #[error("the payload does not fit in a QR code")] + PayloadTooLarge, +} + +/// Rendered forms of one provisioning URI. +#[derive(Debug, Clone)] +pub struct RenderedQr { + /// A standalone SVG document. + /// + /// Consumers must embed it as an image source (a `data:` URI) rather than + /// injecting the markup into the page: it is server-generated today, but + /// treating it as data keeps that from becoming a same-origin script sink + /// if the payload ever becomes attacker-influenced. + pub svg: String, + /// Unicode half-block art, two QR rows per text row, for terminals. + pub utf8: String, +} + +/// Render `payload` as a QR code. +/// +/// Uses medium error correction: the code is displayed on a screen and scanned +/// immediately, so the higher levels only make the symbol denser and harder for +/// a phone camera to resolve. +pub fn render(payload: &str) -> Result { + let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M).map_err(|_| QrError::PayloadTooLarge)?; + + let svg = code + .render() + .min_dimensions(200, 200) + // Explicit colours rather than the default: an SVG with no declared + // colours inherits the page's, and a QR code rendered dark-on-dark by a + // dark-mode viewer is unscannable. + .dark_color(svg::Color("#000000")) + .light_color(svg::Color("#ffffff")) + .build(); + + // `Dense1x2` packs two QR rows into one text row, which is what makes the + // symbol square in a terminal where cells are twice as tall as they are + // wide. A 1x1 rendering comes out stretched and scans poorly. + let utf8 = code + .render::() + .dark_color(unicode::Dense1x2::Light) + .light_color(unicode::Dense1x2::Dark) + .build(); + + Ok(RenderedQr { svg, utf8 }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mfa::totp::{TotpSecret, provisioning_uri}; + + fn sample_uri() -> String { + provisioning_uri("RustFS", "sinan", &TotpSecret::generate()) + } + + #[test] + fn renders_both_forms_for_a_provisioning_uri() { + let rendered = render(&sample_uri()).expect("render"); + + assert!( + rendered.svg.starts_with(" = rendered.utf8.lines().collect(); + + assert!(lines.len() > 10, "expected a multi-row symbol, got {}", lines.len()); + assert!( + rendered + .utf8 + .chars() + .any(|c| matches!(c, '\u{2580}' | '\u{2584}' | '\u{2588}' | ' ')), + "expected half-block characters" + ); + + // Dense1x2 packs two module rows per text row, so the character width + // should be about twice the row count. Allow slack for the quiet zone. + let width = lines.iter().map(|line| line.chars().count()).max().unwrap_or(0); + assert!( + width >= lines.len() && width <= lines.len() * 3, + "aspect looks wrong: {width} columns for {} rows", + lines.len() + ); + } + + #[test] + fn distinct_payloads_render_distinct_symbols() { + let first = render("otpauth://totp/RustFS:a?secret=AAAA").expect("render"); + let second = render("otpauth://totp/RustFS:b?secret=BBBB").expect("render"); + + assert_ne!(first.svg, second.svg); + assert_ne!(first.utf8, second.utf8); + } + + #[test] + fn rendering_is_deterministic_for_a_given_payload() { + let payload = "otpauth://totp/RustFS:sinan?secret=JBSWY3DPEHPK3PXP"; + assert_eq!(render(payload).expect("render").svg, render(payload).expect("render").svg); + } + + #[test] + fn an_oversized_payload_is_reported_rather_than_panicking() { + // A QR code caps out around 2953 bytes; the error path must be a + // returned error, because the payload includes an access key whose + // length the server does not control. + let oversized = "a".repeat(4096); + assert_eq!(render(&oversized).unwrap_err(), QrError::PayloadTooLarge); + } + + #[test] + fn a_long_but_realistic_access_key_still_renders() { + // Access keys can be long; enrollment must not fail for a legitimate one. + let long_account = "a".repeat(128); + let uri = provisioning_uri("RustFS", &long_account, &TotpSecret::generate()); + render(&uri).expect("a realistic provisioning URI must render"); + } +} diff --git a/crates/iam/src/mfa/record.rs b/crates/iam/src/mfa/record.rs new file mode 100644 index 000000000..7a7412dec --- /dev/null +++ b/crates/iam/src/mfa/record.rs @@ -0,0 +1,696 @@ +// 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. + +//! The persisted per-identity MFA record and the state machine over it. +//! +//! Everything here is pure: no storage, no clock, no I/O. The caller supplies +//! `now`, which is what makes the replay, expiry and lockout rules testable at +//! their edges instead of only in the happy case. +//! +//! Enrollment is deliberately two-phase. A started enrollment lands in +//! `pending_secret` and only becomes the active secret once the user proves they +//! can generate a code from it. Without that, a user who scanned the QR into the +//! wrong app — or never scanned it — would be locked out of their own account +//! the moment enrollment was recorded. The same two-phase shape is what makes +//! re-configuring safe: the existing secret keeps working until the new one is +//! confirmed. + +use super::recovery::{self, ConsumeOutcome, StoredRecoveryCode}; +use super::totp::{self, TOTP_ALGORITHM, TOTP_DIGITS, TOTP_PERIOD_SECONDS, TotpSecret}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +/// On-disk format version. A record written by a newer node is refused rather +/// than misread. +pub const MFA_RECORD_VERSION: u8 = 1; + +/// How long a started enrollment stays confirmable. +/// +/// Long enough to install an authenticator app mid-flow, short enough that an +/// abandoned enrollment does not leave a usable secret lying in the store. +pub const PENDING_ENROLLMENT_TTL_SECONDS: i64 = 600; + +/// Consecutive failures before the identity is locked out. +const MAX_FAILED_ATTEMPTS: u32 = 5; + +/// First lockout duration, doubling for each further run of failures. +const LOCKOUT_BASE_SECONDS: i64 = 900; + +/// Ceiling on the lockout, so a sustained attack cannot lock a legitimate user +/// out indefinitely — the point is to make guessing infeasible, not to hand an +/// attacker a denial of service against the account owner. +const LOCKOUT_MAX_SECONDS: i64 = 3600; + +/// A six-digit code has a million values. Bounding attempts is what turns that +/// into an infeasible guess; without it, an unattended script clears the space +/// in hours. +const _: () = assert!(MAX_FAILED_ATTEMPTS > 0); + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum MfaRecordError { + #[error("the stored MFA record has an unsupported version: {0}")] + UnsupportedVersion(u8), + #[error("the stored MFA secret is unusable")] + CorruptSecret, +} + +/// Why a verification attempt failed. +/// +/// The API surface collapses most of these into one opaque answer; the +/// distinctions exist so the audit trail can tell an operator what actually +/// happened. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MfaVerifyError { + /// No active second factor for this identity. + NotEnabled, + /// Too many recent failures. + Locked { retry_after_seconds: u64 }, + /// The code did not match anything. + InvalidCode, + /// A correct TOTP code for a time step that was already spent. + ReplayedTotpCode, + /// A recovery code that had already been used. + ReplayedRecoveryCode, +} + +/// What satisfied a verification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MfaVerification { + Totp, + RecoveryCode { remaining: u32 }, +} + +/// The persisted MFA state for one identity. +/// +/// Field-level notes: +/// +/// * `secret_b32` present means the second factor is active. +/// * `pending_secret_b32` present and unexpired means an enrollment awaits +/// confirmation. Both may be present at once, which is a re-configuration. +/// * `last_used_step` is the anti-replay high-water mark, not a timestamp. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MfaRecord { + pub version: u8, + pub access_key: String, + pub algorithm: String, + pub digits: u8, + pub period_seconds: u32, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub secret_b32: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_secret_b32: Option, + #[serde(with = "time::serde::rfc3339::option", default, skip_serializing_if = "Option::is_none")] + pub pending_expires_at: Option, + + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339::option", default, skip_serializing_if = "Option::is_none")] + pub activated_at: Option, + #[serde(with = "time::serde::rfc3339::option", default, skip_serializing_if = "Option::is_none")] + pub last_verified_at: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_used_step: Option, + + #[serde(default)] + pub recovery_codes: Vec, + #[serde(with = "time::serde::rfc3339::option", default, skip_serializing_if = "Option::is_none")] + pub recovery_codes_generated_at: Option, + + #[serde(default)] + pub failed_attempts: u32, + #[serde(with = "time::serde::rfc3339::option", default, skip_serializing_if = "Option::is_none")] + pub locked_until: Option, +} + +impl MfaRecord { + /// A record with nothing enrolled. + pub fn new(access_key: &str, now: OffsetDateTime) -> Self { + Self { + version: MFA_RECORD_VERSION, + access_key: access_key.to_string(), + algorithm: TOTP_ALGORITHM.to_string(), + digits: TOTP_DIGITS, + period_seconds: TOTP_PERIOD_SECONDS, + secret_b32: None, + pending_secret_b32: None, + pending_expires_at: None, + created_at: now, + activated_at: None, + last_verified_at: None, + last_used_step: None, + recovery_codes: Vec::new(), + recovery_codes_generated_at: None, + failed_attempts: 0, + locked_until: None, + } + } + + /// Reject a record this build cannot interpret. + pub fn validate_version(&self) -> Result<(), MfaRecordError> { + if self.version != MFA_RECORD_VERSION { + return Err(MfaRecordError::UnsupportedVersion(self.version)); + } + Ok(()) + } + + /// Whether a second factor is active. + pub const fn is_enabled(&self) -> bool { + self.secret_b32.is_some() + } + + /// Whether an enrollment is awaiting confirmation. + pub fn has_pending_enrollment(&self, now: OffsetDateTime) -> bool { + self.pending_secret_b32.is_some() && self.pending_expires_at.is_some_and(|expires| now < expires) + } + + /// Recovery codes still usable. + pub fn recovery_codes_remaining(&self) -> u32 { + recovery::remaining(&self.recovery_codes) + } + + /// Whether verification is currently refused because of prior failures. + pub fn lock_remaining_seconds(&self, now: OffsetDateTime) -> Option { + let locked_until = self.locked_until?; + if now >= locked_until { + return None; + } + Some((locked_until - now).whole_seconds().max(1) as u64) + } + + /// Start (or restart) an enrollment. + /// + /// Overwrites any earlier pending secret: a user who reopened the setup + /// dialog expects a fresh QR, and the previous unconfirmed secret has no + /// standing. The active secret is untouched, so a re-configuration cannot + /// lock the user out if they abandon it. + pub fn begin_enrollment(&mut self, secret: &TotpSecret, now: OffsetDateTime) { + self.pending_secret_b32 = Some(secret.to_base32()); + self.pending_expires_at = Some(now + time::Duration::seconds(PENDING_ENROLLMENT_TTL_SECONDS)); + } + + /// Confirm the pending enrollment with `code`. + /// + /// On success the pending secret becomes active, the replay high-water mark + /// is seeded with the step that was just proven, and the caller is expected + /// to install a fresh set of recovery codes. + pub fn activate_enrollment(&mut self, code: &str, now: OffsetDateTime) -> Result<(), MfaVerifyError> { + if let Some(retry_after_seconds) = self.lock_remaining_seconds(now) { + return Err(MfaVerifyError::Locked { retry_after_seconds }); + } + + let Some(pending) = self.pending_secret_b32.clone() else { + return Err(MfaVerifyError::NotEnabled); + }; + if !self.has_pending_enrollment(now) { + // Treated as "nothing to confirm" rather than a code failure: the + // secret is gone, so no code could ever have matched. + self.clear_pending(); + return Err(MfaVerifyError::NotEnabled); + } + + let secret = TotpSecret::from_base32(&pending).map_err(|_| MfaVerifyError::NotEnabled)?; + let unix = now.unix_timestamp().max(0) as u64; + + match secret.verify(code, unix, None) { + Ok(step) => { + self.secret_b32 = Some(pending); + self.activated_at = Some(now); + self.last_used_step = Some(step); + self.last_verified_at = Some(now); + self.clear_pending(); + self.clear_failures(); + Ok(()) + } + Err(_) => { + self.register_failure(now); + Err(MfaVerifyError::InvalidCode) + } + } + } + + /// Verify a second factor against the active enrollment. + /// + /// Accepts either a TOTP code or a recovery code and decides which by shape, + /// so a client never has to ask the user to declare what they typed. + pub fn verify(&mut self, code: &str, now: OffsetDateTime) -> Result { + if let Some(retry_after_seconds) = self.lock_remaining_seconds(now) { + return Err(MfaVerifyError::Locked { retry_after_seconds }); + } + + let Some(secret_b32) = self.secret_b32.clone() else { + return Err(MfaVerifyError::NotEnabled); + }; + + // Shape routing happens before either verification so a recovery code is + // never fed to the TOTP path (where it would burn a failed attempt for + // the wrong reason) and vice versa. + if recovery::looks_like_recovery_code(code) { + return self.verify_recovery_code(code, now); + } + + if !totp::looks_like_totp_code(code) { + self.register_failure(now); + return Err(MfaVerifyError::InvalidCode); + } + + let secret = TotpSecret::from_base32(&secret_b32).map_err(|_| MfaVerifyError::NotEnabled)?; + let unix = now.unix_timestamp().max(0) as u64; + + match secret.verify(code, unix, self.last_used_step) { + Ok(step) => { + self.last_used_step = Some(step); + self.last_verified_at = Some(now); + self.clear_failures(); + Ok(MfaVerification::Totp) + } + Err(_) => { + self.register_failure(now); + // Re-check ignoring the high-water mark purely to classify the + // failure for the audit trail. The caller still gets a single + // opaque rejection, so this tells an attacker nothing while + // telling an operator whether they are seeing replays. + if secret.verify(code, unix, None).is_ok() { + Err(MfaVerifyError::ReplayedTotpCode) + } else { + Err(MfaVerifyError::InvalidCode) + } + } + } + } + + fn verify_recovery_code(&mut self, code: &str, now: OffsetDateTime) -> Result { + match recovery::consume(&mut self.recovery_codes, code, now) { + ConsumeOutcome::Consumed { remaining, .. } => { + self.last_verified_at = Some(now); + self.clear_failures(); + Ok(MfaVerification::RecoveryCode { remaining }) + } + ConsumeOutcome::AlreadyUsed => { + self.register_failure(now); + Err(MfaVerifyError::ReplayedRecoveryCode) + } + ConsumeOutcome::NoMatch => { + self.register_failure(now); + Err(MfaVerifyError::InvalidCode) + } + } + } + + /// Replace the recovery code set. + pub fn set_recovery_codes(&mut self, codes: Vec, now: OffsetDateTime) { + self.recovery_codes = codes; + self.recovery_codes_generated_at = Some(now); + } + + /// Turn the second factor off, clearing every trace of the enrollment. + /// + /// Recovery codes go too: keeping them would leave a live bypass for a + /// factor the user believes is gone. + pub fn disable(&mut self) { + self.secret_b32 = None; + self.activated_at = None; + self.last_used_step = None; + self.recovery_codes.clear(); + self.recovery_codes_generated_at = None; + self.clear_pending(); + self.clear_failures(); + } + + fn clear_pending(&mut self) { + self.pending_secret_b32 = None; + self.pending_expires_at = None; + } + + fn clear_failures(&mut self) { + self.failed_attempts = 0; + self.locked_until = None; + } + + /// Record a failed attempt, locking the identity once the threshold is hit. + /// + /// The lockout doubles for each further run of failures so a persistent + /// attacker faces a rapidly growing cost, capped so the account owner is + /// never locked out for longer than [`LOCKOUT_MAX_SECONDS`]. + fn register_failure(&mut self, now: OffsetDateTime) { + self.failed_attempts = self.failed_attempts.saturating_add(1); + + if !self.failed_attempts.is_multiple_of(MAX_FAILED_ATTEMPTS) { + return; + } + + let runs = self.failed_attempts / MAX_FAILED_ATTEMPTS; + // `runs` is at least 1 here; shift is bounded so the doubling cannot + // overflow before the cap applies. + let scale = 1i64.checked_shl(runs.saturating_sub(1).min(16)).unwrap_or(i64::MAX); + let seconds = LOCKOUT_BASE_SECONDS.saturating_mul(scale).min(LOCKOUT_MAX_SECONDS); + self.locked_until = Some(now + time::Duration::seconds(seconds)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(unix: i64) -> OffsetDateTime { + OffsetDateTime::from_unix_timestamp(unix).expect("valid timestamp") + } + + const T0: i64 = 1_700_000_000; + + fn enrolled() -> (MfaRecord, TotpSecret) { + let secret = TotpSecret::generate(); + let mut record = MfaRecord::new("sinan", at(T0)); + record.begin_enrollment(&secret, at(T0)); + let code = secret.code_at(T0 as u64); + record.activate_enrollment(&code, at(T0)).expect("activate"); + (record, secret) + } + + #[test] + fn a_new_record_has_nothing_enrolled() { + let record = MfaRecord::new("sinan", at(T0)); + + assert!(!record.is_enabled()); + assert!(!record.has_pending_enrollment(at(T0))); + assert_eq!(record.recovery_codes_remaining(), 0); + assert_eq!(record.algorithm, "SHA1"); + assert_eq!(record.digits, 6); + assert_eq!(record.period_seconds, 30); + } + + #[test] + fn a_record_from_an_unknown_version_is_refused() { + let mut record = MfaRecord::new("sinan", at(T0)); + record.version = 99; + assert_eq!(record.validate_version(), Err(MfaRecordError::UnsupportedVersion(99))); + } + + #[test] + fn enrollment_is_only_active_after_confirmation() { + // The property that keeps a mis-scanned QR from locking the user out. + let secret = TotpSecret::generate(); + let mut record = MfaRecord::new("sinan", at(T0)); + record.begin_enrollment(&secret, at(T0)); + + assert!(record.has_pending_enrollment(at(T0))); + assert!(!record.is_enabled(), "a pending enrollment must not gate logins"); + + record + .activate_enrollment(&secret.code_at(T0 as u64), at(T0)) + .expect("activate"); + + assert!(record.is_enabled()); + assert!(!record.has_pending_enrollment(at(T0))); + assert_eq!(record.activated_at, Some(at(T0))); + } + + #[test] + fn activation_with_a_wrong_code_leaves_the_enrollment_pending() { + let secret = TotpSecret::generate(); + let mut record = MfaRecord::new("sinan", at(T0)); + record.begin_enrollment(&secret, at(T0)); + + assert_eq!(record.activate_enrollment("000000", at(T0)), Err(MfaVerifyError::InvalidCode)); + assert!(!record.is_enabled()); + assert!(record.has_pending_enrollment(at(T0)), "the user must be able to retry"); + assert_eq!(record.failed_attempts, 1); + } + + #[test] + fn an_expired_enrollment_cannot_be_confirmed() { + let secret = TotpSecret::generate(); + let mut record = MfaRecord::new("sinan", at(T0)); + record.begin_enrollment(&secret, at(T0)); + + let late = T0 + PENDING_ENROLLMENT_TTL_SECONDS + 1; + assert_eq!( + record.activate_enrollment(&secret.code_at(late as u64), at(late)), + Err(MfaVerifyError::NotEnabled) + ); + assert!(!record.is_enabled()); + assert!(record.pending_secret_b32.is_none(), "an expired secret must not linger"); + } + + #[test] + fn re_enrolling_keeps_the_existing_factor_working_until_confirmed() { + // Re-configuration must not be a window with no second factor. + let (mut record, original) = enrolled(); + let replacement = TotpSecret::generate(); + record.begin_enrollment(&replacement, at(T0 + 60)); + + assert!(record.is_enabled()); + assert_eq!(record.verify(&original.code_at((T0 + 60) as u64), at(T0 + 60)), Ok(MfaVerification::Totp)); + + record + .activate_enrollment(&replacement.code_at((T0 + 120) as u64), at(T0 + 120)) + .expect("activate replacement"); + + // The old secret stops working only once the new one is confirmed. + assert_eq!( + record.verify(&original.code_at((T0 + 180) as u64), at(T0 + 180)), + Err(MfaVerifyError::InvalidCode) + ); + assert_eq!( + record.verify(&replacement.code_at((T0 + 180) as u64), at(T0 + 180)), + Ok(MfaVerification::Totp) + ); + } + + #[test] + fn a_current_totp_code_verifies() { + let (mut record, secret) = enrolled(); + let later = T0 + 60; + + assert_eq!(record.verify(&secret.code_at(later as u64), at(later)), Ok(MfaVerification::Totp)); + assert_eq!(record.last_verified_at, Some(at(later))); + } + + #[test] + fn a_replayed_totp_code_is_rejected_and_classified() { + let (mut record, secret) = enrolled(); + let later = T0 + 60; + let code = secret.code_at(later as u64); + + assert_eq!(record.verify(&code, at(later)), Ok(MfaVerification::Totp)); + // Same code, same window: correct by the clock, refused by the step. + assert_eq!(record.verify(&code, at(later)), Err(MfaVerifyError::ReplayedTotpCode)); + } + + #[test] + fn activation_seeds_the_replay_high_water_mark() { + // Without this, the code used to confirm enrollment would still be + // valid for its remaining window. + let secret = TotpSecret::generate(); + let mut record = MfaRecord::new("sinan", at(T0)); + record.begin_enrollment(&secret, at(T0)); + let code = secret.code_at(T0 as u64); + record.activate_enrollment(&code, at(T0)).expect("activate"); + + assert_eq!(record.verify(&code, at(T0)), Err(MfaVerifyError::ReplayedTotpCode)); + } + + #[test] + fn a_wrong_code_is_rejected_as_invalid() { + let (mut record, secret) = enrolled(); + let later = T0 + 60; + let correct: u32 = secret.code_at(later as u64).parse().expect("digits"); + let wrong = format!("{:06}", (correct + 1) % 1_000_000); + + assert_eq!(record.verify(&wrong, at(later)), Err(MfaVerifyError::InvalidCode)); + } + + #[test] + fn verification_fails_when_nothing_is_enrolled() { + let mut record = MfaRecord::new("sinan", at(T0)); + assert_eq!(record.verify("123456", at(T0)), Err(MfaVerifyError::NotEnabled)); + } + + #[test] + fn a_recovery_code_verifies_and_is_consumed() { + let (mut record, _) = enrolled(); + let generated = recovery::generate(); + record.set_recovery_codes(generated.stored, at(T0)); + + let outcome = record.verify(&generated.plaintext[0], at(T0 + 60)); + assert_eq!(outcome, Ok(MfaVerification::RecoveryCode { remaining: 9 })); + assert_eq!(record.recovery_codes_remaining(), 9); + } + + #[test] + fn a_reused_recovery_code_is_rejected_and_classified() { + let (mut record, _) = enrolled(); + let generated = recovery::generate(); + record.set_recovery_codes(generated.stored, at(T0)); + + record.verify(&generated.plaintext[0], at(T0 + 60)).expect("first use"); + assert_eq!( + record.verify(&generated.plaintext[0], at(T0 + 90)), + Err(MfaVerifyError::ReplayedRecoveryCode) + ); + } + + #[test] + fn a_recovery_code_is_not_charged_against_the_totp_path() { + // Shape routing: a recovery code must not be tried as a TOTP code, or a + // legitimate recovery attempt would also register a TOTP failure. + let (mut record, _) = enrolled(); + let generated = recovery::generate(); + record.set_recovery_codes(generated.stored, at(T0)); + + record.verify(&generated.plaintext[0], at(T0 + 60)).expect("recovery code"); + assert_eq!(record.failed_attempts, 0); + } + + #[test] + fn repeated_failures_lock_the_identity() { + let (mut record, secret) = enrolled(); + let later = T0 + 60; + + for attempt in 1..MAX_FAILED_ATTEMPTS { + assert_eq!(record.verify("000000", at(later)), Err(MfaVerifyError::InvalidCode)); + assert!(record.lock_remaining_seconds(at(later)).is_none(), "locked too early at {attempt}"); + } + + assert_eq!(record.verify("000000", at(later)), Err(MfaVerifyError::InvalidCode)); + let remaining = record.lock_remaining_seconds(at(later)).expect("must be locked"); + assert_eq!(remaining, LOCKOUT_BASE_SECONDS as u64); + + // A correct code is refused while locked: that is the whole point. + assert_eq!( + record.verify(&secret.code_at(later as u64), at(later)), + Err(MfaVerifyError::Locked { + retry_after_seconds: LOCKOUT_BASE_SECONDS as u64 + }) + ); + } + + #[test] + fn the_lock_expires_and_a_correct_code_then_works() { + let (mut record, secret) = enrolled(); + let later = T0 + 60; + for _ in 0..MAX_FAILED_ATTEMPTS { + let _ = record.verify("000000", at(later)); + } + + let after_lock = later + LOCKOUT_BASE_SECONDS + 1; + assert!(record.lock_remaining_seconds(at(after_lock)).is_none()); + assert_eq!( + record.verify(&secret.code_at(after_lock as u64), at(after_lock)), + Ok(MfaVerification::Totp) + ); + } + + #[test] + fn a_successful_verification_clears_the_failure_count() { + let (mut record, secret) = enrolled(); + let later = T0 + 60; + + let _ = record.verify("000000", at(later)); + let _ = record.verify("000000", at(later)); + assert_eq!(record.failed_attempts, 2); + + record.verify(&secret.code_at(later as u64), at(later)).expect("correct code"); + assert_eq!(record.failed_attempts, 0); + assert!(record.locked_until.is_none()); + } + + #[test] + fn successive_lockouts_lengthen_and_then_stop_growing() { + let (mut record, _) = enrolled(); + let mut clock = T0 + 60; + let mut previous = 0u64; + + for round in 1..=8 { + for _ in 0..MAX_FAILED_ATTEMPTS { + let _ = record.verify("000000", at(clock)); + } + let locked_for = record.lock_remaining_seconds(at(clock)).expect("must be locked"); + + assert!(locked_for <= LOCKOUT_MAX_SECONDS as u64, "round {round} exceeded the cap"); + assert!(locked_for >= previous, "round {round} went backwards"); + previous = locked_for; + + // Step past the lock without succeeding, so the failure run continues. + clock += locked_for as i64 + 1; + } + + assert_eq!(previous, LOCKOUT_MAX_SECONDS as u64, "the lockout should reach its cap"); + } + + #[test] + fn disabling_clears_the_secret_and_the_recovery_codes() { + // A user who turns the factor off must not be left with codes that + // still bypass a factor they believe is gone. + let (mut record, secret) = enrolled(); + record.set_recovery_codes(recovery::generate().stored, at(T0)); + + record.disable(); + + assert!(!record.is_enabled()); + assert!(record.secret_b32.is_none()); + assert_eq!(record.recovery_codes_remaining(), 0); + assert!(record.recovery_codes.is_empty()); + assert!(record.activated_at.is_none()); + assert_eq!(record.verify(&secret.code_at(T0 as u64), at(T0)), Err(MfaVerifyError::NotEnabled)); + } + + #[test] + fn malformed_input_is_rejected_without_matching_anything() { + let (mut record, _) = enrolled(); + for bad in ["", "abc", "12345", "1234567", "!!!!"] { + assert_eq!(record.verify(bad, at(T0 + 60)), Err(MfaVerifyError::InvalidCode), "input {bad:?}"); + } + } + + #[test] + fn the_record_round_trips_through_serde_without_leaking_plaintext_codes() { + let (mut record, _) = enrolled(); + let generated = recovery::generate(); + record.set_recovery_codes(generated.stored, at(T0)); + + let encoded = serde_json::to_string(&record).expect("serialize"); + let decoded: MfaRecord = serde_json::from_str(&encoded).expect("deserialize"); + + assert_eq!(decoded.access_key, "sinan"); + assert!(decoded.is_enabled()); + assert_eq!(decoded.recovery_codes_remaining(), 10); + assert_eq!(decoded.last_used_step, record.last_used_step); + + for plaintext in &generated.plaintext { + assert!(!encoded.contains(plaintext), "serialized record leaked a recovery code"); + } + } + + #[test] + fn a_record_written_by_an_older_node_decodes_without_the_optional_fields() { + // Forward/backward tolerance: the optional fields are skipped when + // absent, so a minimal record from a rolling upgrade still loads. + let minimal = serde_json::json!({ + "version": 1, + "access_key": "sinan", + "algorithm": "SHA1", + "digits": 6, + "period_seconds": 30, + "created_at": "2026-08-25T00:00:00Z", + }); + let decoded: MfaRecord = serde_json::from_value(minimal).expect("deserialize"); + + assert!(!decoded.is_enabled()); + assert_eq!(decoded.failed_attempts, 0); + assert!(decoded.recovery_codes.is_empty()); + decoded.validate_version().expect("version 1 is supported"); + } +} diff --git a/crates/iam/src/mfa/recovery.rs b/crates/iam/src/mfa/recovery.rs new file mode 100644 index 000000000..03f54b614 --- /dev/null +++ b/crates/iam/src/mfa/recovery.rs @@ -0,0 +1,400 @@ +// 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. + +//! Single-use recovery codes. +//! +//! The escape hatch for a lost authenticator. Codes are shown once at +//! generation and stored only as hashes, so losing the store does not hand an +//! attacker a working second factor and RustFS cannot show a user their codes +//! again — only replace them. +//! +//! # Why a plain hash and not a password KDF +//! +//! A code carries [`RECOVERY_CODE_ENTROPY_BITS`] bits of uniform randomness, so +//! the attacks a slow KDF defends against do not apply: there is no dictionary +//! to try and no human-chosen pattern to exploit, and a brute-force search of a +//! 100-bit space stays infeasible against a fast hash. Meanwhile a memory-hard +//! KDF would have to run once per stored code on every verification attempt, +//! which turns each guess into an attacker-controlled multiple of that cost. +//! This is the standard treatment for high-entropy bearer tokens, and the same +//! reasoning is why a per-code salt is absent: with no dictionary and no +//! repeated values, a salt would protect nothing. + +use rand::Rng as _; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use subtle::ConstantTimeEq as _; +use time::OffsetDateTime; + +/// How many codes a generation produces. +/// +/// Enough that a user can lose a few without being locked out, few enough that +/// a verification attempt only ever compares against a short list. +pub const RECOVERY_CODE_COUNT: usize = 10; + +/// Characters per group, and groups per code. +const GROUP_LEN: usize = 4; +const GROUP_COUNT: usize = 5; + +/// Significant characters in one code. +const CODE_LEN: usize = GROUP_LEN * GROUP_COUNT; + +/// Entropy per code: one alphabet symbol is 5 bits. +pub const RECOVERY_CODE_ENTROPY_BITS: usize = CODE_LEN * 5; + +/// Crockford base32: base32 with `I`, `L`, `O` and `U` removed. +/// +/// Dropping them means a handwritten code cannot be ambiguous between `1`/`I`/`L` +/// or `0`/`O`, and `U` is excluded so a random code cannot spell an unfortunate +/// word. Exactly 32 symbols, so indexing with `byte % 32` is unbiased. +const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/// Domain separator, so a hash from this scheme can never be confused with one +/// computed over the same bytes for another purpose. +const HASH_DOMAIN: &[u8] = b"rustfs-recovery-code:v1"; + +/// A stored recovery code: its hash, and whether it has been spent. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StoredRecoveryCode { + /// Lowercase hex of the domain-separated SHA-256 digest. + pub hash: String, + /// When this code was consumed. `None` while it is still usable. + #[serde(with = "time::serde::rfc3339::option", default, skip_serializing_if = "Option::is_none")] + pub used_at: Option, +} + +impl StoredRecoveryCode { + pub const fn is_available(&self) -> bool { + self.used_at.is_none() + } +} + +/// A freshly generated set: the plaintext to show once, and what to persist. +#[derive(Debug)] +pub struct GeneratedRecoveryCodes { + /// Formatted for display. Never persisted, never logged. + pub plaintext: Vec, + pub stored: Vec, +} + +/// Generate a fresh set of codes. +pub fn generate() -> GeneratedRecoveryCodes { + let mut rng = rand::rng(); + let mut plaintext = Vec::with_capacity(RECOVERY_CODE_COUNT); + let mut stored = Vec::with_capacity(RECOVERY_CODE_COUNT); + + for _ in 0..RECOVERY_CODE_COUNT { + let mut bytes = [0u8; CODE_LEN]; + rng.fill_bytes(&mut bytes); + + let symbols: Vec = bytes.iter().map(|byte| ALPHABET[(*byte % 32) as usize]).collect(); + let formatted = symbols + .chunks(GROUP_LEN) + .map(|chunk| String::from_utf8_lossy(chunk).to_string()) + .collect::>() + .join("-"); + + stored.push(StoredRecoveryCode { + hash: hash_code(&formatted), + used_at: None, + }); + plaintext.push(formatted); + } + + GeneratedRecoveryCodes { plaintext, stored } +} + +/// Hash a code for storage or comparison. +/// +/// Normalizes first, so a code retyped in lowercase, without dashes, or with +/// `O` for `0` still hashes to the stored value. +pub fn hash_code(code: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(HASH_DOMAIN); + hasher.update([0u8]); + hasher.update(normalize(code).as_bytes()); + hex_lower(&hasher.finalize()) +} + +/// Whether `code` could be a recovery code. +/// +/// Used to route a submitted second factor without asking the user which kind +/// they typed. Deliberately shape-only: it says nothing about validity. +pub fn looks_like_recovery_code(code: &str) -> bool { + let normalized = normalize(code); + normalized.len() == CODE_LEN && normalized.bytes().all(|b| ALPHABET.contains(&b)) +} + +/// Outcome of consuming a code against a stored set. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsumeOutcome { + /// The code matched an unused entry, now marked spent at `index`. + Consumed { index: usize, remaining: u32 }, + /// The code matched an entry that had already been spent. + AlreadyUsed, + /// No entry matched. + NoMatch, +} + +/// Spend `code` against `codes`, marking the matching entry used. +/// +/// Every entry is examined and the comparison is constant-time, so neither the +/// timing nor the outcome reveals *which* code was close. A code that matches an +/// already-spent entry is reported distinctly from one that matches nothing: +/// the caller audits them the same way but an operator investigating a +/// compromise needs to tell "replayed a used code" from "guessed wrong". +pub fn consume(codes: &mut [StoredRecoveryCode], code: &str, now: OffsetDateTime) -> ConsumeOutcome { + let candidate = hash_code(code); + + let mut matched_unused: Option = None; + let mut matched_used = false; + + for (index, stored) in codes.iter().enumerate() { + if !bool::from(stored.hash.as_bytes().ct_eq(candidate.as_bytes())) { + continue; + } + if stored.is_available() { + if matched_unused.is_none() { + matched_unused = Some(index); + } + } else { + matched_used = true; + } + } + + if let Some(index) = matched_unused { + codes[index].used_at = Some(now); + return ConsumeOutcome::Consumed { + index, + remaining: remaining(codes), + }; + } + + if matched_used { + return ConsumeOutcome::AlreadyUsed; + } + + ConsumeOutcome::NoMatch +} + +/// How many codes are still usable. +pub fn remaining(codes: &[StoredRecoveryCode]) -> u32 { + codes.iter().filter(|code| code.is_available()).count() as u32 +} + +/// Strip formatting and fold the characters Crockford treats as equivalent. +fn normalize(code: &str) -> String { + code.chars() + .filter(|c| !c.is_whitespace() && *c != '-') + .map(|c| match c.to_ascii_uppercase() { + // Crockford's decode aliases: a handwritten code must survive being + // read back by a human. + 'O' => '0', + 'I' | 'L' => '1', + other => other, + }) + .collect() +} + +fn hex_lower(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push_str(&format!("{byte:02x}")); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + fn now() -> OffsetDateTime { + OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp") + } + + #[test] + fn generation_produces_the_expected_shape() { + let generated = generate(); + + assert_eq!(generated.plaintext.len(), RECOVERY_CODE_COUNT); + assert_eq!(generated.stored.len(), RECOVERY_CODE_COUNT); + + for code in &generated.plaintext { + // Five groups of four, dash-separated, as the UI renders them. + let groups: Vec<&str> = code.split('-').collect(); + assert_eq!(groups.len(), GROUP_COUNT, "code {code} has the wrong group count"); + for group in groups { + assert_eq!(group.len(), GROUP_LEN, "group {group} has the wrong length"); + assert!(group.bytes().all(|b| ALPHABET.contains(&b)), "group {group} has a stray symbol"); + } + } + } + + #[test] + fn codes_carry_the_documented_entropy() { + assert_eq!(RECOVERY_CODE_ENTROPY_BITS, 100); + } + + #[test] + fn generated_codes_are_distinct() { + let generated = generate(); + let unique: HashSet<&String> = generated.plaintext.iter().collect(); + assert_eq!(unique.len(), RECOVERY_CODE_COUNT, "generation produced a duplicate"); + } + + #[test] + fn the_alphabet_excludes_ambiguous_characters() { + for excluded in *b"ILOU" { + assert!(!ALPHABET.contains(&excluded), "{} must not be in the alphabet", excluded as char); + } + assert_eq!(ALPHABET.len(), 32, "indexing with `% 32` requires exactly 32 symbols"); + } + + #[test] + fn plaintext_is_never_recoverable_from_what_is_stored() { + let generated = generate(); + for (code, stored) in generated.plaintext.iter().zip(&generated.stored) { + let normalized = normalize(code); + assert!(!stored.hash.contains(code), "stored hash leaks the formatted code"); + assert!(!stored.hash.contains(&normalized), "stored hash leaks the normalized code"); + assert_eq!(stored.hash.len(), 64, "expected a hex SHA-256 digest"); + } + } + + #[test] + fn a_generated_code_can_be_consumed_once() { + let generated = generate(); + let mut stored = generated.stored; + let code = &generated.plaintext[3]; + + assert_eq!( + consume(&mut stored, code, now()), + ConsumeOutcome::Consumed { + index: 3, + remaining: (RECOVERY_CODE_COUNT - 1) as u32 + } + ); + assert_eq!(stored[3].used_at, Some(now())); + } + + #[test] + fn a_consumed_code_cannot_be_reused() { + // The single-use property, which is the whole point of the `used_at` + // column: an attacker who saw a code being typed must not be able to + // reuse it. + let generated = generate(); + let mut stored = generated.stored; + let code = &generated.plaintext[0]; + + consume(&mut stored, code, now()); + assert_eq!(consume(&mut stored, code, now()), ConsumeOutcome::AlreadyUsed); + assert_eq!(remaining(&stored), (RECOVERY_CODE_COUNT - 1) as u32); + } + + #[test] + fn an_unknown_code_does_not_match() { + let generated = generate(); + let mut stored = generated.stored; + + assert_eq!(consume(&mut stored, "ZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZ", now()), ConsumeOutcome::NoMatch); + assert_eq!(remaining(&stored), RECOVERY_CODE_COUNT as u32); + } + + #[test] + fn consuming_does_not_disturb_the_other_codes() { + let generated = generate(); + let mut stored = generated.stored; + + consume(&mut stored, &generated.plaintext[5], now()); + + for (index, entry) in stored.iter().enumerate() { + assert_eq!(entry.is_available(), index != 5, "entry {index} changed unexpectedly"); + } + } + + #[test] + fn codes_verify_after_realistic_transcription() { + let generated = generate(); + let code = &generated.plaintext[1]; + + for variant in [ + code.to_ascii_lowercase(), + code.replace('-', ""), + code.replace('-', " "), + format!(" {code} "), + ] { + let mut stored = generated.stored.clone(); + assert!( + matches!(consume(&mut stored, &variant, now()), ConsumeOutcome::Consumed { .. }), + "variant {variant:?} should verify" + ); + } + } + + #[test] + fn crockford_aliases_are_folded() { + // A code containing 0 or 1 must still verify when a human writes O or I. + assert_eq!(normalize("O1IL-0000-0000-0000-0000"), "0111000000000000 0000".replace(' ', "")); + assert_eq!(hash_code("o1il-0000-0000-0000-0000"), hash_code("0111-0000-0000-0000-0000")); + } + + #[test] + fn shape_detection_separates_recovery_codes_from_totp_codes() { + let generated = generate(); + + assert!(looks_like_recovery_code(&generated.plaintext[0])); + assert!(looks_like_recovery_code(&generated.plaintext[0].to_ascii_lowercase())); + assert!(!looks_like_recovery_code("123456")); + assert!(!looks_like_recovery_code("")); + // Right length, wrong alphabet. + assert!(!looks_like_recovery_code("UUUU-UUUU-UUUU-UUUU-UUUU")); + } + + #[test] + fn hashing_is_domain_separated() { + // The same bytes hashed without the domain prefix must not collide with + // this scheme's output. + let code = "ABCD-EFGH-JKMN-PQRS-TVWX"; + let mut bare = Sha256::new(); + bare.update(normalize(code).as_bytes()); + + assert_ne!(hash_code(code), hex_lower(&bare.finalize())); + } + + #[test] + fn remaining_counts_only_unused_codes() { + let generated = generate(); + let mut stored = generated.stored; + assert_eq!(remaining(&stored), RECOVERY_CODE_COUNT as u32); + + for index in 0..3 { + consume(&mut stored, &generated.plaintext[index], now()); + } + assert_eq!(remaining(&stored), (RECOVERY_CODE_COUNT - 3) as u32); + } + + #[test] + fn stored_codes_round_trip_through_serde() { + let generated = generate(); + let mut stored = generated.stored; + consume(&mut stored, &generated.plaintext[0], now()); + + let encoded = serde_json::to_string(&stored).expect("serialize"); + let decoded: Vec = serde_json::from_str(&encoded).expect("deserialize"); + + assert_eq!(decoded, stored); + assert!(!encoded.contains(&generated.plaintext[0]), "serialized form must not carry plaintext"); + } +} diff --git a/crates/iam/src/mfa/service.rs b/crates/iam/src/mfa/service.rs new file mode 100644 index 000000000..3461045f4 --- /dev/null +++ b/crates/iam/src/mfa/service.rs @@ -0,0 +1,391 @@ +// 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. + +//! The MFA operations the admin API exposes. +//! +//! This is the whole feature's surface: the console and the `rc` CLI reach it +//! through the same admin endpoints, and neither carries any of the logic. The +//! functions return `rustfs-madmin` wire types directly rather than a private +//! shape plus a mapping layer, so there is one definition of what a client sees. +//! +//! Errors are deliberately coarse at this boundary. [`MfaServiceError`] tells a +//! handler which HTTP status and which audit class to use, and nothing more: a +//! caller must not be able to tell a wrong code from a replayed one, or an +//! identity with no enrollment from one that is locked out. + +use super::record::{MfaVerification, MfaVerifyError}; +use super::totp::{TOTP_ALGORITHM, TOTP_DIGITS, TOTP_PERIOD_SECONDS, TotpSecret, provisioning_uri}; +use super::{challenge, qr, recovery, store}; +use crate::IamStore; +use crate::error::Error; +use rustfs_madmin::account::{MfaEnrollResponse, MfaStatus, RecoveryCodesResponse, UserMfaStatus}; +use std::sync::Arc; +use time::OffsetDateTime; + +/// Issuer shown by the authenticator app. +/// +/// Constant rather than derived from the deployment: apps key their entries on +/// issuer plus account, so a value that changed with the hostname would make an +/// existing enrollment look like a different account after a rename. +pub const MFA_ISSUER: &str = "RustFS"; + +/// What went wrong, at the granularity a handler needs. +#[derive(Debug, thiserror::Error)] +pub enum MfaServiceError { + #[error("two-factor authentication is not available: {0}")] + EnrollmentUnavailable(&'static str), + #[error("two-factor authentication is not enabled for this identity")] + NotEnabled, + #[error("two-factor authentication is already enabled for this identity")] + AlreadyEnabled, + #[error("no pending enrollment to confirm")] + NoPendingEnrollment, + #[error("the verification code is invalid")] + InvalidCode, + #[error("too many failed attempts; retry in {retry_after_seconds}s")] + Locked { retry_after_seconds: u64 }, + #[error("the login challenge is invalid or has expired")] + InvalidChallenge, + #[error("{0}")] + Internal(String), +} + +impl From for MfaServiceError { + fn from(value: Error) -> Self { + Self::Internal(value.to_string()) + } +} + +/// The audit class for a failure, so every handler classifies identically. +impl MfaServiceError { + pub const fn audit_class(&self) -> &'static str { + match self { + Self::EnrollmentUnavailable(_) => "enrollment_unavailable", + Self::NotEnabled | Self::NoPendingEnrollment => "not_enrolled", + Self::AlreadyEnabled => "already_enabled", + Self::InvalidCode => "invalid_code", + Self::Locked { .. } => "rate_limited", + Self::InvalidChallenge => "challenge_invalid", + Self::Internal(_) => "internal_error", + } + } +} + +fn map_verify_error(error: MfaVerifyError) -> MfaServiceError { + match error { + MfaVerifyError::NotEnabled => MfaServiceError::NotEnabled, + MfaVerifyError::Locked { retry_after_seconds } => MfaServiceError::Locked { retry_after_seconds }, + // Wrong, replayed and malformed all collapse to one answer on the wire. + // The distinction survives only in the audit trail, which the caller + // reads from `MfaVerification`/the record, not from this error. + MfaVerifyError::InvalidCode | MfaVerifyError::ReplayedTotpCode | MfaVerifyError::ReplayedRecoveryCode => { + MfaServiceError::InvalidCode + } + } +} + +/// Report the caller's own MFA state. +pub async fn status(api: Arc, access_key: &str, now: OffsetDateTime) -> Result { + let loaded = store::load(api, access_key, now).await?; + let record = loaded.record; + let available = store::at_rest_protection_available(); + + Ok(MfaStatus { + enabled: record.is_enabled(), + pending: record.has_pending_enrollment(now), + algorithm: record.algorithm.clone(), + digits: record.digits, + period_seconds: record.period_seconds, + activated_at: record.activated_at, + pending_expires_at: record + .has_pending_enrollment(now) + .then_some(record.pending_expires_at) + .flatten(), + recovery_codes_remaining: record.recovery_codes_remaining(), + last_verified_at: record.last_verified_at, + enrollment_available: available, + enrollment_blocked_reason: (!available).then(|| store::ENROLLMENT_UNAVAILABLE_REASON.to_string()), + }) +} + +/// Report another identity's MFA state, for an administrator. +/// +/// Narrower than [`status`] on purpose: an administrator inspecting someone +/// else's account has no need for their enrollment internals. +pub async fn admin_status(api: Arc, access_key: &str, now: OffsetDateTime) -> Result { + let record = store::load(api, access_key, now).await?.record; + + Ok(UserMfaStatus { + access_key: access_key.to_string(), + enabled: record.is_enabled(), + activated_at: record.activated_at, + recovery_codes_remaining: record.recovery_codes_remaining(), + }) +} + +/// Whether `access_key` must present a second factor. +/// +/// The login path's gate. Propagates a store failure rather than answering +/// `false`, so an outage cannot silently disable the second factor. +pub async fn is_enabled(api: Arc, access_key: &str, now: OffsetDateTime) -> Result { + Ok(store::is_enabled(api, access_key, now).await?) +} + +/// Begin an enrollment and return everything needed to complete it. +/// +/// Calling this on an already-enrolled identity is a re-configuration, not an +/// error: the active factor keeps working until the new one is confirmed. +pub async fn enroll(api: Arc, access_key: &str, now: OffsetDateTime) -> Result { + if !store::at_rest_protection_available() { + return Err(MfaServiceError::EnrollmentUnavailable(store::ENROLLMENT_UNAVAILABLE_REASON)); + } + + let secret = TotpSecret::generate(); + let uri = provisioning_uri(MFA_ISSUER, access_key, &secret); + let rendered = qr::render(&uri).map_err(|err| MfaServiceError::Internal(err.to_string()))?; + + let secret_for_store = secret.clone(); + let expires_at = store::update(api, access_key, now, move |record| { + record.begin_enrollment(&secret_for_store, now); + Ok::<_, MfaServiceError>(record.pending_expires_at) + }) + .await?; + + let expires_at = expires_at.ok_or_else(|| MfaServiceError::Internal("enrollment expiry was not recorded".to_string()))?; + + Ok(MfaEnrollResponse { + secret_base32: secret.to_base32(), + otpauth_uri: uri, + qr_svg: rendered.svg, + qr_utf8: rendered.utf8, + algorithm: TOTP_ALGORITHM.to_string(), + digits: TOTP_DIGITS, + period_seconds: TOTP_PERIOD_SECONDS, + expires_at, + }) +} + +/// Confirm a pending enrollment and issue the first set of recovery codes. +/// +/// The codes are returned once and never again: only their hashes are stored. +pub async fn activate( + api: Arc, + access_key: &str, + code: &str, + now: OffsetDateTime, +) -> Result { + let code = code.to_string(); + + let plaintext = store::update(api, access_key, now, move |record| { + if !record.has_pending_enrollment(now) { + return Err(MfaServiceError::NoPendingEnrollment); + } + + record.activate_enrollment(&code, now).map_err(map_verify_error)?; + + // Activation always replaces the recovery codes. Reusing a previous set + // would leave codes valid for a secret they were never issued against. + let generated = recovery::generate(); + record.set_recovery_codes(generated.stored, now); + Ok(generated.plaintext) + }) + .await?; + + Ok(RecoveryCodesResponse { + recovery_codes: plaintext, + generated_at: now, + }) +} + +/// Replace the recovery codes, after proving possession of the second factor. +pub async fn regenerate_recovery_codes( + api: Arc, + access_key: &str, + code: &str, + now: OffsetDateTime, +) -> Result { + let code = code.to_string(); + + let plaintext = store::update(api, access_key, now, move |record| { + if !record.is_enabled() { + return Err(MfaServiceError::NotEnabled); + } + + record.verify(&code, now).map_err(map_verify_error)?; + + let generated = recovery::generate(); + record.set_recovery_codes(generated.stored, now); + Ok(generated.plaintext) + }) + .await?; + + Ok(RecoveryCodesResponse { + recovery_codes: plaintext, + generated_at: now, + }) +} + +/// Turn the second factor off, after proving possession of it. +/// +/// The caller is expected to have already re-verified the account password: +/// this function checks the factor, not the identity. +pub async fn disable(api: Arc, access_key: &str, code: &str, now: OffsetDateTime) -> Result<(), MfaServiceError> { + let code = code.to_string(); + + store::update(api, access_key, now, move |record| { + if !record.is_enabled() { + return Err(MfaServiceError::NotEnabled); + } + + record.verify(&code, now).map_err(map_verify_error)?; + + record.disable(); + Ok(()) + }) + .await +} + +/// Clear an identity's second factor administratively. +/// +/// The break-glass path for a user who lost both their authenticator and their +/// recovery codes. Deletes the record outright rather than disabling it, so no +/// stale lockout counter survives to block the user's next enrollment. +pub async fn admin_reset(api: Arc, access_key: &str) -> Result<(), MfaServiceError> { + Ok(store::delete(api, access_key).await?) +} + +/// Verify a second factor during session minting. +/// +/// Returns which factor satisfied it, so the caller can audit a recovery-code +/// login differently from a routine one and warn when the codes run low. +pub async fn verify( + api: Arc, + access_key: &str, + code: &str, + now: OffsetDateTime, +) -> Result { + let code = code.to_string(); + + store::update(api, access_key, now, move |record| record.verify(&code, now).map_err(map_verify_error)).await +} + +/// Issue a login challenge for `access_key`. +pub fn issue_challenge(access_key: &str, now: OffsetDateTime, signing_key: &[u8]) -> String { + challenge::issue(access_key, now.unix_timestamp().max(0) as u64, signing_key) +} + +/// Validate a login challenge presented alongside a second factor. +pub fn validate_challenge( + challenge_token: &str, + access_key: &str, + now: OffsetDateTime, + signing_key: &[u8], +) -> Result<(), MfaServiceError> { + challenge::validate(challenge_token, access_key, now.unix_timestamp().max(0) as u64, signing_key) + .map_err(|_| MfaServiceError::InvalidChallenge) +} + +/// How long an issued challenge remains valid. +pub const fn challenge_ttl_seconds() -> u64 { + challenge::CHALLENGE_TTL_SECONDS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrong_replayed_and_malformed_codes_are_indistinguishable_on_the_wire() { + // The security property: a caller probing the endpoint must not be able + // to tell that a code was *correct but replayed*, which would confirm + // they had captured a real code. + for error in [ + MfaVerifyError::InvalidCode, + MfaVerifyError::ReplayedTotpCode, + MfaVerifyError::ReplayedRecoveryCode, + ] { + let mapped = map_verify_error(error); + assert!(matches!(mapped, MfaServiceError::InvalidCode), "{error:?} leaked as {mapped}"); + assert_eq!(mapped.to_string(), "the verification code is invalid"); + } + } + + #[test] + fn a_lockout_reports_its_retry_hint() { + let mapped = map_verify_error(MfaVerifyError::Locked { + retry_after_seconds: 900, + }); + assert!(matches!( + mapped, + MfaServiceError::Locked { + retry_after_seconds: 900 + } + )); + assert_eq!(mapped.audit_class(), "rate_limited"); + } + + #[test] + fn verification_failures_map_to_their_audit_classes() { + for (error, expected) in [ + (MfaVerifyError::InvalidCode, "invalid_code"), + (MfaVerifyError::ReplayedTotpCode, "invalid_code"), + (MfaVerifyError::ReplayedRecoveryCode, "invalid_code"), + (MfaVerifyError::NotEnabled, "not_enrolled"), + (MfaVerifyError::Locked { retry_after_seconds: 60 }, "rate_limited"), + ] { + assert_eq!(map_verify_error(error).audit_class(), expected, "for {error:?}"); + } + } + + #[test] + fn a_storage_error_stays_internal() { + // A disk or quorum failure must not be reported to the user as a bad + // code, or they will keep retrying a request that cannot succeed. + let mapped = MfaServiceError::from(Error::other("erasure set is offline")); + + assert!(matches!(mapped, MfaServiceError::Internal(_))); + assert_eq!(mapped.audit_class(), "internal_error"); + } + + #[test] + fn the_issuer_is_stable() { + // Authenticator apps key entries on issuer plus account; a value that + // moved with the hostname would orphan existing enrollments. + assert_eq!(MFA_ISSUER, "RustFS"); + } + + #[test] + fn challenges_issued_here_validate_here() { + let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp"); + let key = b"signing-key"; + let token = issue_challenge("sinan", now, key); + + assert!(validate_challenge(&token, "sinan", now, key).is_ok()); + assert!(matches!( + validate_challenge(&token, "someone-else", now, key), + Err(MfaServiceError::InvalidChallenge) + )); + } + + #[test] + fn every_error_has_a_distinct_audit_class_where_it_matters() { + // Enrollment-unavailable and locked must not be reported as a bad code: + // both are actionable by the operator or the user, and conflating them + // with a wrong code hides the remedy. + assert_eq!(MfaServiceError::EnrollmentUnavailable("x").audit_class(), "enrollment_unavailable"); + assert_eq!(MfaServiceError::Locked { retry_after_seconds: 1 }.audit_class(), "rate_limited"); + assert_eq!(MfaServiceError::InvalidCode.audit_class(), "invalid_code"); + assert_eq!(MfaServiceError::InvalidChallenge.audit_class(), "challenge_invalid"); + } +} diff --git a/crates/iam/src/mfa/store.rs b/crates/iam/src/mfa/store.rs new file mode 100644 index 000000000..aed4f7dee --- /dev/null +++ b/crates/iam/src/mfa/store.rs @@ -0,0 +1,268 @@ +// 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. + +//! Persistence for MFA records. +//! +//! Records live in the same object store and under the same at-rest encryption +//! as IAM identities, one object per identity: +//! +//! ```text +//! .rustfs.sys/config/mfa//totp.json +//! ``` +//! +//! # Why `config/mfa/` and not `config/iam/mfa/` +//! +//! The IAM cache loader walks the whole of `config/iam/` on startup and buckets +//! every key it finds by its first path segment. A new prefix under there would +//! be swept into that walk for no benefit, so MFA records sit in a sibling +//! prefix. They still go through the IAM at-rest crypto, which is the part worth +//! sharing. +//! +//! # Concurrency +//! +//! Every mutation is a read-modify-write, and two of them racing would lose an +//! update — including, in the worst case, a replay high-water mark or a spent +//! recovery code. Rather than holding a distributed lock across the round trip, +//! writes carry the ETag they read as an `If-Match` precondition and retry when +//! the store reports a conflict. That is the same optimistic scheme the IAM +//! lazy-rewrite path already uses, and it degrades to a retry rather than to a +//! held lock that a crashed node would have to time out. +//! +//! # No caching +//! +//! Records are read from the store on every verification. A cache would need +//! cluster-wide invalidation to keep the replay mark and the lockout counter +//! honest, and getting that wrong reopens exactly the holes this module exists +//! to close. Verifications are rare enough that the read is not worth +//! optimising. + +use super::record::MfaRecord; +use crate::error::{Error, Result, is_err_config_not_found}; +use crate::storage_api::object_store::HTTPPreconditions; +use crate::store::object::{decrypt_iam_blob, encrypt_iam_blob}; +use crate::{ + IAM_CONFIG_ROOT_PREFIX, IamStorageError, IamStore, delete_iam_config, keyring, read_iam_config_with_metadata, + save_iam_config_with_opts, +}; +use std::sync::Arc; +use time::OffsetDateTime; +use tracing::warn; + +type IamObjectOptions = ::ObjectOptions; + +/// Attempts before a contended write gives up. +/// +/// A conflict means another request for the *same identity* wrote first, which +/// is rare; more than a few in a row means something is wrong rather than busy. +const MAX_WRITE_ATTEMPTS: usize = 5; + +/// Path of the record for `access_key`. +fn record_path(access_key: &str) -> String { + format!("{IAM_CONFIG_ROOT_PREFIX}/mfa/{access_key}/totp.json") +} + +/// Whether the server can protect a TOTP secret at rest. +/// +/// Enrollment is refused when this is false. A TOTP secret is credential- +/// equivalent — anyone holding it can mint valid codes forever — so writing one +/// in plaintext would let the second factor be lifted straight off a disk, +/// leaving the user with a false sense of protection. IAM identities tolerate a +/// missing master key for backward compatibility with existing deployments; +/// a new feature has no such history to honour. +pub fn at_rest_protection_available() -> bool { + keyring::encrypt_key().is_some() +} + +/// Operator-facing explanation for a refused enrollment. +pub const ENROLLMENT_UNAVAILABLE_REASON: &str = + "two-factor authentication requires RUSTFS_IAM_MASTER_KEY to be configured so the shared secret can be encrypted at rest"; + +/// A record plus the ETag it was read at, for a subsequent conditional write. +#[derive(Debug)] +pub struct LoadedRecord { + pub record: MfaRecord, + /// `None` when the record does not exist yet. + etag: Option, +} + +impl LoadedRecord { + /// Whether this identity has ever had a record written. + pub const fn is_new(&self) -> bool { + self.etag.is_none() + } +} + +/// Load the record for `access_key`, or a fresh one if none exists. +/// +/// An absent record is not an error: it is the state every identity starts in. +pub async fn load(api: Arc, access_key: &str, now: OffsetDateTime) -> Result { + let path = record_path(access_key); + + match read_iam_config_with_metadata(api, &path, &IamObjectOptions::default()).await { + Ok((data, info)) => { + let plain = decrypt_iam_blob(&data)?; + let record: MfaRecord = serde_json::from_slice(&plain).map_err(|err| { + // A record that cannot be parsed must not silently become "no + // second factor": that would turn corruption into a bypass. + warn!( + path = %path, + error = %err, + "MFA record is unreadable; refusing to treat it as absent" + ); + Error::other(format!("the stored MFA record for this identity is unreadable: {err}")) + })?; + record.validate_version().map_err(|err| Error::other(err.to_string()))?; + + Ok(LoadedRecord { record, etag: info.etag }) + } + Err(err) if is_err_config_not_found(&err.clone().into()) => Ok(LoadedRecord { + record: MfaRecord::new(access_key, now), + etag: None, + }), + Err(err) => Err(err.into()), + } +} + +/// Read-only view of whether `access_key` has an active second factor. +/// +/// Returns `false` for an identity with no record, and propagates a read +/// failure rather than defaulting: the login path calls this to decide whether +/// to demand a second factor, and a store outage must not be an automatic +/// bypass. +pub async fn is_enabled(api: Arc, access_key: &str, now: OffsetDateTime) -> Result { + Ok(load(api, access_key, now).await?.record.is_enabled()) +} + +/// Apply `mutate` to the identity's record and persist the result. +/// +/// Retries on a lost race, re-reading so the mutation always applies to current +/// state. `mutate` may therefore run more than once and must not have side +/// effects of its own. +/// +/// Generic over the closure's error so a caller can return its own typed +/// failure — a rejected code, say — without encoding it into a string and +/// decoding it on the way out. Storage failures arrive through `From`. +pub async fn update( + api: Arc, + access_key: &str, + now: OffsetDateTime, + mut mutate: F, +) -> std::result::Result +where + F: FnMut(&mut MfaRecord) -> std::result::Result, + E: From, +{ + let path = record_path(access_key); + + for attempt in 1..=MAX_WRITE_ATTEMPTS { + let loaded = load(api.clone(), access_key, now).await.map_err(E::from)?; + let mut record = loaded.record; + let outcome = mutate(&mut record)?; + + let plain = serde_json::to_vec(&record).map_err(|err| E::from(Error::other(err.to_string())))?; + let encrypted = encrypt_iam_blob(&plain).map_err(E::from)?; + + let mut opts = IamObjectOptions { + max_parity: true, + ..Default::default() + }; + // A new record must not overwrite one another request created between + // our read and our write, so an absent ETag becomes `If-None-Match: *` + // rather than an unconditional put. + opts.http_preconditions = Some(match loaded.etag { + Some(etag) => HTTPPreconditions { + if_match: Some(etag), + ..Default::default() + }, + None => HTTPPreconditions { + if_none_match: Some("*".to_string()), + ..Default::default() + }, + }); + + match save_iam_config_with_opts(api.clone(), &path, encrypted, &opts).await { + Ok(()) => return Ok(outcome), + Err(IamStorageError::PreconditionFailed) => { + warn!( + path = %path, + attempt, + "MFA record write lost a race; retrying against current state" + ); + } + Err(err) => return Err(E::from(err.into())), + } + } + + Err(E::from(Error::other( + "the MFA record for this identity is being modified concurrently; retry the request", + ))) +} + +/// Remove the record entirely. +/// +/// Used by the administrative reset, where leaving a disabled-but-present +/// record would only preserve a stale lockout counter. +pub async fn delete(api: Arc, access_key: &str) -> Result<()> { + let path = record_path(access_key); + match delete_iam_config(api, &path).await { + Ok(()) => Ok(()), + // Already absent is the desired end state. + Err(err) if is_err_config_not_found(&err.clone().into()) => Ok(()), + Err(err) => Err(err.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn record_paths_sit_beside_the_iam_tree_not_inside_it() { + // Inside `config/iam/` the IAM cache loader would sweep these into its + // startup walk; this pins them out of it. + let path = record_path("sinan"); + + assert_eq!(path, "config/mfa/sinan/totp.json"); + assert!(!path.starts_with("config/iam/")); + } + + #[test] + fn record_paths_are_scoped_per_identity() { + assert_ne!(record_path("sinan"), record_path("someone-else")); + assert!(record_path("sinan").contains("/sinan/")); + } + + #[test] + fn the_unavailable_reason_names_the_variable_an_operator_must_set() { + // The message is the whole remediation path for a blocked enrollment. + assert!(ENROLLMENT_UNAVAILABLE_REASON.contains("RUSTFS_IAM_MASTER_KEY")); + } + + #[test] + fn a_loaded_record_reports_whether_it_is_new() { + let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp"); + + let fresh = LoadedRecord { + record: MfaRecord::new("sinan", now), + etag: None, + }; + assert!(fresh.is_new()); + + let existing = LoadedRecord { + record: MfaRecord::new("sinan", now), + etag: Some("etag".to_string()), + }; + assert!(!existing.is_new()); + } +} diff --git a/crates/iam/src/mfa/totp.rs b/crates/iam/src/mfa/totp.rs new file mode 100644 index 000000000..62d7420d4 --- /dev/null +++ b/crates/iam/src/mfa/totp.rs @@ -0,0 +1,430 @@ +// 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. + +//! RFC 6238 time-based one-time passwords. +//! +//! Implemented here rather than pulled in, because the algorithm is short and +//! the workspace already carries every primitive it needs (`hmac`, `sha1`, +//! `rand`, `subtle`). The parameters are fixed at the values every mainstream +//! authenticator app implements — SHA-1, 6 digits, a 30-second step — because +//! an enrollment that a user cannot scan into Google Authenticator, 1Password, +//! Ente Auth or Authy is worthless regardless of how modern its hash is. +//! +//! SHA-1 here is not a collision-resistance claim: HMAC-SHA-1 remains sound as +//! a PRF, which is all TOTP asks of it. + +use data_encoding::BASE32_NOPAD; +use hmac::{Hmac, KeyInit as _, Mac}; +use rand::Rng as _; +use sha1::Sha1; +use subtle::ConstantTimeEq as _; + +type HmacSha1 = Hmac; + +/// Hash algorithm advertised in the provisioning URI. +pub const TOTP_ALGORITHM: &str = "SHA1"; + +/// Digits in a generated code. +pub const TOTP_DIGITS: u8 = 6; + +/// Length of one time step, in seconds. +pub const TOTP_PERIOD_SECONDS: u32 = 30; + +/// Shared-secret length. RFC 4226 requires at least 128 bits and recommends +/// 160, which is also the HMAC-SHA-1 block-aligned choice. +const TOTP_SECRET_BYTES: usize = 20; + +/// Steps of clock skew tolerated on either side of the current one. +/// +/// One step (±30s) is the usual compromise: it absorbs realistic phone clock +/// drift and the time a person spends typing, while keeping the number of +/// simultaneously-valid codes at three. Widening this multiplies an attacker's +/// odds per guess by the same factor. +const TOTP_SKEW_STEPS: u64 = 1; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum TotpError { + #[error("the shared secret is not valid unpadded base32")] + MalformedSecret, + #[error("the shared secret is too short")] + SecretTooShort, + #[error("the submitted code is not {TOTP_DIGITS} digits")] + MalformedCode, +} + +/// A TOTP shared secret. +/// +/// Wrapped rather than passed as a bare `String` so a secret cannot be +/// accidentally logged: [`Debug`] is redacted, and the base32 form is only +/// reachable through the explicitly-named [`Self::to_base32`]. +#[derive(Clone, PartialEq, Eq)] +pub struct TotpSecret(Vec); + +impl std::fmt::Debug for TotpSecret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("TotpSecret([REDACTED])") + } +} + +impl TotpSecret { + /// Generate a fresh secret. + /// + /// Uses the same CSPRNG the credential generator does (`rand::rng()`, a + /// thread-local ChaCha seeded from the OS), so a predictable TOTP secret + /// would require the same failure that already breaks access-key + /// generation. + pub fn generate() -> Self { + let mut bytes = vec![0u8; TOTP_SECRET_BYTES]; + rand::rng().fill_bytes(&mut bytes); + Self(bytes) + } + + /// Parse a stored or user-typed secret. + pub fn from_base32(encoded: &str) -> Result { + // Authenticator apps and humans both introduce spaces and lowercase + // when a key is transcribed by hand. + let normalized: String = encoded.chars().filter(|c| !c.is_whitespace()).collect(); + let bytes = BASE32_NOPAD + .decode(normalized.to_ascii_uppercase().as_bytes()) + .map_err(|_| TotpError::MalformedSecret)?; + + if bytes.len() < 16 { + return Err(TotpError::SecretTooShort); + } + + Ok(Self(bytes)) + } + + /// Render for manual entry into an authenticator app. + pub fn to_base32(&self) -> String { + BASE32_NOPAD.encode(&self.0) + } + + /// The code for a given time step. + fn code_at_step(&self, step: u64) -> u32 { + // `new_from_slice` only rejects keys for algorithms with a fixed key + // size; HMAC accepts any length, so this cannot fail here. + let mut mac = HmacSha1::new_from_slice(&self.0).expect("HMAC accepts keys of any length"); + mac.update(&step.to_be_bytes()); + let digest = mac.finalize().into_bytes(); + + // RFC 4226 dynamic truncation. + let offset = (digest[digest.len() - 1] & 0x0f) as usize; + let binary = u32::from_be_bytes([ + digest[offset] & 0x7f, + digest[offset + 1], + digest[offset + 2], + digest[offset + 3], + ]); + + binary % 10u32.pow(TOTP_DIGITS as u32) + } + + /// The code for a given Unix timestamp. Exposed for tests and for clients + /// that need to display the current code. + pub fn code_at(&self, unix_seconds: u64) -> String { + format_code(self.code_at_step(step_for(unix_seconds))) + } + + /// Verify `code` against `unix_seconds`, returning the time step it matched. + /// + /// The returned step is what makes replay detection possible: the caller + /// persists it and refuses any later attempt at the same step, so a code + /// observed in transit cannot be reused inside its own validity window. + /// + /// `last_used_step` rejects a match at or before a step already consumed. + /// Comparison is constant-time, and the candidate steps are all evaluated + /// so a match late in the window does not take measurably longer than one + /// early in it. + pub fn verify(&self, code: &str, unix_seconds: u64, last_used_step: Option) -> Result { + let candidate = parse_code(code)?; + let current = step_for(unix_seconds); + + let mut matched: Option = None; + for offset in -(TOTP_SKEW_STEPS as i64)..=(TOTP_SKEW_STEPS as i64) { + let Some(step) = current.checked_add_signed(offset) else { + continue; + }; + if last_used_step.is_some_and(|used| step <= used) { + continue; + } + // `bool::from(..)` on a `Choice`, not `==`: the digest-derived code + // is compared without an early exit. + if bool::from(self.code_at_step(step).ct_eq(&candidate)) && matched.is_none() { + matched = Some(step); + } + } + + matched.ok_or(TotpError::MalformedCode) + } +} + +/// Whether `code` has the shape of a TOTP code. +/// +/// Used to route a submitted second factor to TOTP or recovery-code +/// verification without asking the user which kind they typed. +pub fn looks_like_totp_code(code: &str) -> bool { + let trimmed: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + trimmed.len() == TOTP_DIGITS as usize && trimmed.bytes().all(|b| b.is_ascii_digit()) +} + +fn parse_code(code: &str) -> Result { + let trimmed: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + if trimmed.len() != TOTP_DIGITS as usize || !trimmed.bytes().all(|b| b.is_ascii_digit()) { + return Err(TotpError::MalformedCode); + } + trimmed.parse::().map_err(|_| TotpError::MalformedCode) +} + +fn format_code(code: u32) -> String { + format!("{code:0width$}", width = TOTP_DIGITS as usize) +} + +/// The time step containing `unix_seconds`. +pub fn step_for(unix_seconds: u64) -> u64 { + unix_seconds / TOTP_PERIOD_SECONDS as u64 +} + +/// The `otpauth://` provisioning URI an authenticator app scans. +/// +/// `issuer` is repeated in the label and the query parameter: apps disagree on +/// which one they read, and one that reads only the label would otherwise show +/// the account with no issuer at all. +pub fn provisioning_uri(issuer: &str, account: &str, secret: &TotpSecret) -> String { + let label = format!("{}:{}", encode_uri_component(issuer), encode_uri_component(account)); + format!( + "otpauth://totp/{label}?secret={secret}&issuer={issuer}&algorithm={TOTP_ALGORITHM}&digits={TOTP_DIGITS}&period={TOTP_PERIOD_SECONDS}", + secret = secret.to_base32(), + issuer = encode_uri_component(issuer), + ) +} + +/// Percent-encode everything outside the unreserved set. +/// +/// Hand-rolled rather than pulled from a URL crate because the input is a label +/// component, not a path: `/`, `:` and `?` must all be escaped here, which the +/// path-oriented encoders in the graph do not do. +fn encode_uri_component(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => encoded.push(byte as char), + _ => encoded.push_str(&format!("%{byte:02X}")), + } + } + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The RFC 6238 appendix B secret: the ASCII string "12345678901234567890". + fn rfc_secret() -> TotpSecret { + TotpSecret(b"12345678901234567890".to_vec()) + } + + #[test] + fn matches_rfc6238_sha1_test_vectors() { + // RFC 6238 Appendix B, the SHA-1 rows, truncated to 6 digits. + // Anchoring on the published vectors is what proves interoperability + // with authenticator apps; a self-consistent implementation could be + // wrong in exactly the same way in both directions. + let secret = rfc_secret(); + for (unix_seconds, expected_8_digits) in [ + (59u64, "94287082"), + (1_111_111_109, "07081804"), + (1_111_111_111, "14050471"), + (1_234_567_890, "89005924"), + (2_000_000_000, "69279037"), + (20_000_000_000, "65353130"), + ] { + let expected = &expected_8_digits[expected_8_digits.len() - 6..]; + assert_eq!(secret.code_at(unix_seconds), expected, "at t={unix_seconds}"); + } + } + + #[test] + fn generated_secrets_are_distinct_and_round_trip() { + let first = TotpSecret::generate(); + let second = TotpSecret::generate(); + assert_ne!(first.to_base32(), second.to_base32()); + + let parsed = TotpSecret::from_base32(&first.to_base32()).expect("round-trip"); + assert_eq!(parsed, first); + } + + #[test] + fn secret_parsing_tolerates_transcription_noise() { + let secret = rfc_secret(); + let grouped = secret + .to_base32() + .to_ascii_lowercase() + .as_bytes() + .chunks(4) + .map(|c| String::from_utf8_lossy(c).to_string()) + .collect::>() + .join(" "); + + assert_eq!(TotpSecret::from_base32(&grouped).expect("parse"), secret); + } + + #[test] + fn secret_debug_never_reveals_the_secret() { + let secret = rfc_secret(); + let rendered = format!("{secret:?}"); + assert_eq!(rendered, "TotpSecret([REDACTED])"); + assert!(!rendered.contains(&secret.to_base32())); + } + + #[test] + fn short_secrets_are_rejected() { + // 10 bytes: below the RFC 4226 128-bit floor. + let short = BASE32_NOPAD.encode(&[0u8; 10]); + assert_eq!(TotpSecret::from_base32(&short), Err(TotpError::SecretTooShort)); + } + + #[test] + fn malformed_secrets_are_rejected() { + assert_eq!(TotpSecret::from_base32("not base32!!!"), Err(TotpError::MalformedSecret)); + } + + #[test] + fn a_current_code_verifies_and_reports_its_step() { + let secret = rfc_secret(); + let now = 1_700_000_000; + let code = secret.code_at(now); + + assert_eq!(secret.verify(&code, now, None), Ok(step_for(now))); + } + + #[test] + fn codes_one_step_away_are_accepted() { + let secret = rfc_secret(); + let now = 1_700_000_000u64; + + let previous = secret.code_at(now - TOTP_PERIOD_SECONDS as u64); + let next = secret.code_at(now + TOTP_PERIOD_SECONDS as u64); + + assert_eq!(secret.verify(&previous, now, None), Ok(step_for(now) - 1)); + assert_eq!(secret.verify(&next, now, None), Ok(step_for(now) + 1)); + } + + #[test] + fn codes_two_steps_away_are_rejected() { + let secret = rfc_secret(); + let now = 1_700_000_000u64; + let stale = secret.code_at(now - 2 * TOTP_PERIOD_SECONDS as u64); + + assert_eq!(secret.verify(&stale, now, None), Err(TotpError::MalformedCode)); + } + + #[test] + fn a_consumed_step_cannot_be_replayed() { + // The core anti-replay property: a code captured in transit stays valid + // for up to 90 seconds by the clock, so the step must be burned. + let secret = rfc_secret(); + let now = 1_700_000_000u64; + let code = secret.code_at(now); + let step = secret.verify(&code, now, None).expect("first use"); + + assert_eq!(secret.verify(&code, now, Some(step)), Err(TotpError::MalformedCode)); + } + + #[test] + fn a_consumed_step_also_blocks_earlier_steps() { + // Rejecting `step <= used` rather than `step == used` closes the window + // where an attacker replays the *previous* step's code after the + // current one has been consumed. + let secret = rfc_secret(); + let now = 1_700_000_000u64; + let previous = secret.code_at(now - TOTP_PERIOD_SECONDS as u64); + let current_step = step_for(now); + + assert_eq!(secret.verify(&previous, now, Some(current_step)), Err(TotpError::MalformedCode)); + } + + #[test] + fn a_later_step_still_verifies_after_an_earlier_one_was_consumed() { + let secret = rfc_secret(); + let now = 1_700_000_000u64; + let current_step = step_for(now); + let next = secret.code_at(now + TOTP_PERIOD_SECONDS as u64); + + assert_eq!(secret.verify(&next, now, Some(current_step)), Ok(current_step + 1)); + } + + #[test] + fn wrong_codes_are_rejected() { + let secret = rfc_secret(); + let now = 1_700_000_000u64; + let correct = secret.code_at(now); + // Perturb one digit rather than using a constant, so the test cannot + // pass by coincidence. + let wrong = format_code((correct.parse::().expect("digits") + 1) % 1_000_000); + + assert_eq!(secret.verify(&wrong, now, None), Err(TotpError::MalformedCode)); + } + + #[test] + fn malformed_codes_are_rejected_without_consulting_the_secret() { + let secret = rfc_secret(); + let now = 1_700_000_000u64; + + for bad in ["", "12345", "1234567", "abcdef", "12 34 5", "-12345"] { + assert_eq!(secret.verify(bad, now, None), Err(TotpError::MalformedCode), "input {bad:?}"); + } + } + + #[test] + fn codes_keep_leading_zeros() { + // A code of 1234 must be shown and accepted as "001234"; dropping the + // padding is the classic TOTP interop bug. + assert_eq!(format_code(1234), "001234"); + assert_eq!(format_code(0), "000000"); + assert_eq!(format_code(999_999), "999999"); + } + + #[test] + fn code_shape_detection_separates_totp_from_recovery_codes() { + assert!(looks_like_totp_code("123456")); + assert!(looks_like_totp_code("123 456")); + assert!(!looks_like_totp_code("ABCD-EFGH-IJKL")); + assert!(!looks_like_totp_code("12345")); + assert!(!looks_like_totp_code("1234567")); + } + + #[test] + fn provisioning_uri_carries_the_parameters_apps_need() { + let secret = rfc_secret(); + let uri = provisioning_uri("RustFS", "sinan", &secret); + + assert!(uri.starts_with("otpauth://totp/RustFS:sinan?"), "{uri}"); + assert!(uri.contains(&format!("secret={}", secret.to_base32()))); + assert!(uri.contains("issuer=RustFS")); + assert!(uri.contains("algorithm=SHA1")); + assert!(uri.contains("digits=6")); + assert!(uri.contains("period=30")); + } + + #[test] + fn provisioning_uri_escapes_label_separators() { + // An access key may legitimately contain a colon or a slash, either of + // which would otherwise split the label and mis-attribute the entry. + let uri = provisioning_uri("Rust FS", "team:sinan/admin", &TotpSecret::generate()); + + assert!(uri.contains("otpauth://totp/Rust%20FS:team%3Asinan%2Fadmin?"), "{uri}"); + assert!(uri.contains("issuer=Rust%20FS")); + } +} diff --git a/crates/iam/src/store/object.rs b/crates/iam/src/store/object.rs index e6b023a4a..a8737015c 100644 --- a/crates/iam/src/store/object.rs +++ b/crates/iam/src/store/object.rs @@ -199,6 +199,21 @@ pub fn try_decrypt_iam_blob(data: &[u8]) -> Option> { ObjectStore::decrypt_data_with_source(data).ok().map(|outcome| outcome.plain) } +/// Encrypt a blob for at-rest storage using the IAM master key. +/// +/// Shared with [`crate::mfa`], which stores TOTP secrets under the same key so +/// there is one at-rest scheme for every credential-equivalent secret the IAM +/// domain owns, and one key to rotate. +pub(crate) fn encrypt_iam_blob(plain: &[u8]) -> Result> { + ObjectStore::encrypt_data_with_master_key(plain) +} + +/// Decrypt a blob written by [`encrypt_iam_blob`], or read a legacy plaintext +/// one, using the same key sources as the IAM load path. +pub(crate) fn decrypt_iam_blob(data: &[u8]) -> Result> { + ObjectStore::decrypt_data_with_source(data).map(|outcome| outcome.plain) +} + #[derive(Clone)] pub struct ObjectStore { object_api: Arc, diff --git a/crates/iam/src/sys.rs b/crates/iam/src/sys.rs index ca716cc47..fecbf9f5c 100644 --- a/crates/iam/src/sys.rs +++ b/crates/iam/src/sys.rs @@ -58,6 +58,15 @@ const STS_INVALIDATION_ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duratio #[cfg(test)] const STS_INVALIDATION_ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(20); +/// Concurrent STS deletions inside a single revocation batch. +const STS_REVOCATION_BATCH_CONCURRENCY: usize = 16; +/// Process-wide ceiling on in-flight STS deletions across all batches, so +/// concurrent revocations cannot exhaust the runtime the peer notifications +/// each deletion waits on. +const STS_REVOCATION_GLOBAL_LIMIT: usize = 64; +static STS_REVOCATION_PERMITS: std::sync::LazyLock = + std::sync::LazyLock::new(|| tokio::sync::Semaphore::new(STS_REVOCATION_GLOBAL_LIMIT)); + pub const MAX_SVCSESSION_POLICY_SIZE: usize = 4096; pub const SITE_REPLICATOR_SERVICE_ACCOUNT: &str = "site-replicator-0"; @@ -476,6 +485,67 @@ impl IamSys { task.await.map_err(Error::other)? } + /// Revoke a set of STS access keys, returning how many were deleted. + /// + /// An STS credential *is* the session in RustFS, so deleting it invalidates + /// the session token immediately. Deletions run concurrently under both a + /// per-batch and a process-wide cap, so a large fan-out cannot starve the + /// peer-notification path that each individual deletion depends on. + /// + /// Every key is attempted even when one fails; the first error is returned + /// afterwards so a single unreachable peer cannot silently skip the + /// remaining revocations. + /// + /// The MinIO-compatible `revoke-tokens` endpoint keeps its own + /// provider-filtered variant (`admin/handlers/idp_compat.rs`) because it + /// injects the revoke closure for testing; both funnel into + /// [`Self::delete_temp_account`]. + pub async fn revoke_sts_accounts(&self, access_keys: Vec) -> Result { + use futures::StreamExt as _; + + let results = futures::stream::iter(access_keys) + .map(|access_key| async move { + let _permit = STS_REVOCATION_PERMITS.acquire().await.map_err(Error::other)?; + self.delete_temp_account(&access_key, true).await + }) + .buffer_unordered(STS_REVOCATION_BATCH_CONCURRENCY) + .collect::>() + .await; + + let mut revoked = 0usize; + let mut first_error = None; + for result in results { + match result { + Ok(()) => revoked += 1, + Err(err) => { + if first_error.is_none() { + first_error = Some(err); + } + } + } + } + + match first_error { + Some(err) => Err(err), + None => Ok(revoked), + } + } + + /// Revoke every STS session minted from `parent_access_key`. + /// + /// Called after that identity's secret key changes: a session issued under + /// the old secret must not outlive it. Returns the number of sessions + /// revoked; zero is a normal result for an identity with no live sessions. + pub async fn revoke_sts_sessions_for_parent(&self, parent_access_key: &str) -> Result { + let sessions = self.list_sts_accounts(parent_access_key).await?; + if sessions.is_empty() { + return Ok(0); + } + + let access_keys = sessions.into_iter().map(|cred| cred.access_key).collect(); + self.revoke_sts_accounts(access_keys).await + } + #[cfg(test)] fn load_user_notification_probe( failures_before_success: usize, diff --git a/crates/madmin/src/account.rs b/crates/madmin/src/account.rs new file mode 100644 index 000000000..450f97ed1 --- /dev/null +++ b/crates/madmin/src/account.rs @@ -0,0 +1,332 @@ +// 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. + +//! Wire contract for self-service account and multi-factor authentication. +//! +//! Console and the `rc` CLI both decode these shapes, so this module is the +//! single definition of the account/MFA API surface. Adding a field here is +//! additive; renaming or removing one is a breaking change for both clients. + +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +/// Error code returned when a session-minting request needs an MFA proof. +/// +/// Emitted by `AssumeRole` when the caller's identity has TOTP enabled and the +/// request carried no `TokenCode`. Clients match on this exact string to decide +/// whether to prompt for a second factor instead of reporting a login failure. +pub const ERR_MFA_REQUIRED: &str = "MultiFactorAuthRequired"; + +/// Error code returned when too many second-factor attempts have failed. +pub const ERR_MFA_LOCKED: &str = "MultiFactorAuthLocked"; + +/// How the calling credential was established. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum IdentityType { + /// The server's bootstrap root credential. + Root, + /// A built-in IAM user stored under `config/iam/users/`. + Iam, + /// A temporary STS session credential. + Sts, + /// A service account minted from a parent identity. + ServiceAccount, +} + +impl IdentityType { + pub const fn as_str(self) -> &'static str { + match self { + Self::Root => "root", + Self::Iam => "iam", + Self::Sts => "sts", + Self::ServiceAccount => "service-account", + } + } +} + +/// Where the identity's long-term secret lives. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CredentialsSource { + /// Provisioned from the server process environment; immutable at runtime. + Env, + /// Stored in the IAM object store; mutable through the admin API. + Iam, +} + +impl CredentialsSource { + pub const fn as_str(self) -> &'static str { + match self { + Self::Env => "env", + Self::Iam => "iam", + } + } +} + +/// Which self-service mutations the server will accept for this identity. +/// +/// Clients use this to disable controls instead of letting the user submit a +/// request that is guaranteed to fail. Root credentials report `false` for both +/// because they are pinned by a process-wide `OnceLock` and feed the derived +/// internode RPC secret. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct AccountMutability { + #[serde(default)] + pub password: bool, + #[serde(default)] + pub username: bool, +} + +/// MFA state as reported alongside the account summary. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AccountMfaSummary { + #[serde(default)] + pub enabled: bool, + /// An enrollment has been started but not yet activated. + #[serde(default)] + pub pending: bool, + #[serde(with = "time::serde::rfc3339::option", default, skip_serializing_if = "Option::is_none")] + pub activated_at: Option, + #[serde(default)] + pub recovery_codes_remaining: u32, + #[serde(with = "time::serde::rfc3339::option", default, skip_serializing_if = "Option::is_none")] + pub last_verified_at: Option, + /// Server-side at-rest protection is unavailable, so enrollment is refused. + #[serde(default)] + pub enrollment_available: bool, + /// Human-readable reason when `enrollment_available` is false. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enrollment_blocked_reason: Option, +} + +/// Response of `GET /rustfs/admin/v3/account/info`. +/// +/// Describes the caller to itself. It never accepts a target parameter, so it +/// cannot be used to enumerate other identities. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SelfAccountInfo { + /// The long-term identity behind the request. For STS and service-account + /// credentials this is the parent, not the ephemeral access key. + pub access_key: String, + pub identity_type: IdentityType, + /// The ephemeral access key actually presented, when it differs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_access_key: Option, + pub is_admin: bool, + pub status: String, + #[serde(default)] + pub member_of: Vec, + #[serde(default)] + pub policies: Vec, + pub credentials_source: CredentialsSource, + pub mutable: AccountMutability, + pub mfa: AccountMfaSummary, +} + +/// Request body of `POST /rustfs/admin/v3/account/password`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChangePasswordRequest { + /// Proof of possession. Required even though the request is already signed: + /// a signature only proves the credential was used, not that the human at + /// the keyboard knows it. + pub current_secret_key: String, + pub new_secret_key: String, +} + +/// Request body of `PUT /rustfs/admin/v3/set-user-secret-key`. +/// +/// Administrative reset of another user's secret key. Unlike `add-user` this +/// preserves the target's status, policies and group memberships. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetUserSecretKeyRequest { + pub secret_key: String, +} + +/// Response of `GET /rustfs/admin/v3/account/mfa`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MfaStatus { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub pending: bool, + pub algorithm: String, + pub digits: u8, + pub period_seconds: u32, + #[serde(with = "time::serde::rfc3339::option", default, skip_serializing_if = "Option::is_none")] + pub activated_at: Option, + #[serde(with = "time::serde::rfc3339::option", default, skip_serializing_if = "Option::is_none")] + pub pending_expires_at: Option, + #[serde(default)] + pub recovery_codes_remaining: u32, + #[serde(with = "time::serde::rfc3339::option", default, skip_serializing_if = "Option::is_none")] + pub last_verified_at: Option, + #[serde(default)] + pub enrollment_available: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enrollment_blocked_reason: Option, +} + +/// Response of `POST /rustfs/admin/v3/account/mfa/enroll`. +/// +/// The secret appears here exactly once per enrollment. Clients must render it +/// and then discard it; persisting it in browser storage or a config file +/// defeats the second factor. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MfaEnrollResponse { + /// Base32 (RFC 4648, unpadded) shared secret for manual entry. + pub secret_base32: String, + /// `otpauth://totp/...` provisioning URI for QR scanning. + pub otpauth_uri: String, + /// Server-rendered QR as a standalone SVG document. Rendered by the server + /// so neither client needs its own QR encoder. + pub qr_svg: String, + /// Server-rendered QR as Unicode half-block art for terminals. + pub qr_utf8: String, + pub algorithm: String, + pub digits: u8, + pub period_seconds: u32, + #[serde(with = "time::serde::rfc3339")] + pub expires_at: OffsetDateTime, +} + +/// Request body carrying a single second-factor code. +/// +/// Accepts either a TOTP digit code or a recovery code; the server decides +/// which by format, so clients do not need to classify user input. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MfaCodeRequest { + pub code: String, +} + +/// Request body of `DELETE /rustfs/admin/v3/account/mfa`. +/// +/// Turning off the second factor is a step-up operation: a hijacked browser +/// session holding only STS credentials must not be able to do it silently. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MfaDisableRequest { + pub code: String, + pub current_secret_key: String, +} + +/// Response of MFA activation and recovery-code regeneration. +/// +/// This is the only place recovery codes appear in plaintext; the server keeps +/// only their hashes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecoveryCodesResponse { + pub recovery_codes: Vec, + #[serde(with = "time::serde::rfc3339")] + pub generated_at: OffsetDateTime, +} + +/// Response of `GET /rustfs/admin/v3/mfa/challenge`. +/// +/// Requires a valid signature, so a caller only ever learns the MFA state of +/// the identity whose secret key it already holds. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MfaChallengeResponse { + pub required: bool, + /// Opaque, signed, time-bound value to echo back as `SerialNumber`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub challenge: Option, + #[serde(with = "time::serde::rfc3339::option", default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, +} + +/// Response of `GET /rustfs/admin/v3/user/mfa?accessKey=...`. +/// +/// Deliberately narrower than [`MfaStatus`]: an administrator inspecting +/// someone else's account has no need for their enrollment internals. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserMfaStatus { + pub access_key: String, + pub enabled: bool, + #[serde(with = "time::serde::rfc3339::option", default, skip_serializing_if = "Option::is_none")] + pub activated_at: Option, + #[serde(default)] + pub recovery_codes_remaining: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_type_wire_values_are_kebab_case() { + assert_eq!(serde_json::to_string(&IdentityType::Root).expect("serialize"), "\"root\""); + assert_eq!( + serde_json::to_string(&IdentityType::ServiceAccount).expect("serialize"), + "\"service-account\"" + ); + assert_eq!(IdentityType::ServiceAccount.as_str(), "service-account"); + } + + #[test] + fn credentials_source_wire_values_are_stable() { + assert_eq!(serde_json::to_string(&CredentialsSource::Env).expect("serialize"), "\"env\""); + assert_eq!(serde_json::to_string(&CredentialsSource::Iam).expect("serialize"), "\"iam\""); + } + + #[test] + fn account_info_round_trips() { + let info = SelfAccountInfo { + access_key: "sinan".to_string(), + identity_type: IdentityType::Iam, + session_access_key: Some("temp".to_string()), + is_admin: true, + status: "enabled".to_string(), + member_of: vec!["ops".to_string()], + policies: vec!["consoleAdmin".to_string()], + credentials_source: CredentialsSource::Iam, + mutable: AccountMutability { + password: true, + username: false, + }, + mfa: AccountMfaSummary { + enabled: true, + recovery_codes_remaining: 7, + enrollment_available: true, + ..Default::default() + }, + }; + + let encoded = serde_json::to_string(&info).expect("serialize"); + let decoded: SelfAccountInfo = serde_json::from_str(&encoded).expect("deserialize"); + + assert_eq!(decoded.access_key, "sinan"); + assert_eq!(decoded.identity_type, IdentityType::Iam); + assert!(decoded.mutable.password); + assert!(!decoded.mutable.username); + assert_eq!(decoded.mfa.recovery_codes_remaining, 7); + } + + #[test] + fn mfa_challenge_defaults_to_not_required() { + // Older servers omit the whole payload; clients must not prompt. + let decoded: MfaChallengeResponse = serde_json::from_str("{\"required\":false}").expect("deserialize"); + assert!(!decoded.required); + assert!(decoded.challenge.is_none()); + } + + #[test] + fn mfa_status_tolerates_absent_optional_fields() { + let decoded: MfaStatus = + serde_json::from_str("{\"algorithm\":\"SHA1\",\"digits\":6,\"period_seconds\":30}").expect("deserialize"); + assert!(!decoded.enabled); + assert!(!decoded.pending); + assert_eq!(decoded.digits, 6); + assert!(decoded.activated_at.is_none()); + } +} diff --git a/crates/madmin/src/lib.rs b/crates/madmin/src/lib.rs index a9d4bd8b9..d4a4dfc6a 100644 --- a/crates/madmin/src/lib.rs +++ b/crates/madmin/src/lib.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +pub mod account; pub mod client; pub mod group; pub mod heal_commands; @@ -26,6 +27,7 @@ pub mod trace; pub mod user; pub mod utils; +pub use account::*; pub use client::*; pub use group::*; pub use info_commands::*; diff --git a/crates/s3-types/src/event_name.rs b/crates/s3-types/src/event_name.rs index dee252dbd..8e6e39f0b 100644 --- a/crates/s3-types/src/event_name.rs +++ b/crates/s3-types/src/event_name.rs @@ -105,6 +105,17 @@ pub enum EventName { KmsServiceConfigured, KmsServiceStarted, KmsServiceStopped, + + // IAM identity management-plane events. Like the KMS block above they reach + // the audit sink only, and must keep being appended last. + // + // Deliberately coarse: only two variants for the whole account/MFA surface, + // because `mask()` gives every variant its own bit in a `u64` and the budget + // is nearly spent. The specific operation lives in `AuditEntry::api.name` + // and the `iamOperation` tag, which is what a SIEM filters on anyway. + // Splitting these per-operation would need `mask()` widened first. + IamIdentityCredentialChanged, + IamIdentityAuthChallenge, } // Single event type sequential array for Everything.expand() @@ -159,7 +170,7 @@ const LAST_SINGLE_TYPE_VALUE: u32 = EventName::IntelligentTiering as u32; /// meaningful: `mask()` turns a leaf variant's discriminant `v` into the bit /// `1 << (v - 1)`, so the highest discriminant is also the highest bit index in /// use. Keep this pointing at whatever variant is declared last. -const LAST_EVENT_NAME_VALUE: u32 = EventName::KmsServiceStopped as u32; +const LAST_EVENT_NAME_VALUE: u32 = EventName::IamIdentityAuthChallenge as u32; /// `mask()` returns a `u64`, so discriminants may run from 1 to 64 inclusive. /// @@ -244,6 +255,9 @@ impl EventName { "kms:Service:Configured" => Ok(EventName::KmsServiceConfigured), "kms:Service:Started" => Ok(EventName::KmsServiceStarted), "kms:Service:Stopped" => Ok(EventName::KmsServiceStopped), + // IAM events use their own namespace for the same reason KMS does. + "iam:Identity:CredentialChanged" => Ok(EventName::IamIdentityCredentialChanged), + "iam:Identity:AuthChallenge" => Ok(EventName::IamIdentityAuthChallenge), // `Everything` has no string representation (`as_str` yields ""), so it // cannot be parsed back from a string. Every other variant round-trips. _ => Err(ParseEventNameError(s.to_string())), @@ -320,6 +334,8 @@ impl EventName { EventName::KmsServiceConfigured => "kms:Service:Configured", EventName::KmsServiceStarted => "kms:Service:Started", EventName::KmsServiceStopped => "kms:Service:Stopped", + EventName::IamIdentityCredentialChanged => "iam:Identity:CredentialChanged", + EventName::IamIdentityAuthChallenge => "iam:Identity:AuthChallenge", } } @@ -467,6 +483,14 @@ impl EventName { | EventName::KmsServiceStopped ) } + + /// Whether this is an IAM identity management-plane event. + /// + /// Mirrors [`Self::is_kms`]: these reach the audit sink only, so nothing in + /// the bucket notification path should ever select them. + pub fn is_iam(&self) -> bool { + matches!(self, EventName::IamIdentityCredentialChanged | EventName::IamIdentityAuthChallenge) + } } /// Returns the S3 notification event schema version for a given event. @@ -702,7 +726,9 @@ mod tests { | EventName::KmsKeyAccessed | EventName::KmsServiceConfigured | EventName::KmsServiceStarted - | EventName::KmsServiceStopped => {} + | EventName::KmsServiceStopped + | EventName::IamIdentityCredentialChanged + | EventName::IamIdentityAuthChallenge => {} } } @@ -770,6 +796,8 @@ mod tests { EventName::KmsServiceConfigured, EventName::KmsServiceStarted, EventName::KmsServiceStopped, + EventName::IamIdentityCredentialChanged, + EventName::IamIdentityAuthChallenge, ]; /// Every KMS management-plane event. @@ -858,7 +886,7 @@ mod tests { /// only as one element of an array someone may forget to extend. #[test] fn test_last_variant_still_gets_its_own_bit() { - let last = EventName::KmsServiceStopped; + let last = EventName::IamIdentityAuthChallenge; assert_ne!(last.mask(), 0, "the last variant's mask overflowed to zero"); assert_eq!( last.mask(), @@ -1006,6 +1034,43 @@ mod tests { } } + /// Every IAM identity management-plane event. + const IAM_EVENT_NAMES: &[EventName] = &[EventName::IamIdentityCredentialChanged, EventName::IamIdentityAuthChallenge]; + + /// IAM event names must live in their own namespace, for the same reason + /// KMS ones do: a bucket notification config must not be able to subscribe + /// to account or authentication activity. + #[test] + fn test_iam_event_names_are_outside_the_s3_and_kms_namespaces() { + for ev in IAM_EVENT_NAMES { + assert!(ev.is_iam(), "{ev} should be classified as an IAM event"); + assert!(!ev.is_kms(), "{ev} must not also claim the KMS namespace"); + assert!(ev.as_str().starts_with("iam:"), "unexpected IAM event name {:?}", ev.as_str()); + assert_eq!(EventName::parse(ev.as_str()).as_ref(), Ok(ev), "IAM event {ev} must round-trip"); + assert_eq!(ev.expand(), vec![*ev], "IAM event {ev} must expand to itself only"); + } + + for ev in ALL_EVENT_NAMES.iter().filter(|ev| !ev.is_iam()) { + assert!(!ev.as_str().starts_with("iam:"), "{ev} must not claim the IAM namespace"); + } + } + + /// Each IAM event must own a distinct mask bit that no S3 selector shares. + #[test] + fn test_iam_event_masks_do_not_collide() { + let mut seen = 0u64; + for ev in IAM_EVENT_NAMES { + let mask = ev.mask(); + assert_ne!(mask, 0, "IAM event {ev} must have a non-zero mask"); + assert_eq!(seen & mask, 0, "IAM event {ev} mask overlaps another IAM event"); + seen |= mask; + + for kms in KMS_EVENT_NAMES { + assert_eq!(kms.mask() & mask, 0, "IAM event {ev} mask collides with KMS event {kms}"); + } + } + } + /// KMS event names must live in their own namespace so that neither a /// `s3:` prefix filter nor an `s3:...:*` wildcard can select them. #[test] diff --git a/docs/operations/two-factor-auth.md b/docs/operations/two-factor-auth.md new file mode 100644 index 000000000..07f12b34b --- /dev/null +++ b/docs/operations/two-factor-auth.md @@ -0,0 +1,299 @@ +# Two-Factor Authentication + +> Scope: the self-service account surface (`/rustfs/admin/v3/account/*`), the +> login gate on `AssumeRole`, and the administrative reset +> (`/rustfs/admin/v3/user/mfa`). + +This document records what the second factor does and does not protect, and why. +The boundaries are deliberate; several of them look like gaps until the +alternative is spelled out. + +## What is protected + +TOTP gates **session minting**: the `AssumeRole` call that turns a long-term +credential into a short-lived STS session. That is the only interactive login +RustFS has — the Console holds nothing but an STS session, and obtains it by +signing an `AssumeRole` request with the access key the user typed. + +When an identity has an active enrollment: + +``` +access key + secret key + │ + ▼ +GET /v3/mfa/challenge (SigV4-signed; answers "is a factor needed?") + │ required: true + ▼ +POST / Action=AssumeRole + SerialNumber= + TokenCode=<6-digit TOTP | recovery code> + │ + ▼ +STS credentials, with the claim x-rustfs-mfa-verified: true +``` + +Without a `TokenCode`, `AssumeRole` fails with `AccessDenied` and a message +carrying the `MultiFactorAuthRequired` marker. Clients match on that marker to +prompt for a code rather than reporting a failed login — the password *was* +accepted. + +`SerialNumber` and `TokenCode` are `AssumeRole`'s own parameters, so an SDK or a +script authenticates the same way the Console does, with no RustFS-specific +protocol. + +## What is deliberately not protected + +**A request signed directly with a long-term access key is not gated.** This is +the most important boundary in the design, and it is intentional: + +- Gating it would break every script, SDK client and `rc` invocation the moment + a human enabled 2FA on their own account. An operator who turned on a security + feature would discover it by way of a production outage. +- It would add no protection. Whoever holds the secret key already has full + access to everything that identity can reach; they never need to present a + code, because they never need to mint a session. + +This is the same division AWS draws: MFA gates `AssumeRole` and is enforced for +API calls through the `aws:MultiFactorAuthPresent` policy condition, not by +refusing signed requests. + +**Consequence to state plainly:** 2FA raises the cost of a stolen *password*. It +does not contain a stolen *secret key*. Because in RustFS the password **is** the +S3 secret key (see below), those are the same string — so 2FA protects the +console login path against credential reuse and phishing, and nothing more, +until the policy-condition work lands. + +The tracked follow-up is an `aws:MultiFactorAuthPresent` condition key populated +from the `x-rustfs-mfa-verified` session claim, which would let an operator write +a policy that denies administrative actions to a session that presented no +second factor. That is the mechanism that makes 2FA meaningful for API access. + +**OIDC and Keystone sessions are not gated either.** Those identities are +authenticated by their provider; a RustFS-side TOTP enrollment would not be +consulted at login and would give a false impression of protection. MFA for a +federated identity belongs to its IdP. `CallerIdentity` reports such sessions as +`FederatedIdentity` and refuses enrollment. + +**Service-account credentials cannot manage their parent's factor.** A machine +credential must not be able to take over the human identity it was minted from. + +## Password reality: there is no password hash + +RustFS is an S3 server. SigV4 requires the server to know the secret key itself +in order to recompute a request signature, so secret keys **cannot** be hashed — +not here, and not in any S3-compatible implementation. The "password" a user +types into the Console is their S3 secret key. + +What protects it instead: + +| Protection | Mechanism | +| --- | --- | +| At rest | `RUSTFS_IAM_MASTER_KEY` + `encrypt_stream_io` (Argon2id → AES-GCM / ChaCha20-Poly1305) | +| Length floor | `is_secret_key_valid` (`SECRET_KEY_MIN_LEN`) | +| Rotation | `POST /v3/account/password`, requiring the current secret | +| Session cleanup | Every STS session minted from the identity is revoked on rotation | + +There is deliberately **no maximum length**. The previous Console capped +passwords at 40 characters, which was a client-side invention with no server +constraint behind it; capping password length is an anti-pattern. + +## At-rest protection is mandatory for TOTP secrets + +A TOTP secret is credential-equivalent: anyone holding it can mint valid codes +forever. So enrollment is **refused** when `RUSTFS_IAM_MASTER_KEY` is not +configured, rather than writing the secret in plaintext: + +``` +POST /v3/account/mfa/enroll → 501 NotImplemented +"two-factor authentication requires RUSTFS_IAM_MASTER_KEY to be configured + so the shared secret can be encrypted at rest" +``` + +IAM *identities* tolerate a missing master key for backward compatibility with +existing deployments. A new feature has no such history to honour, and a second +factor that can be lifted off a disk is worse than none, because the user +believes they have one. + +`GET /v3/account/mfa` reports `enrollment_available: false` with the reason, so +the Console and `rc` explain the remedy instead of offering a control that fails. + +## Root credentials cannot be changed at runtime + +The root identity comes from `RUSTFS_ACCESS_KEY` / `RUSTFS_SECRET_KEY` and lands +in a process-wide `OnceLock` (`crates/credentials/src/credentials.rs`). It cannot +be rotated while the server runs, and the account surface reports this as +`credentials_source: "env"` with `mutable.password: false`. + +This is not merely a missing feature. The root secret key feeds three things: + +1. **STS session token signing** (`root_credentials::token_signing_key`) — every + live session in the cluster is HMAC-signed with it. +2. **The internode RPC secret** (`derive_rpc_secret`), unless + `RUSTFS_RPC_SECRET` is set explicitly. +3. **Legacy IAM at-rest decryption** for blobs migrated from MinIO. + +Rotating it at runtime would therefore invalidate every session cluster-wide and +break node-to-node authentication. Making root mutable is a separate piece of +work with those three couplings as prerequisites; it is not a side effect of +adding a profile page. + +**Operational recommendation:** treat root as a bootstrap identity. Create a +built-in IAM user with the `consoleAdmin` policy for day-to-day administration. +That identity has a working password change and full 2FA support. + +## Rate limiting, replay and expiry + +| Control | Value | Where | +| --- | --- | --- | +| Failed attempts before lockout | 5 | `mfa/record.rs` | +| First lockout | 15 minutes, doubling per further run | `mfa/record.rs` | +| Lockout ceiling | 1 hour | so a sustained attack cannot deny the owner indefinitely | +| TOTP clock skew | ±1 step (±30s) | three codes valid at once, no more | +| TOTP replay | Consumed time step is a high-water mark; `step <= last_used` is refused | closes the ~90s window a captured code would otherwise have | +| Recovery code replay | `used_at` stamp, single use | | +| Login challenge TTL | 5 minutes | | +| Pending enrollment TTL | 10 minutes | an abandoned enrollment leaves no usable secret | + +A wrong code, a replayed code and a malformed code are **indistinguishable** on +the wire: all three return `AccessDenied` with the same message. The distinction +survives only in the audit trail, so an operator can tell a guessing attempt from +a replay without an attacker learning that a captured code was genuine. + +The lockout is stored in the record and updated under an optimistic +compare-and-set, so it holds across the cluster rather than per node. + +## Storage + +``` +.rustfs.sys/config/mfa//totp.json (encrypted with the IAM master key) +``` + +A sibling of `config/iam/`, not a child: the IAM cache loader walks the whole +`config/iam/` tree on startup and buckets what it finds by first path segment, so +a new prefix under there would be swept into that walk for no benefit. + +Records are **not cached**. Every verification reads from the store, because a +cache would need cluster-wide invalidation to keep the replay mark and the +lockout counter honest, and getting that wrong reopens exactly the holes this +design closes. Verifications are rare enough that the read is not worth +optimising. + +Writes are read-modify-write under an `If-Match` precondition with bounded +retries — the same optimistic scheme the IAM lazy-rewrite path uses. It degrades +to a retry rather than to a distributed lock a crashed node would have to time +out. + +## Login challenges are stateless + +A challenge is `HMAC-SHA256(root_secret, "rustfs-mfa-challenge:v1" ‖ access_key ‖ +issued_at)`, base64url-encoded with its payload. + +The obvious alternative is a TTL cache, the way the OIDC flow stores its PKCE +verifiers. That store is node-local, which is fine for OIDC because the whole +authorization round trip returns to the node that started it. A second factor +does not: a cluster behind a load balancer without session affinity would issue +the challenge on one node and receive the code on another, and a node-local +challenge would fail there for reasons no operator could debug. + +Statelessness costs nothing, because the challenge is not what makes the exchange +single-use — the consumed TOTP time step is. + +## Authorization model + +| Route | Gate | +| --- | --- | +| `GET /v3/account/info` | possession of the credential | +| `POST /v3/account/password` | credential **+ knowledge of the current secret** | +| `GET /v3/account/mfa` | possession of the credential | +| `POST /v3/account/mfa/enroll` | credential, and the credential kind must be mutable | +| `POST /v3/account/mfa/activate` | credential + a valid code from the pending secret | +| `POST /v3/account/mfa/disable` | credential + **a valid code and the account password** | +| `POST /v3/account/mfa/recovery-codes` | credential + a valid code | +| `GET /v3/mfa/challenge` | possession of the credential | +| `GET /v3/user/mfa` | `admin:GetUser` | +| `DELETE /v3/user/mfa` | `admin:EnableUser` | +| `PUT /v3/set-user-secret-key` | `admin:CreateUser` | + +The self-service routes carry **no admin action**. Giving them one would be wrong +in both directions: it would stop an ordinary user from changing their own +password, and it would let any holder of that action change somebody else's. +They are registered as `CredentialOnly` in the route-policy matrix. + +`POST /v3/account/password` and `POST /v3/account/mfa/disable` require a +proof-of-knowledge step because a signature only proves a credential was *used*. +The Console signs with a short-lived session, so without it a hijacked browser +tab could rewrite the account's credentials or strip its second factor. + +### Why turning the factor off needs the password too + +Requiring only a code would mean a single shoulder-surfed number, in a session +someone walked away from, is enough to remove the protection. Requiring the +password makes disabling the factor as hard as the thing the factor protects. + +### Break-glass + +`DELETE /v3/user/mfa` clears another identity's factor, for a user who lost both +their authenticator and their recovery codes. It is gated on `admin:EnableUser` +rather than a bespoke action, because that is the same capability that can +already re-enable a disabled account — anyone who can do that can already take +the identity over, so a separate action would be a distinction without a security +difference. + +The record is deleted outright rather than disabled, so no stale lockout counter +survives to block the user's next enrollment. The acting administrator is +recorded in the audit entry. + +## Recovery codes + +Ten codes, `XXXX-XXXX-XXXX-XXXX-XXXX`, 100 bits of uniform randomness each, in a +Crockford base32 alphabet with `I`, `L`, `O` and `U` removed so a handwritten +code cannot be ambiguous. + +Stored as domain-separated SHA-256 digests, **not** a password KDF. With 100 bits +of uniform randomness there is no dictionary to try and no human-chosen pattern +to exploit, so the attacks a slow KDF defends against do not apply — while a +memory-hard KDF would have to run once per stored code on every verification +attempt, turning each guess into an attacker-controlled multiple of that cost. +This is the standard treatment for high-entropy bearer tokens, and the same +reasoning is why there is no per-code salt. + +Codes are returned in plaintext exactly once. Activation always replaces the set: +reusing a previous one would leave codes valid for a secret they were never +issued against. Disabling clears them, so no live bypass survives a factor the +user believes is gone. + +## Audit + +Two `EventName` variants carry the whole surface: + +- `iam:Identity:CredentialChanged` — password rotation, enrollment, activation, + disable, recovery-code regeneration, administrative reset. +- `iam:Identity:AuthChallenge` — challenge issuance and second-factor + verification. + +The per-operation detail lives in `api.name` and the `iamOperation` tag, which is +what a SIEM filters on. The enum is coarse because `EventName::mask()` gives every +variant its own bit in a `u64` and the budget is nearly spent — 63 of 64 used +after these two. Splitting these per-operation needs `mask()` widened first. + +**Redaction:** no secret key, TOTP secret, provisioning URI, submitted code or +recovery code enters an audit entry — not even hashed, and not on the failure +paths where the submitted value would be the most tempting thing to record. +Failures are described by a closed set of static strings +(`AccountAuditFailure`), so no caller-supplied bytes can reach a log target +through this module. + +## Known limitations + +1. **2FA does not gate direct SigV4 access.** By design; see above. The fix is + the `aws:MultiFactorAuthPresent` policy condition. +2. **Root cannot rotate its own credentials at runtime.** By design; see above. +3. **GHSA-m77q-r63m-pj89 is unaffected.** STS session tokens are signed with the + root secret key, so anyone holding it can still forge a session token — + including one carrying `x-rustfs-mfa-verified`. 2FA does not close this; a + dedicated STS signing key does, and that advisory is tracked separately. +4. **Username changes are not supported for anyone.** The access key is the + primary key for policy mappings, group membership, service-account parents and + bucket-policy principals. A rename is a migration that orphans service + accounts and silently breaks bucket-policy ARNs, not an edit; + `mutable.username` is `false` for every identity. diff --git a/rustfs/src/admin/handlers/account.rs b/rustfs/src/admin/handlers/account.rs new file mode 100644 index 000000000..6378b4bb3 --- /dev/null +++ b/rustfs/src/admin/handlers/account.rs @@ -0,0 +1,518 @@ +// 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. + +//! Self-service account endpoints. +//! +//! * `GET /rustfs/admin/v3/account/info` — describe the caller to itself +//! * `POST /rustfs/admin/v3/account/password` — rotate the caller's own secret +//! +//! These act on whoever is calling rather than on a target named in the +//! request, so they carry no admin-action gate: every authenticated identity +//! may inspect and manage itself. What they do carry instead is a proof-of- +//! knowledge check, because a signature only proves that a credential was +//! *used* — the Console signs with a short-lived STS session, so a hijacked +//! browser tab could otherwise rewrite the parent identity's password without +//! ever knowing it. +//! +//! Who may rotate a secret at all is decided by +//! [`crate::admin::service::caller_identity`], not here. + +use super::account_audit::{ + AccountAuditContext, AccountAuditFailure, AccountAuditOperation, AccountAuditRecord, emit as emit_audit, +}; +use super::admin_json_response; +use super::iam_error::iam_error_to_s3_error; +use super::supervise_admin_mutation; +use crate::admin::auth::validate_admin_request; +use crate::admin::router::{AdminOperation, Operation, S3Router}; +use crate::admin::runtime_sources::{current_action_credentials, current_ready_iam_handle, object_store_from_req}; +use crate::admin::service::caller_identity::CallerIdentity; +use crate::admin::storage_api::s3::{self, Body, S3ErrorCode, S3Request, S3Response, S3Result}; +use crate::admin::utils::read_compatible_admin_body; +use crate::auth::constant_time_eq; +use crate::server::RemoteAddr; +use http::StatusCode; +use hyper::Method; +use matchit::Params; +use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE; +use rustfs_iam::mfa::service as mfa_service; +use rustfs_madmin::account::{AccountMfaSummary, ChangePasswordRequest, IdentityType, SelfAccountInfo, SetUserSecretKeyRequest}; +use rustfs_policy::auth::is_secret_key_valid; +use rustfs_policy::policy::action::{Action, AdminAction}; +use rustfs_utils::MaskedAccessKey; +use time::OffsetDateTime; +use tracing::{info, warn}; + +const LOG_COMPONENT_ADMIN: &str = "admin"; +const LOG_SUBSYSTEM_ACCOUNT: &str = "account"; +const EVENT_ADMIN_ACCOUNT_STATE: &str = "admin_account_state"; + +pub(crate) const ACCOUNT_INFO_ROUTE: &str = "/rustfs/admin/v3/account/info"; +pub(crate) const ACCOUNT_PASSWORD_ROUTE: &str = "/rustfs/admin/v3/account/password"; + +pub fn register_account_route(r: &mut S3Router) -> std::io::Result<()> { + r.insert(Method::GET, ACCOUNT_INFO_ROUTE, AdminOperation(&SelfAccountInfoHandler {}))?; + r.insert(Method::POST, ACCOUNT_PASSWORD_ROUTE, AdminOperation(&ChangeOwnPasswordHandler {}))?; + + Ok(()) +} + +/// `GET /rustfs/admin/v3/account/info` +pub struct SelfAccountInfoHandler {} + +#[async_trait::async_trait] +impl Operation for SelfAccountInfoHandler { + async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { + let caller = CallerIdentity::resolve(&req).await?; + let iam_store = + current_ready_iam_handle().map_err(|_| s3::error(S3ErrorCode::InternalError, "iam is not initialized"))?; + + // Root has no IAM record at all — `check_key` special-cases it — so its + // status and memberships are synthesized rather than looked up. + let (status, member_of) = if matches!(caller.identity_type, IdentityType::Root) { + ("enabled".to_string(), Vec::new()) + } else { + match iam_store.get_user_info(&caller.access_key).await { + Ok(info) => (info.status.as_ref().to_string(), info.member_of.unwrap_or_default()), + // A federated session has no builtin user record; that is not an + // error, it just means there is nothing builtin to report. + Err(_) => ("enabled".to_string(), Vec::new()), + } + }; + + let policies = iam_store + .policy_db_get(&caller.access_key, &caller.credentials.groups) + .await + .unwrap_or_default(); + + // Reported inline rather than behind a second round trip, so a client + // can render the whole security surface from one response. + let mfa = match object_store_from_req(&req) { + Some(store) => { + let status = mfa_service::status(store, &caller.access_key, OffsetDateTime::now_utc()) + .await + .map_err(|err| s3::error(S3ErrorCode::InternalError, format!("{err}")))?; + AccountMfaSummary { + enabled: status.enabled, + pending: status.pending, + activated_at: status.activated_at, + recovery_codes_remaining: status.recovery_codes_remaining, + last_verified_at: status.last_verified_at, + // Enrollment availability is the MFA capability, not the + // password one: a root identity may protect its console + // login even though its secret key is fixed. + enrollment_available: status.enrollment_available && caller.mfa_denial.is_none(), + enrollment_blocked_reason: match caller.mfa_denial { + Some(denial) => Some(denial.message().to_string()), + None => status.enrollment_blocked_reason, + }, + } + } + None => return Err(s3::error(S3ErrorCode::ServiceUnavailable, "the object store is not ready")), + }; + + let info = SelfAccountInfo { + access_key: caller.access_key.clone(), + identity_type: caller.identity_type, + session_access_key: caller.session_access_key.clone(), + is_admin: caller.is_owner, + status, + member_of, + policies, + credentials_source: caller.credentials_source, + mutable: caller.mutability(), + mfa, + }; + + admin_json_response(req.uri.path(), &caller.credentials.secret_key, StatusCode::OK, &info) + } +} + +/// `POST /rustfs/admin/v3/account/password` +pub struct ChangeOwnPasswordHandler {} + +#[async_trait::async_trait] +impl Operation for ChangeOwnPasswordHandler { + async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { + let caller = CallerIdentity::resolve(&req).await?; + let audit = AccountAuditContext::from_request(&req); + + if let Err(err) = caller.ensure_credential_mutation_allowed() { + emit_audit( + &audit, + AccountAuditRecord::failure( + AccountAuditOperation::ChangeOwnPassword, + &caller.access_key, + caller.identity_type, + AccountAuditFailure::NotPermittedForCredential, + ) + .with_session_access_key(caller.session_access_key.as_deref()), + ); + return Err(err); + } + + let path = req.uri.path().to_string(); + let body = + read_compatible_admin_body(req.input, MAX_ADMIN_REQUEST_BODY_SIZE, &path, &caller.credentials.secret_key).await?; + let request: ChangePasswordRequest = serde_json::from_slice(&body) + .map_err(|e| s3::error(S3ErrorCode::InvalidRequest, format!("invalid change-password request: {e}")))?; + + let iam_store = + current_ready_iam_handle().map_err(|_| s3::error(S3ErrorCode::InternalError, "iam is not initialized"))?; + + let Some(stored) = iam_store.get_user(&caller.access_key).await else { + // Reached only if the identity was deleted between authentication + // and this lookup. + emit_audit( + &audit, + AccountAuditRecord::failure( + AccountAuditOperation::ChangeOwnPassword, + &caller.access_key, + caller.identity_type, + AccountAuditFailure::Internal, + ), + ); + return Err(s3::error(S3ErrorCode::InvalidRequest, "the calling identity no longer exists")); + }; + + if !constant_time_eq(&request.current_secret_key, &stored.credentials.secret_key) { + warn!( + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_ACCOUNT, + event = EVENT_ADMIN_ACCOUNT_STATE, + action = "change_own_password", + access_key = %MaskedAccessKey(&caller.access_key), + result = "invalid_current_secret", + "admin account state" + ); + emit_audit( + &audit, + AccountAuditRecord::failure( + AccountAuditOperation::ChangeOwnPassword, + &caller.access_key, + caller.identity_type, + AccountAuditFailure::InvalidCurrentSecret, + ) + .with_session_access_key(caller.session_access_key.as_deref()), + ); + // Deliberately the same message the validation failures below use, + // so a caller cannot distinguish "wrong current password" from + // "new password rejected" by probing. + return Err(s3::error(S3ErrorCode::InvalidRequest, "the current secret key is incorrect")); + } + + if let Err(err) = validate_new_secret_key(&request) { + emit_audit( + &audit, + AccountAuditRecord::failure( + AccountAuditOperation::ChangeOwnPassword, + &caller.access_key, + caller.identity_type, + AccountAuditFailure::InvalidNewSecret, + ) + .with_session_access_key(caller.session_access_key.as_deref()), + ); + return Err(err); + } + + let access_key = caller.access_key.clone(); + let new_secret_key = request.new_secret_key.clone(); + let identity_type = caller.identity_type; + let session_access_key = caller.session_access_key.clone(); + let audit_for_task = audit.clone(); + + // Detached from request cancellation: a client that disconnects between + // the secret write and the session revocation must not leave the old + // sessions alive against a rotated secret. + let sessions_revoked = supervise_admin_mutation("change own password", async move { + let iam_store = + current_ready_iam_handle().map_err(|_| s3::error(S3ErrorCode::InternalError, "iam is not initialized"))?; + + iam_store + .set_user_secret_key(&access_key, &new_secret_key) + .await + .map_err(iam_error_to_s3_error)?; + + // Sessions minted under the old secret must not outlive it. A + // failure here is reported but does not undo the rotation: the new + // secret is already authoritative, and re-running the revocation is + // safe, whereas rolling the secret back would resurrect it. + let revoked = match iam_store.revoke_sts_sessions_for_parent(&access_key).await { + Ok(revoked) => revoked, + Err(err) => { + warn!( + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_ACCOUNT, + event = EVENT_ADMIN_ACCOUNT_STATE, + action = "change_own_password", + access_key = %MaskedAccessKey(&access_key), + result = "session_revocation_incomplete", + error = ?err, + "admin account state" + ); + 0 + } + }; + + emit_audit( + &audit_for_task, + AccountAuditRecord::success(AccountAuditOperation::ChangeOwnPassword, &access_key, identity_type) + .with_session_access_key(session_access_key.as_deref()) + .with_sessions_revoked(revoked), + ); + + info!( + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_ACCOUNT, + event = EVENT_ADMIN_ACCOUNT_STATE, + action = "change_own_password", + access_key = %MaskedAccessKey(&access_key), + sessions_revoked = revoked, + result = "changed", + "admin account state" + ); + + Ok(revoked) + }) + .await?; + + admin_json_response( + &path, + &caller.credentials.secret_key, + StatusCode::OK, + &ChangePasswordResult { + sessions_revoked: sessions_revoked as u32, + }, + ) + } +} + +/// `PUT /rustfs/admin/v3/set-user-secret-key?accessKey=…` +/// +/// Administrative reset of another identity's secret key. +/// +/// Exists because the only way to change a password before this was to re-POST +/// the whole user through `add-user`, which rewrites `status` and drops the +/// policy field along with it — a password reset that silently re-enabled a +/// disabled account. This touches the secret and nothing else. +pub struct SetUserSecretKeyHandler {} + +#[derive(Debug, serde::Deserialize, Default)] +struct SetUserSecretKeyQuery { + #[serde(rename = "accessKey", alias = "access-key")] + access_key: Option, +} + +#[async_trait::async_trait] +impl Operation for SetUserSecretKeyHandler { + async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { + let query: SetUserSecretKeyQuery = match req.uri.query() { + Some(query) => serde_urlencoded::from_str(query) + .map_err(|_| s3::error(S3ErrorCode::InvalidArgument, "failed to decode query"))?, + None => SetUserSecretKeyQuery::default(), + }; + let target = query.access_key.unwrap_or_default(); + if target.is_empty() { + return Err(s3::error(S3ErrorCode::InvalidArgument, "access key is empty")); + } + + let caller = CallerIdentity::resolve(&req).await?; + let audit = AccountAuditContext::from_request(&req); + + // The root identity is provisioned from the environment; its secret is a + // process-wide `OnceLock` that also derives the internode RPC secret, so + // there is nothing here that could change it. + if current_action_credentials().is_some_and(|root| constant_time_eq(&root.access_key, &target)) { + return Err(s3::error( + S3ErrorCode::InvalidRequest, + "the root identity is provisioned from the server environment and cannot be changed at runtime", + )); + } + + // A derived credential must not rewrite the secret of the identity it + // was minted from: the session would otherwise be able to promote + // itself into permanent control of that account. + if caller.session_access_key.is_some() && caller.access_key == target { + return Err(s3::error( + S3ErrorCode::InvalidRequest, + "cannot change the credentials of the parent identity of this session", + )); + } + + validate_admin_request( + &req.headers, + &caller.credentials, + caller.is_owner, + false, + vec![Action::AdminAction(AdminAction::CreateUserAdminAction)], + req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), + ) + .await + .inspect_err(|_| { + emit_audit( + &audit, + AccountAuditRecord::failure( + AccountAuditOperation::ResetUserPassword, + &target, + caller.identity_type, + AccountAuditFailure::AccessDenied, + ) + .with_session_access_key(Some(caller.access_key.as_str())), + ); + })?; + + let path = req.uri.path().to_string(); + let body = + read_compatible_admin_body(req.input, MAX_ADMIN_REQUEST_BODY_SIZE, &path, &caller.credentials.secret_key).await?; + let request: SetUserSecretKeyRequest = serde_json::from_slice(&body) + .map_err(|e| s3::error(S3ErrorCode::InvalidRequest, format!("invalid set-user-secret-key request: {e}")))?; + + if !is_secret_key_valid(&request.secret_key) { + return Err(s3::error(S3ErrorCode::InvalidArgument, "the new secret key is too short")); + } + + let actor = caller.access_key.clone(); + let identity_type = caller.identity_type; + let audit_for_task = audit.clone(); + + let sessions_revoked = supervise_admin_mutation("set user secret key", async move { + let iam_store = + current_ready_iam_handle().map_err(|_| s3::error(S3ErrorCode::InternalError, "iam is not initialized"))?; + + iam_store + .set_user_secret_key(&target, &request.secret_key) + .await + .map_err(iam_error_to_s3_error)?; + + let revoked = match iam_store.revoke_sts_sessions_for_parent(&target).await { + Ok(revoked) => revoked, + Err(err) => { + warn!( + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_ACCOUNT, + event = EVENT_ADMIN_ACCOUNT_STATE, + action = "set_user_secret_key", + access_key = %MaskedAccessKey(&target), + result = "session_revocation_incomplete", + error = ?err, + "admin account state" + ); + 0 + } + }; + + emit_audit( + &audit_for_task, + AccountAuditRecord::success(AccountAuditOperation::ResetUserPassword, &target, identity_type) + .with_session_access_key(Some(actor.as_str())) + .with_sessions_revoked(revoked), + ); + info!( + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_ACCOUNT, + event = EVENT_ADMIN_ACCOUNT_STATE, + action = "set_user_secret_key", + access_key = %MaskedAccessKey(&target), + actor_access_key = %MaskedAccessKey(&actor), + sessions_revoked = revoked, + result = "changed", + "admin account state" + ); + + Ok(revoked) + }) + .await?; + + admin_json_response( + &path, + &caller.credentials.secret_key, + StatusCode::OK, + &ChangePasswordResult { + sessions_revoked: sessions_revoked as u32, + }, + ) + } +} + +/// Number of sessions the rotation invalidated, so a client can tell the user +/// they have been signed out elsewhere. +#[derive(Debug, serde::Serialize)] +struct ChangePasswordResult { + sessions_revoked: u32, +} + +/// Reject a new secret that would be useless or a no-op. +fn validate_new_secret_key(request: &ChangePasswordRequest) -> S3Result<()> { + if !is_secret_key_valid(&request.new_secret_key) { + return Err(s3::error(S3ErrorCode::InvalidArgument, "the new secret key is too short")); + } + + if constant_time_eq(&request.current_secret_key, &request.new_secret_key) { + return Err(s3::error( + S3ErrorCode::InvalidArgument, + "the new secret key must differ from the current one", + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::ADMIN_PREFIX; + + fn change_request(current: &str, new: &str) -> ChangePasswordRequest { + ChangePasswordRequest { + current_secret_key: current.to_string(), + new_secret_key: new.to_string(), + } + } + + #[test] + fn new_secret_key_must_meet_the_length_floor() { + // Same floor the IAM layer enforces, checked here so the caller gets a + // useful message instead of a generic IAM error. + let err = validate_new_secret_key(&change_request("old-secret-key", "short")).expect_err("must reject"); + assert!(err.to_string().contains("too short"), "{err}"); + } + + #[test] + fn new_secret_key_must_differ_from_the_current_one() { + let err = validate_new_secret_key(&change_request("same-secret-key", "same-secret-key")).expect_err("must reject"); + assert!(err.to_string().contains("must differ"), "{err}"); + } + + #[test] + fn a_valid_rotation_passes_validation() { + validate_new_secret_key(&change_request("old-secret-key", "new-secret-key")).expect("must accept"); + } + + #[test] + fn route_constants_stay_under_the_admin_prefix() { + // The constants spell the full path so registration has a single source + // of truth; this pins them to the prefix the router canonicalises on. + assert!(ACCOUNT_INFO_ROUTE.starts_with(ADMIN_PREFIX)); + assert!(ACCOUNT_PASSWORD_ROUTE.starts_with(ADMIN_PREFIX)); + } + + #[test] + fn routes_are_registered_under_the_admin_prefix() { + let mut router: S3Router = S3Router::new(false); + register_account_route(&mut router).expect("register account routes"); + + assert!(router.contains_route(Method::GET, ACCOUNT_INFO_ROUTE)); + assert!(router.contains_route(Method::POST, ACCOUNT_PASSWORD_ROUTE)); + } +} diff --git a/rustfs/src/admin/handlers/account_audit.rs b/rustfs/src/admin/handlers/account_audit.rs new file mode 100644 index 000000000..8a0840703 --- /dev/null +++ b/rustfs/src/admin/handlers/account_audit.rs @@ -0,0 +1,426 @@ +// 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. + +//! Audit adapter for the self-service account and MFA endpoints. +//! +//! Emits onto the same pipeline as the S3 and KMS entries, so account and +//! authentication activity lands in whatever SIEM a deployment already +//! operates. Modelled on [`super::kms_audit`], which established this shape. +//! +//! # Redaction +//! +//! Nothing carried here can reconstruct a credential. Secret keys, TOTP +//! secrets, provisioning URIs, submitted codes and recovery codes never enter +//! an entry — not even hashed, and not even on the failure paths where the +//! submitted value would be the most tempting thing to record. Failures are +//! described by the [`AccountAuditFailure`] vocabulary, which is a closed set +//! of static strings, so no caller-supplied bytes can reach a log target +//! through this module. + +use crate::admin::storage_api::s3::{Body, S3Request}; +use crate::server::RemoteAddr; +use crate::storage::access::request_context_from_extensions; +use crate::storage::helper::spawn_background_with_context; +use crate::storage::request_context::RequestContext; +use hashbrown::HashMap; +use rustfs_audit::entity::{ApiDetailsBuilder, AuditEntry, AuditEntryBuilder}; +use rustfs_audit::global::AuditLogger; +use rustfs_madmin::account::IdentityType; +use rustfs_s3_types::EventName; +use rustfs_targets::get_request_user_agent; +use serde_json::Value; + +/// Audit entry schema version, shared with the S3 and KMS paths so a consumer +/// parses these entries with the parser it already has. +const AUDIT_ENTRY_VERSION: &str = "1.0"; + +/// `trigger` value marking an entry as produced by the account/MFA API. +const AUDIT_TRIGGER: &str = "account-admin"; + +/// `type` value letting a consumer separate identity entries from S3 and KMS +/// ones without enumerating operation names. +const AUDIT_ENTRY_TYPE: &str = "iam-identity"; + +/// The operations audited by this module. +/// +/// The [`EventName`] enum is deliberately coarse for IAM (two variants for the +/// whole surface, because `mask()` is nearly out of bits), so this is where the +/// per-operation detail lives. Consumers filter on `api.name` and the +/// `iamOperation` tag, both fed from here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AccountAuditOperation { + /// The caller rotated its own secret key. + ChangeOwnPassword, + /// An administrator reset another identity's secret key. + ResetUserPassword, + /// A TOTP enrollment was started. + MfaEnroll, + /// A started enrollment was confirmed and the second factor became active. + MfaActivate, + /// The second factor was turned off. + MfaDisable, + /// Recovery codes were replaced. + MfaRecoveryCodesRegenerated, + /// An administrator cleared another identity's second factor. + AdminResetUserMfa, + /// A second factor was presented during session minting. + MfaVerify, + /// A login challenge was issued because the identity requires a second + /// factor. + MfaChallengeIssued, +} + +impl AccountAuditOperation { + /// Stable operation name, recorded as `api.name`. + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::ChangeOwnPassword => "AccountChangePassword", + Self::ResetUserPassword => "AdminSetUserSecretKey", + Self::MfaEnroll => "AccountMfaEnroll", + Self::MfaActivate => "AccountMfaActivate", + Self::MfaDisable => "AccountMfaDisable", + Self::MfaRecoveryCodesRegenerated => "AccountMfaRecoveryCodes", + Self::AdminResetUserMfa => "AdminResetUserMfa", + Self::MfaVerify => "MfaVerify", + Self::MfaChallengeIssued => "MfaChallenge", + } + } + + /// Which of the two IAM event classes this operation belongs to. + const fn event(self) -> EventName { + match self { + Self::MfaVerify | Self::MfaChallengeIssued => EventName::IamIdentityAuthChallenge, + _ => EventName::IamIdentityCredentialChanged, + } + } +} + +/// Closed vocabulary of audited failure reasons. +/// +/// A closed set rather than the error message: an error rendered from request +/// data would otherwise carry that data into the audit log, and an audit log is +/// a poor place to discover a leaked code or secret. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AccountAuditFailure { + /// The submitted current secret key did not match. + InvalidCurrentSecret, + /// The submitted TOTP or recovery code did not verify. + InvalidCode, + /// Too many failed attempts; the identity is temporarily locked. + RateLimited, + /// The submitted challenge was malformed, unsigned, or for another identity. + ChallengeInvalid, + /// The authorization gate rejected the request. + AccessDenied, + /// The request was well-formed but not allowed for this credential kind. + NotPermittedForCredential, + /// The new secret key failed validation. + InvalidNewSecret, + /// No enrollment exists to act on. + NotEnrolled, + /// At-rest protection for the TOTP secret is unavailable. + EnrollmentUnavailable, + /// The operation failed for an internal reason. + Internal, +} + +impl AccountAuditFailure { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::InvalidCurrentSecret => "invalid_current_secret", + Self::InvalidCode => "invalid_code", + Self::RateLimited => "rate_limited", + Self::ChallengeInvalid => "challenge_invalid", + Self::AccessDenied => "access_denied", + Self::NotPermittedForCredential => "not_permitted_for_credential", + Self::InvalidNewSecret => "invalid_new_secret", + Self::NotEnrolled => "not_enrolled", + Self::EnrollmentUnavailable => "enrollment_unavailable", + Self::Internal => "internal_error", + } + } +} + +/// Which second factor satisfied a verification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MfaMethod { + Totp, + RecoveryCode, +} + +impl MfaMethod { + const fn as_str(self) -> &'static str { + match self { + Self::Totp => "totp", + Self::RecoveryCode => "recovery-code", + } + } +} + +/// Request-scoped context copied out of a request before it is consumed. +/// +/// Handlers take the body by value, so the fields an entry needs are captured +/// up front rather than borrowed at emit time. +#[derive(Debug, Clone, Default)] +pub(crate) struct AccountAuditContext { + remote_host: Option, + request_id: Option, + user_agent: Option, + req_path: Option, + request_context: Option, +} + +impl AccountAuditContext { + pub(crate) fn from_request(req: &S3Request) -> Self { + let user_agent = get_request_user_agent(&req.headers); + let request_context = request_context_from_extensions(&req.extensions); + + Self { + remote_host: req + .extensions + .get::>() + .and_then(|opt| opt.map(|addr| addr.0.ip().to_string())), + request_id: request_context.as_ref().map(|ctx| ctx.request_id.clone()), + user_agent: (!user_agent.is_empty()).then_some(user_agent), + req_path: Some(req.uri.path().to_string()), + request_context, + } + } +} + +/// One audited account or MFA operation. +pub(crate) struct AccountAuditRecord<'a> { + pub(crate) operation: AccountAuditOperation, + /// The durable identity the operation acted on. + pub(crate) identity: &'a str, + pub(crate) identity_type: IdentityType, + /// The credential that signed the request, when it differs from `identity`. + pub(crate) session_access_key: Option<&'a str>, + pub(crate) failure: Option, + pub(crate) mfa_method: Option, + /// Sessions invalidated as a side effect, when the operation revokes any. + pub(crate) sessions_revoked: Option, + /// Recovery codes left after the operation, when it changes the count. + pub(crate) recovery_codes_remaining: Option, +} + +impl<'a> AccountAuditRecord<'a> { + /// A successful operation on `identity`. + pub(crate) fn success(operation: AccountAuditOperation, identity: &'a str, identity_type: IdentityType) -> Self { + Self { + operation, + identity, + identity_type, + session_access_key: None, + failure: None, + mfa_method: None, + sessions_revoked: None, + recovery_codes_remaining: None, + } + } + + /// A rejected operation on `identity`. + pub(crate) fn failure( + operation: AccountAuditOperation, + identity: &'a str, + identity_type: IdentityType, + failure: AccountAuditFailure, + ) -> Self { + Self { + failure: Some(failure), + ..Self::success(operation, identity, identity_type) + } + } + + pub(crate) const fn with_session_access_key(mut self, session_access_key: Option<&'a str>) -> Self { + self.session_access_key = session_access_key; + self + } + + pub(crate) const fn with_mfa_method(mut self, method: MfaMethod) -> Self { + self.mfa_method = Some(method); + self + } + + pub(crate) const fn with_sessions_revoked(mut self, revoked: usize) -> Self { + self.sessions_revoked = Some(revoked); + self + } + + pub(crate) const fn with_recovery_codes_remaining(mut self, remaining: u32) -> Self { + self.recovery_codes_remaining = Some(remaining); + self + } +} + +/// Emit one entry, best effort. +/// +/// The operation has already completed when this is called and nothing here can +/// change its result, matching the pipeline's established semantics. +pub(crate) fn emit(context: &AccountAuditContext, record: AccountAuditRecord<'_>) { + let entry = build_entry(context, &record); + let request_context = context.request_context.clone(); + spawn_background_with_context(request_context, async move { + AuditLogger::log(entry).await; + }); +} + +fn build_entry(context: &AccountAuditContext, record: &AccountAuditRecord<'_>) -> AuditEntry { + let status = if record.failure.is_some() { "failure" } else { "success" }; + + let api = ApiDetailsBuilder::new() + .name(record.operation.as_str()) + .status(status) + .build(); + + let mut builder = AuditEntryBuilder::new(AUDIT_ENTRY_VERSION, record.operation.event(), AUDIT_TRIGGER, api) + .entry_type(AUDIT_ENTRY_TYPE) + .access_key(record.identity) + .tags(entry_tags(record)); + + // The durable identity goes in `access_key`; when a derived credential + // signed the request, `parent_user` records which one, so an investigator + // can tell "root changed its own password" from "an STS session did". + if let Some(session_access_key) = record.session_access_key { + builder = builder.parent_user(session_access_key); + } + if let Some(remote_host) = context.remote_host.as_deref() { + builder = builder.remote_host(remote_host); + } + if let Some(request_id) = context.request_id.as_deref() { + builder = builder.request_id(request_id); + } + if let Some(user_agent) = context.user_agent.as_deref() { + builder = builder.user_agent(user_agent); + } + if let Some(req_path) = context.req_path.as_deref() { + builder = builder.req_path(req_path); + } + if let Some(failure) = record.failure { + builder = builder.error(failure.as_str()); + } + + builder.build() +} + +fn entry_tags(record: &AccountAuditRecord<'_>) -> HashMap { + let mut tags = HashMap::new(); + tags.insert("iamOperation".to_string(), Value::String(record.operation.as_str().to_string())); + tags.insert("identityType".to_string(), Value::String(record.identity_type.as_str().to_string())); + + if let Some(method) = record.mfa_method { + tags.insert("mfaMethod".to_string(), Value::String(method.as_str().to_string())); + } + if let Some(revoked) = record.sessions_revoked { + tags.insert("sessionsRevoked".to_string(), Value::Number(revoked.into())); + } + if let Some(remaining) = record.recovery_codes_remaining { + tags.insert("recoveryCodesRemaining".to_string(), Value::Number(remaining.into())); + } + + tags +} + +#[cfg(test)] +mod tests { + use super::*; + + fn context() -> AccountAuditContext { + AccountAuditContext { + remote_host: Some("203.0.113.7".to_string()), + request_id: Some("req-1".to_string()), + user_agent: Some("rc/0.1".to_string()), + req_path: Some("/rustfs/admin/v3/account/password".to_string()), + request_context: None, + } + } + + #[test] + fn successful_password_change_is_recorded_as_a_credential_change() { + let entry = build_entry( + &context(), + &AccountAuditRecord::success(AccountAuditOperation::ChangeOwnPassword, "sinan", IdentityType::Iam) + .with_session_access_key(Some("TEMPKEY")) + .with_sessions_revoked(3), + ); + + assert_eq!(entry.event, EventName::IamIdentityCredentialChanged); + assert_eq!(entry.api.name.as_deref(), Some("AccountChangePassword")); + assert_eq!(entry.api.status.as_deref(), Some("success")); + assert_eq!(entry.access_key.as_deref(), Some("sinan")); + assert_eq!(entry.parent_user.as_deref(), Some("TEMPKEY")); + assert!(entry.error.is_none()); + + let tags = entry.tags.expect("tags"); + assert_eq!(tags.get("iamOperation"), Some(&Value::String("AccountChangePassword".into()))); + assert_eq!(tags.get("identityType"), Some(&Value::String("iam".into()))); + assert_eq!(tags.get("sessionsRevoked"), Some(&Value::Number(3.into()))); + } + + #[test] + fn mfa_verification_is_recorded_as_an_auth_challenge() { + let entry = build_entry( + &context(), + &AccountAuditRecord::success(AccountAuditOperation::MfaVerify, "sinan", IdentityType::Iam) + .with_mfa_method(MfaMethod::RecoveryCode) + .with_recovery_codes_remaining(9), + ); + + assert_eq!(entry.event, EventName::IamIdentityAuthChallenge); + let tags = entry.tags.expect("tags"); + assert_eq!(tags.get("mfaMethod"), Some(&Value::String("recovery-code".into()))); + assert_eq!(tags.get("recoveryCodesRemaining"), Some(&Value::Number(9.into()))); + } + + #[test] + fn failures_record_the_class_and_never_the_submitted_value() { + let entry = build_entry( + &context(), + &AccountAuditRecord::failure( + AccountAuditOperation::MfaVerify, + "sinan", + IdentityType::Iam, + AccountAuditFailure::InvalidCode, + ), + ); + + assert_eq!(entry.api.status.as_deref(), Some("failure")); + assert_eq!(entry.error.as_deref(), Some("invalid_code")); + + // The whole serialized entry must not contain anything code-shaped: the + // point of the closed failure vocabulary is that no submitted value can + // reach a log target through here. + let encoded = serde_json::to_string(&entry).expect("serialize"); + assert!(!encoded.contains("123456"), "audit entry must never echo a submitted code"); + } + + #[test] + fn every_operation_maps_to_an_iam_event() { + for operation in [ + AccountAuditOperation::ChangeOwnPassword, + AccountAuditOperation::ResetUserPassword, + AccountAuditOperation::MfaEnroll, + AccountAuditOperation::MfaActivate, + AccountAuditOperation::MfaDisable, + AccountAuditOperation::MfaRecoveryCodesRegenerated, + AccountAuditOperation::AdminResetUserMfa, + AccountAuditOperation::MfaVerify, + AccountAuditOperation::MfaChallengeIssued, + ] { + let event = operation.event(); + assert!(event.is_iam(), "{} must map to an IAM event, got {event}", operation.as_str()); + assert!(!operation.as_str().is_empty()); + } + } +} diff --git a/rustfs/src/admin/handlers/idp_compat.rs b/rustfs/src/admin/handlers/idp_compat.rs index 4138e0a62..fef9fd8cb 100644 --- a/rustfs/src/admin/handlers/idp_compat.rs +++ b/rustfs/src/admin/handlers/idp_compat.rs @@ -46,10 +46,10 @@ use crate::admin::handlers::service_account::AddServiceAccount; use crate::admin::handlers::user::ImportIam; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::runtime_sources::{current_app_context, current_ready_iam_handle, current_server_config_for_context}; -use crate::admin::utils::{encode_compatible_admin_payload, is_compat_admin_request}; +use crate::admin::utils::is_compat_admin_request; use crate::auth::{check_key_valid, get_session_token}; use crate::server::{ADMIN_PREFIX, RemoteAddr}; -use http::{HeaderMap, StatusCode}; +use http::StatusCode; use hyper::Method; use matchit::Params; use rustfs_config::DEFAULT_DELIMITER; @@ -60,7 +60,6 @@ use rustfs_madmin::{ }; use rustfs_policy::policy::action::{Action, AdminAction}; use rustfs_utils::MaskedAccessKey; -use s3s::header::CONTENT_TYPE; use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error}; use serde::Serialize; use std::{collections::HashMap, sync::LazyLock}; @@ -991,13 +990,7 @@ fn json_response( status: StatusCode, payload: &T, ) -> S3Result> { - let body = serde_json::to_vec(payload) - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize error: {e}")))?; - let (body, content_type) = encode_compatible_admin_payload(path, secret_key, body)?; - - let mut header = HeaderMap::new(); - header.insert(CONTENT_TYPE, content_type.parse().expect("valid header value")); - Ok(S3Response::with_headers((status, Body::from(body)), header)) + super::admin_json_response(path, secret_key, status, payload) } #[cfg(test)] diff --git a/rustfs/src/admin/handlers/mfa.rs b/rustfs/src/admin/handlers/mfa.rs new file mode 100644 index 000000000..90ebd7ef5 --- /dev/null +++ b/rustfs/src/admin/handlers/mfa.rs @@ -0,0 +1,747 @@ +// 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. + +//! Two-factor authentication endpoints. +//! +//! Self-service, acting on the caller: +//! +//! * `GET /rustfs/admin/v3/account/mfa` — current state +//! * `POST /rustfs/admin/v3/account/mfa/enroll` — start enrollment +//! * `POST /rustfs/admin/v3/account/mfa/activate` — confirm enrollment +//! * `POST /rustfs/admin/v3/account/mfa/disable` — turn it off +//! * `POST /rustfs/admin/v3/account/mfa/recovery-codes` — replace the codes +//! +//! Login: +//! +//! * `GET /rustfs/admin/v3/mfa/challenge` — is a factor needed? +//! +//! Administrative, acting on another identity: +//! +//! * `GET /rustfs/admin/v3/user/mfa?accessKey=…` — inspect +//! * `DELETE /rustfs/admin/v3/user/mfa?accessKey=…` — break-glass reset +//! +//! Every handler here is HTTP plumbing: authorization, deserialization, +//! serialization and audit. The state machine lives in +//! [`rustfs_iam::mfa`], so the console and the CLI exercise identical logic. +//! +//! `disable` is a `POST` rather than a `DELETE` because it carries a body — the +//! second factor *and* the account password. A `DELETE` with a signed body is +//! legal but awkward for enough HTTP clients that it is not worth the purity. + +use super::account_audit::{ + AccountAuditContext, AccountAuditFailure, AccountAuditOperation, AccountAuditRecord, MfaMethod, emit as emit_audit, +}; +use super::admin_json_response; +use crate::admin::auth::validate_admin_request; +use crate::admin::router::{AdminOperation, Operation, S3Router}; +use crate::admin::runtime_sources::{current_token_signing_key, object_store_from_req}; +use crate::admin::service::caller_identity::CallerIdentity; +use crate::admin::storage_api::runtime::ECStore; +use crate::admin::storage_api::s3::{self, Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result}; +use crate::admin::utils::read_compatible_admin_body; +use crate::auth::constant_time_eq; +use crate::server::RemoteAddr; +use http::StatusCode; +use hyper::Method; +use matchit::Params; +use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE; +use rustfs_iam::mfa::{MfaServiceError, MfaVerification, service as mfa_service}; +use rustfs_madmin::account::{MfaChallengeResponse, MfaCodeRequest, MfaDisableRequest, UserMfaStatus}; +use rustfs_policy::policy::action::{Action, AdminAction}; +use rustfs_utils::MaskedAccessKey; +use serde::Deserialize; +use std::sync::Arc; +use time::OffsetDateTime; +use tracing::{info, warn}; + +const LOG_COMPONENT_ADMIN: &str = "admin"; +const LOG_SUBSYSTEM_MFA: &str = "mfa"; +const EVENT_ADMIN_MFA_STATE: &str = "admin_mfa_state"; + +pub(crate) const ACCOUNT_MFA_ROUTE: &str = "/rustfs/admin/v3/account/mfa"; +pub(crate) const ACCOUNT_MFA_ENROLL_ROUTE: &str = "/rustfs/admin/v3/account/mfa/enroll"; +pub(crate) const ACCOUNT_MFA_ACTIVATE_ROUTE: &str = "/rustfs/admin/v3/account/mfa/activate"; +pub(crate) const ACCOUNT_MFA_DISABLE_ROUTE: &str = "/rustfs/admin/v3/account/mfa/disable"; +pub(crate) const ACCOUNT_MFA_RECOVERY_CODES_ROUTE: &str = "/rustfs/admin/v3/account/mfa/recovery-codes"; +pub(crate) const MFA_CHALLENGE_ROUTE: &str = "/rustfs/admin/v3/mfa/challenge"; +pub(crate) const USER_MFA_ROUTE: &str = "/rustfs/admin/v3/user/mfa"; + +pub fn register_mfa_route(r: &mut S3Router) -> std::io::Result<()> { + r.insert(Method::GET, ACCOUNT_MFA_ROUTE, AdminOperation(&AccountMfaStatusHandler {}))?; + r.insert(Method::POST, ACCOUNT_MFA_ENROLL_ROUTE, AdminOperation(&AccountMfaEnrollHandler {}))?; + r.insert(Method::POST, ACCOUNT_MFA_ACTIVATE_ROUTE, AdminOperation(&AccountMfaActivateHandler {}))?; + r.insert(Method::POST, ACCOUNT_MFA_DISABLE_ROUTE, AdminOperation(&AccountMfaDisableHandler {}))?; + r.insert( + Method::POST, + ACCOUNT_MFA_RECOVERY_CODES_ROUTE, + AdminOperation(&AccountMfaRecoveryCodesHandler {}), + )?; + r.insert(Method::GET, MFA_CHALLENGE_ROUTE, AdminOperation(&MfaChallengeHandler {}))?; + r.insert(Method::GET, USER_MFA_ROUTE, AdminOperation(&UserMfaStatusHandler {}))?; + r.insert(Method::DELETE, USER_MFA_ROUTE, AdminOperation(&UserMfaResetHandler {}))?; + + Ok(()) +} + +/// Map a service failure onto the wire. +/// +/// `InvalidCode` becomes `AccessDenied` with a fixed message: the service has +/// already collapsed wrong, replayed and malformed codes into one variant, and +/// the message must not reintroduce the distinction. +fn map_service_error(error: MfaServiceError) -> S3Error { + match error { + MfaServiceError::EnrollmentUnavailable(reason) => S3Error::with_message(S3ErrorCode::NotImplemented, reason.to_string()), + MfaServiceError::NotEnabled => { + S3Error::with_message(S3ErrorCode::InvalidRequest, "two-factor authentication is not enabled".to_string()) + } + MfaServiceError::NoPendingEnrollment => S3Error::with_message( + S3ErrorCode::InvalidRequest, + "there is no pending enrollment to confirm; start setup again".to_string(), + ), + MfaServiceError::AlreadyEnabled => { + S3Error::with_message(S3ErrorCode::InvalidRequest, "two-factor authentication is already enabled".to_string()) + } + MfaServiceError::InvalidCode => { + S3Error::with_message(S3ErrorCode::AccessDenied, "the verification code is invalid".to_string()) + } + // `SlowDown` is the S3 vocabulary's closest analogue to 429, and clients + // already treat it as "back off" rather than "retry immediately". + MfaServiceError::Locked { retry_after_seconds } => S3Error::with_message( + S3ErrorCode::SlowDown, + format!("too many failed attempts; try again in {retry_after_seconds} seconds"), + ), + MfaServiceError::InvalidChallenge => { + S3Error::with_message(S3ErrorCode::AccessDenied, "the login challenge is invalid or has expired".to_string()) + } + MfaServiceError::Internal(message) => S3Error::with_message(S3ErrorCode::InternalError, message), + } +} + +/// Audit class for a service failure, so every handler classifies the same way. +fn audit_failure_for(error: &MfaServiceError) -> AccountAuditFailure { + match error { + MfaServiceError::EnrollmentUnavailable(_) => AccountAuditFailure::EnrollmentUnavailable, + MfaServiceError::NotEnabled | MfaServiceError::NoPendingEnrollment => AccountAuditFailure::NotEnrolled, + MfaServiceError::AlreadyEnabled => AccountAuditFailure::NotPermittedForCredential, + MfaServiceError::InvalidCode => AccountAuditFailure::InvalidCode, + MfaServiceError::Locked { .. } => AccountAuditFailure::RateLimited, + MfaServiceError::InvalidChallenge => AccountAuditFailure::ChallengeInvalid, + MfaServiceError::Internal(_) => AccountAuditFailure::Internal, + } +} + +fn store_from_req(req: &S3Request) -> S3Result> { + object_store_from_req(req).ok_or_else(|| s3::error(S3ErrorCode::ServiceUnavailable, "the object store is not ready")) +} + +/// Resolve the caller and confirm this credential kind may manage a second +/// factor for its identity. +async fn resolve_self_service_caller(req: &S3Request) -> S3Result { + let caller = CallerIdentity::resolve(req).await?; + // The MFA capability, not the password one: a root identity may enroll a + // second factor even though its secret key is fixed for the life of the + // process. + caller.ensure_mfa_management_allowed()?; + Ok(caller) +} + +/// `GET /rustfs/admin/v3/account/mfa` +pub struct AccountMfaStatusHandler {} + +#[async_trait::async_trait] +impl Operation for AccountMfaStatusHandler { + async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { + // Reading state is allowed for every credential kind: a service account + // may need to know whether its parent is protected, even though it may + // not change that. + let caller = CallerIdentity::resolve(&req).await?; + let store = store_from_req(&req)?; + + let status = mfa_service::status(store, &caller.access_key, OffsetDateTime::now_utc()) + .await + .map_err(map_service_error)?; + + admin_json_response(req.uri.path(), &caller.credentials.secret_key, StatusCode::OK, &status) + } +} + +/// `POST /rustfs/admin/v3/account/mfa/enroll` +pub struct AccountMfaEnrollHandler {} + +#[async_trait::async_trait] +impl Operation for AccountMfaEnrollHandler { + async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { + let caller = resolve_self_service_caller(&req).await?; + let audit = AccountAuditContext::from_request(&req); + let store = store_from_req(&req)?; + + let response = match mfa_service::enroll(store, &caller.access_key, OffsetDateTime::now_utc()).await { + Ok(response) => response, + Err(err) => { + emit_audit( + &audit, + AccountAuditRecord::failure( + AccountAuditOperation::MfaEnroll, + &caller.access_key, + caller.identity_type, + audit_failure_for(&err), + ) + .with_session_access_key(caller.session_access_key.as_deref()), + ); + return Err(map_service_error(err)); + } + }; + + emit_audit( + &audit, + AccountAuditRecord::success(AccountAuditOperation::MfaEnroll, &caller.access_key, caller.identity_type) + .with_session_access_key(caller.session_access_key.as_deref()), + ); + info!( + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_MFA, + event = EVENT_ADMIN_MFA_STATE, + action = "enroll", + access_key = %MaskedAccessKey(&caller.access_key), + result = "pending", + "admin mfa state" + ); + + admin_json_response(req.uri.path(), &caller.credentials.secret_key, StatusCode::OK, &response) + } +} + +/// `POST /rustfs/admin/v3/account/mfa/activate` +pub struct AccountMfaActivateHandler {} + +#[async_trait::async_trait] +impl Operation for AccountMfaActivateHandler { + async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { + let caller = resolve_self_service_caller(&req).await?; + let audit = AccountAuditContext::from_request(&req); + let store = store_from_req(&req)?; + let path = req.uri.path().to_string(); + + let body = + read_compatible_admin_body(req.input, MAX_ADMIN_REQUEST_BODY_SIZE, &path, &caller.credentials.secret_key).await?; + let request: MfaCodeRequest = serde_json::from_slice(&body) + .map_err(|e| s3::error(S3ErrorCode::InvalidRequest, format!("invalid activation request: {e}")))?; + + let response = match mfa_service::activate(store, &caller.access_key, &request.code, OffsetDateTime::now_utc()).await { + Ok(response) => response, + Err(err) => { + warn!( + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_MFA, + event = EVENT_ADMIN_MFA_STATE, + action = "activate", + access_key = %MaskedAccessKey(&caller.access_key), + result = %err.audit_class(), + "admin mfa state" + ); + emit_audit( + &audit, + AccountAuditRecord::failure( + AccountAuditOperation::MfaActivate, + &caller.access_key, + caller.identity_type, + audit_failure_for(&err), + ) + .with_session_access_key(caller.session_access_key.as_deref()), + ); + return Err(map_service_error(err)); + } + }; + + emit_audit( + &audit, + AccountAuditRecord::success(AccountAuditOperation::MfaActivate, &caller.access_key, caller.identity_type) + .with_session_access_key(caller.session_access_key.as_deref()) + .with_recovery_codes_remaining(response.recovery_codes.len() as u32), + ); + + admin_json_response(&path, &caller.credentials.secret_key, StatusCode::OK, &response) + } +} + +/// `POST /rustfs/admin/v3/account/mfa/disable` +pub struct AccountMfaDisableHandler {} + +#[async_trait::async_trait] +impl Operation for AccountMfaDisableHandler { + async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { + let caller = resolve_self_service_caller(&req).await?; + let audit = AccountAuditContext::from_request(&req); + let store = store_from_req(&req)?; + let path = req.uri.path().to_string(); + + let body = + read_compatible_admin_body(req.input, MAX_ADMIN_REQUEST_BODY_SIZE, &path, &caller.credentials.secret_key).await?; + let request: MfaDisableRequest = serde_json::from_slice(&body) + .map_err(|e| s3::error(S3ErrorCode::InvalidRequest, format!("invalid disable request: {e}")))?; + + // Step-up: the second factor alone is not enough to remove the second + // factor. The console signs with a short-lived STS session, so a + // hijacked tab would otherwise be able to strip the protection using + // only a code shoulder-surfed once. + let iam_store = crate::admin::runtime_sources::current_ready_iam_handle() + .map_err(|_| s3::error(S3ErrorCode::InternalError, "iam is not initialized"))?; + let Some(stored) = iam_store.get_user(&caller.access_key).await else { + return Err(s3::error(S3ErrorCode::InvalidRequest, "the calling identity no longer exists")); + }; + if !constant_time_eq(&request.current_secret_key, &stored.credentials.secret_key) { + emit_audit( + &audit, + AccountAuditRecord::failure( + AccountAuditOperation::MfaDisable, + &caller.access_key, + caller.identity_type, + AccountAuditFailure::InvalidCurrentSecret, + ) + .with_session_access_key(caller.session_access_key.as_deref()), + ); + return Err(s3::error(S3ErrorCode::AccessDenied, "the current secret key is incorrect")); + } + + if let Err(err) = mfa_service::disable(store, &caller.access_key, &request.code, OffsetDateTime::now_utc()).await { + emit_audit( + &audit, + AccountAuditRecord::failure( + AccountAuditOperation::MfaDisable, + &caller.access_key, + caller.identity_type, + audit_failure_for(&err), + ) + .with_session_access_key(caller.session_access_key.as_deref()), + ); + return Err(map_service_error(err)); + } + + emit_audit( + &audit, + AccountAuditRecord::success(AccountAuditOperation::MfaDisable, &caller.access_key, caller.identity_type) + .with_session_access_key(caller.session_access_key.as_deref()), + ); + info!( + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_MFA, + event = EVENT_ADMIN_MFA_STATE, + action = "disable", + access_key = %MaskedAccessKey(&caller.access_key), + result = "disabled", + "admin mfa state" + ); + + Ok(empty_ok()) + } +} + +/// `POST /rustfs/admin/v3/account/mfa/recovery-codes` +pub struct AccountMfaRecoveryCodesHandler {} + +#[async_trait::async_trait] +impl Operation for AccountMfaRecoveryCodesHandler { + async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { + let caller = resolve_self_service_caller(&req).await?; + let audit = AccountAuditContext::from_request(&req); + let store = store_from_req(&req)?; + let path = req.uri.path().to_string(); + + let body = + read_compatible_admin_body(req.input, MAX_ADMIN_REQUEST_BODY_SIZE, &path, &caller.credentials.secret_key).await?; + let request: MfaCodeRequest = serde_json::from_slice(&body) + .map_err(|e| s3::error(S3ErrorCode::InvalidRequest, format!("invalid recovery-code request: {e}")))?; + + let response = + match mfa_service::regenerate_recovery_codes(store, &caller.access_key, &request.code, OffsetDateTime::now_utc()) + .await + { + Ok(response) => response, + Err(err) => { + emit_audit( + &audit, + AccountAuditRecord::failure( + AccountAuditOperation::MfaRecoveryCodesRegenerated, + &caller.access_key, + caller.identity_type, + audit_failure_for(&err), + ) + .with_session_access_key(caller.session_access_key.as_deref()), + ); + return Err(map_service_error(err)); + } + }; + + emit_audit( + &audit, + AccountAuditRecord::success( + AccountAuditOperation::MfaRecoveryCodesRegenerated, + &caller.access_key, + caller.identity_type, + ) + .with_session_access_key(caller.session_access_key.as_deref()) + .with_recovery_codes_remaining(response.recovery_codes.len() as u32), + ); + + admin_json_response(&path, &caller.credentials.secret_key, StatusCode::OK, &response) + } +} + +/// `GET /rustfs/admin/v3/mfa/challenge` +/// +/// Answers "does this identity need a second factor?" for a caller that has +/// already proved it holds the identity's secret key, since the request is +/// signed. That signature requirement is what keeps this from being an +/// enumeration oracle: a caller only ever learns about the identity whose +/// credentials it already has. +pub struct MfaChallengeHandler {} + +#[async_trait::async_trait] +impl Operation for MfaChallengeHandler { + async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { + let caller = CallerIdentity::resolve(&req).await?; + let audit = AccountAuditContext::from_request(&req); + let store = store_from_req(&req)?; + let now = OffsetDateTime::now_utc(); + + let required = mfa_service::is_enabled(store, &caller.access_key, now) + .await + .map_err(map_service_error)?; + + let response = if required { + let Some(signing_key) = current_token_signing_key() else { + return Err(s3::error(S3ErrorCode::InternalError, "the session signing key is not initialized")); + }; + let challenge = mfa_service::issue_challenge(&caller.access_key, now, signing_key.as_bytes()); + + emit_audit( + &audit, + AccountAuditRecord::success(AccountAuditOperation::MfaChallengeIssued, &caller.access_key, caller.identity_type) + .with_session_access_key(caller.session_access_key.as_deref()), + ); + + MfaChallengeResponse { + required: true, + challenge: Some(challenge), + expires_at: Some(now + time::Duration::seconds(mfa_service::challenge_ttl_seconds() as i64)), + } + } else { + MfaChallengeResponse { + required: false, + challenge: None, + expires_at: None, + } + }; + + admin_json_response(req.uri.path(), &caller.credentials.secret_key, StatusCode::OK, &response) + } +} + +#[derive(Debug, Deserialize, Default)] +struct UserMfaQuery { + #[serde(rename = "accessKey", alias = "access-key")] + access_key: Option, +} + +fn parse_user_mfa_query(req: &S3Request) -> S3Result { + let query: UserMfaQuery = match req.uri.query() { + Some(query) => { + serde_urlencoded::from_str(query).map_err(|_| s3::error(S3ErrorCode::InvalidArgument, "failed to decode query"))? + } + None => UserMfaQuery::default(), + }; + + let access_key = query.access_key.unwrap_or_default(); + if access_key.is_empty() { + return Err(s3::error(S3ErrorCode::InvalidArgument, "access key is empty")); + } + Ok(access_key) +} + +/// `GET /rustfs/admin/v3/user/mfa?accessKey=…` +pub struct UserMfaStatusHandler {} + +#[async_trait::async_trait] +impl Operation for UserMfaStatusHandler { + async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { + let target = parse_user_mfa_query(&req)?; + let caller = CallerIdentity::resolve(&req).await?; + + validate_admin_request( + &req.headers, + &caller.credentials, + caller.is_owner, + false, + vec![Action::AdminAction(AdminAction::GetUserAdminAction)], + req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), + ) + .await?; + + let store = store_from_req(&req)?; + let status: UserMfaStatus = mfa_service::admin_status(store, &target, OffsetDateTime::now_utc()) + .await + .map_err(map_service_error)?; + + admin_json_response(req.uri.path(), &caller.credentials.secret_key, StatusCode::OK, &status) + } +} + +/// `DELETE /rustfs/admin/v3/user/mfa?accessKey=…` +/// +/// The break-glass path: an administrator clears the second factor for a user +/// who lost both their authenticator and their recovery codes. +/// +/// Gated on `EnableUser`, not on a bespoke action, because the capability being +/// exercised is the same one that can already re-enable a disabled account — +/// anyone who can do that can already take over the identity, so a separate +/// action would be a distinction without a security difference. +pub struct UserMfaResetHandler {} + +#[async_trait::async_trait] +impl Operation for UserMfaResetHandler { + async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { + let target = parse_user_mfa_query(&req)?; + let caller = CallerIdentity::resolve(&req).await?; + let audit = AccountAuditContext::from_request(&req); + + validate_admin_request( + &req.headers, + &caller.credentials, + caller.is_owner, + false, + vec![Action::AdminAction(AdminAction::EnableUserAdminAction)], + req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), + ) + .await + .inspect_err(|_| { + emit_audit( + &audit, + AccountAuditRecord::failure( + AccountAuditOperation::AdminResetUserMfa, + &target, + caller.identity_type, + AccountAuditFailure::AccessDenied, + ) + .with_session_access_key(Some(caller.access_key.as_str())), + ); + })?; + + let store = store_from_req(&req)?; + mfa_service::admin_reset(store, &target).await.map_err(map_service_error)?; + + emit_audit( + &audit, + AccountAuditRecord::success(AccountAuditOperation::AdminResetUserMfa, &target, caller.identity_type) + // The acting administrator, recorded so a reset is always + // attributable to a person and not just to the target. + .with_session_access_key(Some(caller.access_key.as_str())), + ); + warn!( + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_MFA, + event = EVENT_ADMIN_MFA_STATE, + action = "admin_reset", + target_access_key = %MaskedAccessKey(&target), + actor_access_key = %MaskedAccessKey(&caller.access_key), + result = "reset", + "admin mfa state" + ); + + Ok(empty_ok()) + } +} + +/// Verify a second factor on behalf of the session-minting path. +/// +/// Lives here so the STS handler does not have to know the MFA service, the +/// audit vocabulary, or how a challenge is validated. +pub(crate) async fn verify_for_session( + store: Arc, + audit: &AccountAuditContext, + access_key: &str, + identity_type: rustfs_madmin::account::IdentityType, + challenge: Option<&str>, + code: &str, +) -> S3Result { + let now = OffsetDateTime::now_utc(); + + // The challenge is validated first: it is cheap, and a stale one should not + // consume an attempt against the rate limiter. + if let Some(challenge) = challenge.filter(|value| !value.is_empty()) { + let Some(signing_key) = current_token_signing_key() else { + return Err(s3::error(S3ErrorCode::InternalError, "the session signing key is not initialized")); + }; + if let Err(err) = mfa_service::validate_challenge(challenge, access_key, now, signing_key.as_bytes()) { + emit_audit( + audit, + AccountAuditRecord::failure( + AccountAuditOperation::MfaVerify, + access_key, + identity_type, + AccountAuditFailure::ChallengeInvalid, + ), + ); + return Err(map_service_error(err)); + } + } + + match mfa_service::verify(store, access_key, code, now).await { + Ok(verification) => { + let method = match verification { + MfaVerification::Totp => MfaMethod::Totp, + MfaVerification::RecoveryCode { .. } => MfaMethod::RecoveryCode, + }; + let mut record = + AccountAuditRecord::success(AccountAuditOperation::MfaVerify, access_key, identity_type).with_mfa_method(method); + if let MfaVerification::RecoveryCode { remaining } = verification { + record = record.with_recovery_codes_remaining(remaining); + warn!( + component = LOG_COMPONENT_ADMIN, + subsystem = LOG_SUBSYSTEM_MFA, + event = EVENT_ADMIN_MFA_STATE, + action = "verify", + access_key = %MaskedAccessKey(access_key), + recovery_codes_remaining = remaining, + result = "recovery_code_used", + "admin mfa state" + ); + } + emit_audit(audit, record); + Ok(verification) + } + Err(err) => { + emit_audit( + audit, + AccountAuditRecord::failure(AccountAuditOperation::MfaVerify, access_key, identity_type, audit_failure_for(&err)), + ); + Err(map_service_error(err)) + } + } +} + +fn empty_ok() -> S3Response<(StatusCode, Body)> { + let mut header = hyper::HeaderMap::new(); + header.insert(s3::header::CONTENT_LENGTH, "0".parse().expect("valid header value")); + S3Response::with_headers((StatusCode::OK, Body::empty()), header) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::ADMIN_PREFIX; + + #[test] + fn routes_are_registered() { + let mut router: S3Router = S3Router::new(false); + register_mfa_route(&mut router).expect("register mfa routes"); + + assert!(router.contains_route(Method::GET, ACCOUNT_MFA_ROUTE)); + assert!(router.contains_route(Method::POST, ACCOUNT_MFA_ENROLL_ROUTE)); + assert!(router.contains_route(Method::POST, ACCOUNT_MFA_ACTIVATE_ROUTE)); + assert!(router.contains_route(Method::POST, ACCOUNT_MFA_DISABLE_ROUTE)); + assert!(router.contains_route(Method::POST, ACCOUNT_MFA_RECOVERY_CODES_ROUTE)); + assert!(router.contains_route(Method::GET, MFA_CHALLENGE_ROUTE)); + assert!(router.contains_route(Method::GET, USER_MFA_ROUTE)); + assert!(router.contains_route(Method::DELETE, USER_MFA_ROUTE)); + } + + #[test] + fn route_constants_stay_under_the_admin_prefix() { + for route in [ + ACCOUNT_MFA_ROUTE, + ACCOUNT_MFA_ENROLL_ROUTE, + ACCOUNT_MFA_ACTIVATE_ROUTE, + ACCOUNT_MFA_DISABLE_ROUTE, + ACCOUNT_MFA_RECOVERY_CODES_ROUTE, + MFA_CHALLENGE_ROUTE, + USER_MFA_ROUTE, + ] { + assert!(route.starts_with(ADMIN_PREFIX), "{route} is outside the admin prefix"); + } + } + + #[test] + fn wrong_and_replayed_codes_produce_the_same_response() { + // The service already collapses them; this pins that the HTTP layer does + // not reintroduce the distinction through its message or status. + let first = map_service_error(MfaServiceError::InvalidCode); + let second = map_service_error(MfaServiceError::InvalidCode); + + assert_eq!(first.code(), &S3ErrorCode::AccessDenied); + assert_eq!(first.to_string(), second.to_string()); + assert!(!first.to_string().contains("replay")); + } + + #[test] + fn a_lockout_is_reported_as_backpressure_with_its_retry_hint() { + let error = map_service_error(MfaServiceError::Locked { + retry_after_seconds: 900, + }); + + assert_eq!(error.code(), &S3ErrorCode::SlowDown); + assert!(error.to_string().contains("900"), "{error}"); + } + + #[test] + fn an_unavailable_enrollment_reports_the_remedy() { + let error = map_service_error(MfaServiceError::EnrollmentUnavailable( + rustfs_iam::mfa::store::ENROLLMENT_UNAVAILABLE_REASON, + )); + + assert_eq!(error.code(), &S3ErrorCode::NotImplemented); + assert!(error.to_string().contains("RUSTFS_IAM_MASTER_KEY"), "{error}"); + } + + #[test] + fn service_failures_all_have_an_audit_class() { + for error in [ + MfaServiceError::NotEnabled, + MfaServiceError::NoPendingEnrollment, + MfaServiceError::InvalidCode, + MfaServiceError::Locked { retry_after_seconds: 1 }, + MfaServiceError::InvalidChallenge, + MfaServiceError::Internal("x".to_string()), + MfaServiceError::EnrollmentUnavailable("x"), + ] { + // Every variant must map, so a new one cannot silently audit as a + // wrong code. + let class = audit_failure_for(&error); + assert!(!class.as_str().is_empty()); + } + } + + #[test] + fn the_target_access_key_is_required_for_the_administrative_routes() { + let request = |uri: &str| S3Request { + input: Body::empty(), + method: Method::GET, + uri: uri.parse().expect("uri should parse"), + headers: hyper::HeaderMap::new(), + extensions: http::Extensions::new(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + }; + + // No query at all, and an explicitly empty value, must both be refused + // rather than resolving to some default identity. + assert!(parse_user_mfa_query(&request("http://localhost/rustfs/admin/v3/user/mfa")).is_err()); + assert!(parse_user_mfa_query(&request("http://localhost/rustfs/admin/v3/user/mfa?accessKey=")).is_err()); + assert_eq!( + parse_user_mfa_query(&request("http://localhost/rustfs/admin/v3/user/mfa?accessKey=sinan")).expect("parse"), + "sinan" + ); + } +} diff --git a/rustfs/src/admin/handlers/mod.rs b/rustfs/src/admin/handlers/mod.rs index 6822ccaa4..1495d2508 100644 --- a/rustfs/src/admin/handlers/mod.rs +++ b/rustfs/src/admin/handlers/mod.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +pub mod account; +pub(crate) mod account_audit; pub mod account_info; pub mod audit; mod audit_runtime_config; @@ -40,6 +42,7 @@ pub mod kms_key_metadata; pub mod kms_keys; pub mod kms_management; pub mod metrics; +pub mod mfa; pub mod module_switch; mod notify_runtime_access; pub mod object_data_cache; @@ -70,6 +73,27 @@ pub mod user_iam; pub mod user_lifecycle; pub mod user_policy_binding; +/// Serialize `payload` as the body of an admin JSON response. +/// +/// Routes reached through the `/minio/admin` compat prefix carry an encrypted +/// body; `encode_compatible_admin_payload` decides that from the path, so every +/// handler must go through here rather than serializing directly, or a +/// MinIO client gets plaintext where it expects ciphertext. +pub(crate) fn admin_json_response( + path: &str, + secret_key: &str, + status: http::StatusCode, + payload: &T, +) -> s3s::S3Result> { + let body = serde_json::to_vec(payload) + .map_err(|e| s3s::S3Error::with_message(s3s::S3ErrorCode::InternalError, format!("serialize error: {e}")))?; + let (body, content_type) = crate::admin::utils::encode_compatible_admin_payload(path, secret_key, body)?; + + let mut header = hyper::HeaderMap::new(); + header.insert(s3s::header::CONTENT_TYPE, content_type.parse().expect("valid header value")); + Ok(s3s::S3Response::with_headers((status, s3s::Body::from(body)), header)) +} + pub(crate) async fn supervise_admin_mutation( operation: &'static str, mutation: impl std::future::Future> + Send + 'static, diff --git a/rustfs/src/admin/handlers/sts.rs b/rustfs/src/admin/handlers/sts.rs index 9d137bb3e..7bc7acaac 100644 --- a/rustfs/src/admin/handlers/sts.rs +++ b/rustfs/src/admin/handlers/sts.rs @@ -13,6 +13,9 @@ // limitations under the License. use super::is_admin::IsAdminHandler; +use crate::admin::handlers::account_audit::AccountAuditContext; +use crate::admin::handlers::mfa::verify_for_session as mfa_verify_for_session; +use crate::admin::runtime_sources::object_store_from_req; use crate::admin::service::federated_identity::DefaultFederatedSessionBinding; use crate::admin::service::session_policy::populate_session_policy; use crate::admin::storage_api::bucket::utils::serialize; @@ -32,6 +35,8 @@ use hyper::Method; use matchit::Params; use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE; use rustfs_iam::federation::{FederatedSessionBindingError, FederationError}; +use rustfs_iam::mfa::service as mfa_service; +use rustfs_madmin::account::{ERR_MFA_REQUIRED, IdentityType}; use rustfs_madmin::{SITE_REPL_API_VERSION, SR_IAM_ITEM_STS_ACC, SRIAMItem, SRSTSCredential}; use rustfs_policy::{ auth::get_new_credentials_with_metadata, @@ -40,6 +45,7 @@ use rustfs_policy::{ action::{Action, StsAction}, }, }; +use rustfs_utils::MaskedAccessKey; use s3s::{ Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, dto::{AssumeRoleOutput, Credentials, Timestamp}, @@ -144,6 +150,15 @@ pub struct AssumeRoleRequest { pub policy: String, pub external_id: String, pub web_identity_token: String, + /// The login challenge from `GET /v3/mfa/challenge`, echoed back. + /// + /// AWS uses `SerialNumber` to name an MFA device; RustFS has one virtual + /// device per identity, so the field carries the challenge instead. It is + /// optional: a client that skips the challenge round trip and sends only a + /// `TokenCode` still authenticates. + pub serial_number: String, + /// A six-digit TOTP code or a recovery code. + pub token_code: String, } pub struct AssumeRoleHandle {} @@ -152,6 +167,11 @@ impl Operation for AssumeRoleHandle { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { debug!("handle AssumeRoleHandle"); + // Captured before the body is consumed: the second-factor gate needs the + // object store, and its audit entries need the request metadata. + let store = object_store_from_req(&req); + let audit = AccountAuditContext::from_request(&req); + let mut input = req.input; let bytes = match input.store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE).await { @@ -167,7 +187,7 @@ impl Operation for AssumeRoleHandle { match body.action.as_str() { ASSUME_ROLE_ACTION => { let remote_addr = req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)); - handle_assume_role(req.credentials, req.uri, req.headers, remote_addr, body).await + handle_assume_role(req.credentials, req.uri, req.headers, remote_addr, body, store, &audit).await } ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION => handle_assume_role_with_web_identity(body).await, _ => Err(s3_error!(InvalidArgument, "unsupported Action")), @@ -182,6 +202,8 @@ async fn handle_assume_role( headers: http::HeaderMap, remote_addr: Option, body: AssumeRoleRequest, + store: Option>, + audit: &AccountAuditContext, ) -> S3Result> { let Some(user) = credentials else { return Err(s3_error!(InvalidRequest, "get cred failed")); @@ -223,8 +245,30 @@ async fn handle_assume_role( return Err(s3_error!(InvalidArgument, "not support version")); } + // Second-factor gate. + // + // This is the only place a second factor can be enforced, because minting an + // STS session is the only interactive login RustFS has. Note what is + // deliberately *not* gated: a request signed directly with a long-term + // access key. Gating that would break every script and CLI the moment a + // human enabled 2FA on their own account, and it would not add protection — + // whoever holds the secret key already has full access without ever + // presenting a code. Making 2FA meaningful for direct API access needs a + // policy condition on the session, which is tracked separately. + // + // An identity with no enrollment takes no new code path at all, so the + // behaviour of every existing deployment is unchanged. + let mfa_verified = enforce_second_factor(&cred.access_key, &body, store, audit).await?; + let mut claims = cred.claims.unwrap_or_default(); + if mfa_verified { + // Recorded on the session so a later policy condition can require it, + // and so an audit consumer can tell a two-factor session from a + // single-factor one. + claims.insert(MFA_VERIFIED_CLAIM.to_string(), Value::Bool(true)); + } + populate_session_policy(&mut claims, &body.policy)?; let exp = clamp_assume_role_duration(body.duration_seconds); @@ -297,6 +341,61 @@ async fn handle_assume_role( Ok(S3Response::new((StatusCode::OK, Body::from(output)))) } +/// Session claim marking a session that presented a second factor. +/// +/// Namespaced with the `x-rustfs-` prefix so it cannot collide with an OIDC +/// claim of the same name arriving from an identity provider. +pub(crate) const MFA_VERIFIED_CLAIM: &str = "x-rustfs-mfa-verified"; + +/// Require and verify a second factor when `access_key` has one enrolled. +/// +/// Returns whether a factor was actually presented and verified. `false` means +/// the identity has no enrollment, not that verification was skipped. +async fn enforce_second_factor( + access_key: &str, + body: &AssumeRoleRequest, + store: Option>, + audit: &AccountAuditContext, +) -> S3Result { + let Some(store) = store else { + // Failing closed here would make every login depend on the store being + // reachable, but failing *open* would let a store outage disable the + // second factor. The store is required for the lookup, so an + // unavailable one is reported as unavailable. + return Err(crate::admin::storage_api::s3::error( + S3ErrorCode::ServiceUnavailable, + "the object store is not ready", + )); + }; + + let now = OffsetDateTime::now_utc(); + let required = mfa_service::is_enabled(store.clone(), access_key, now) + .await + .map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, err.to_string()))?; + + if !required { + return Ok(false); + } + + if body.token_code.is_empty() { + debug!( + access_key = %MaskedAccessKey(access_key), + "AssumeRole requires a second factor" + ); + // The message carries the sentinel clients match on to decide whether to + // prompt for a code rather than report a failed login. + return Err(S3Error::with_message( + S3ErrorCode::AccessDenied, + format!("{ERR_MFA_REQUIRED}: a second authentication factor is required"), + )); + } + + let challenge = (!body.serial_number.is_empty()).then_some(body.serial_number.as_str()); + mfa_verify_for_session(store, audit, access_key, IdentityType::Iam, challenge, &body.token_code).await?; + + Ok(true) +} + /// Handle the AssumeRoleWithWebIdentity action. /// The JWT (id_token) in the request is the authentication — no SigV4 needed. async fn handle_assume_role_with_web_identity(body: AssumeRoleRequest) -> S3Result> { diff --git a/rustfs/src/admin/handlers/system.rs b/rustfs/src/admin/handlers/system.rs index 3b678c069..3715b79ac 100644 --- a/rustfs/src/admin/handlers/system.rs +++ b/rustfs/src/admin/handlers/system.rs @@ -14,6 +14,8 @@ use super::{cluster_snapshot, metrics}; use crate::admin::auth::validate_admin_request; +use crate::admin::handlers::account::{ACCOUNT_INFO_ROUTE, ACCOUNT_PASSWORD_ROUTE}; +use crate::admin::handlers::mfa::{ACCOUNT_MFA_ROUTE, MFA_CHALLENGE_ROUTE, USER_MFA_ROUTE}; use crate::admin::route_policy::{ ADMIN_ROUTE_POLICY_SPECS, DEFERRED_ADMIN_ROUTE_POLICIES, DeferredAdminRoutePolicy, DeferredRoutePolicyReason, }; @@ -1107,6 +1109,14 @@ fn advertised_admin_capabilities() -> Vec { ("admin.iam.access-keys-bulk", HttpMethod::Get, IAM_ACCESS_KEYS_BULK_ROUTE), ("admin.iam.access-keys-bulk.ldap", HttpMethod::Get, IAM_ACCESS_KEYS_BULK_LDAP_ROUTE), ("admin.iam.access-keys-bulk.openid", HttpMethod::Get, IAM_ACCESS_KEYS_BULK_OPENID_ROUTE), + // Advertised so the console can hide the profile and 2FA surfaces + // against an older server instead of probing and handling a 404, and so + // `rc admin capabilities` reports them. + ("admin.account.info", HttpMethod::Get, ACCOUNT_INFO_ROUTE), + ("admin.account.password", HttpMethod::Post, ACCOUNT_PASSWORD_ROUTE), + ("admin.account.mfa", HttpMethod::Get, ACCOUNT_MFA_ROUTE), + ("admin.mfa.challenge", HttpMethod::Get, MFA_CHALLENGE_ROUTE), + ("admin.user.mfa", HttpMethod::Get, USER_MFA_ROUTE), ] .into_iter() .map(|(name, method, route)| AdvertisedAdminCapability { diff --git a/rustfs/src/admin/handlers/user_lifecycle.rs b/rustfs/src/admin/handlers/user_lifecycle.rs index 5b0f8bbba..db99bee41 100644 --- a/rustfs/src/admin/handlers/user_lifecycle.rs +++ b/rustfs/src/admin/handlers/user_lifecycle.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use super::account::SetUserSecretKeyHandler; use super::user::{AddUser, GetUserInfo, ListUsers, RemoveUser, SetUserStatus}; use crate::{ admin::router::{AdminOperation, S3Router}, @@ -50,5 +51,11 @@ pub fn register_user_lifecycle_route(r: &mut S3Router) -> std::i AdminOperation(&SetUserStatus {}), )?; + r.insert( + Method::PUT, + format!("{}{}", ADMIN_PREFIX, "/v3/set-user-secret-key").as_str(), + AdminOperation(&SetUserSecretKeyHandler {}), + )?; + Ok(()) } diff --git a/rustfs/src/admin/mod.rs b/rustfs/src/admin/mod.rs index 37c4b34c9..701c87839 100644 --- a/rustfs/src/admin/mod.rs +++ b/rustfs/src/admin/mod.rs @@ -36,9 +36,9 @@ mod kms_contract; mod route_registration_test; use handlers::{ - audit, batch_job, bucket_meta, cluster_snapshot, config_admin, diagnostics, durability as durability_handler, extensions, - heal, health, idp_compat, ilm_transition, inspect_archive, kms, module_switch, object_data_cache, object_zip_download, oidc, - plugins_catalog, plugins_instances, pools, profile_admin, quota as quota_handler, rebalance, + account, audit, batch_job, bucket_meta, cluster_snapshot, config_admin, diagnostics, durability as durability_handler, + extensions, heal, health, idp_compat, ilm_transition, inspect_archive, kms, mfa, module_switch, object_data_cache, + object_zip_download, oidc, plugins_catalog, plugins_instances, pools, profile_admin, quota as quota_handler, rebalance, replication as replication_handler, scanner, site_replication, sts, system, table_catalog, tier, tls_debug, usage_prefix, user, }; @@ -66,6 +66,8 @@ fn register_admin_routes(r: &mut S3Router) -> std::io::Result<() health::register_health_route(r)?; sts::register_admin_auth_route(r)?; + account::register_account_route(r)?; + mfa::register_mfa_route(r)?; user::register_user_route(r)?; system::register_system_route(r)?; pools::register_pool_route(r)?; diff --git a/rustfs/src/admin/route_policy.rs b/rustfs/src/admin/route_policy.rs index e423c0b0e..ea5ed00b6 100644 --- a/rustfs/src/admin/route_policy.rs +++ b/rustfs/src/admin/route_policy.rs @@ -24,6 +24,7 @@ const CONFIG_UPDATE: AdminActionRef = AdminActionRef::new("ConfigUpdateAdminActi const CONSOLE_LOG: AdminActionRef = AdminActionRef::new("ConsoleLogAdminAction"); const COMMIT_TABLE: AdminActionRef = AdminActionRef::new("CommitTableAction"); const CREATE_POLICY: AdminActionRef = AdminActionRef::new("CreatePolicyAdminAction"); +const CREATE_USER: AdminActionRef = AdminActionRef::new("CreateUserAdminAction"); const CREATE_SERVICE_ACCOUNT: AdminActionRef = AdminActionRef::new("CreateServiceAccountAdminAction"); const CREATE_TABLE: AdminActionRef = AdminActionRef::new("CreateTableAction"); const DECOMMISSION: AdminActionRef = AdminActionRef::new("DecommissionAdminAction"); @@ -38,6 +39,7 @@ const EXPORT_IAM: AdminActionRef = AdminActionRef::new("ExportIAMAction"); const FORCE_UNLOCK: AdminActionRef = AdminActionRef::new("ForceUnlockAdminAction"); const GET_BUCKET_TARGET: AdminActionRef = AdminActionRef::new("GetBucketTargetAction"); const GET_GROUP: AdminActionRef = AdminActionRef::new("GetGroupAdminAction"); +const GET_USER: AdminActionRef = AdminActionRef::new("GetUserAdminAction"); const GET_METRICS: AdminActionRef = AdminActionRef::new("GetMetricsAction"); const GET_POLICY: AdminActionRef = AdminActionRef::new("GetPolicyAdminAction"); const GET_REPLICATION_METRICS: AdminActionRef = AdminActionRef::new("GetReplicationMetricsAction"); @@ -153,6 +155,14 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[ admin(HttpMethod::Get, "/rustfs/admin/v3/list-users", LIST_USERS, RouteRiskLevel::Sensitive), admin(HttpMethod::Delete, "/rustfs/admin/v3/remove-user", DELETE_USER, RouteRiskLevel::High), admin(HttpMethod::Put, "/rustfs/admin/v3/set-user-status", ENABLE_USER, RouteRiskLevel::High), + // Resetting somebody else's secret key is the same capability as creating + // them, so it carries the same action. + admin(HttpMethod::Put, "/rustfs/admin/v3/set-user-secret-key", CREATE_USER, RouteRiskLevel::High), + admin(HttpMethod::Get, "/rustfs/admin/v3/user/mfa", GET_USER, RouteRiskLevel::Sensitive), + // Clearing another identity's second factor is break-glass: whoever can + // re-enable a disabled account can already take the identity over, so this + // shares that action rather than inventing a weaker one. + admin(HttpMethod::Delete, "/rustfs/admin/v3/user/mfa", ENABLE_USER, RouteRiskLevel::High), admin(HttpMethod::Get, "/rustfs/admin/v3/groups", LIST_GROUPS, RouteRiskLevel::Sensitive), admin(HttpMethod::Get, "/rustfs/admin/v3/group", GET_GROUP, RouteRiskLevel::Sensitive), admin( @@ -1478,6 +1488,53 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[ pub const DEFERRED_ADMIN_ROUTE_POLICIES: &[DeferredAdminRoutePolicy] = &[ deferred(HttpMethod::Get, "/rustfs/admin/v3/accountinfo", DeferredRoutePolicyReason::S3Action), + // The self-service account routes act on the caller, never on a target + // named in the request, so they gate on possession of the credential (plus, + // for the mutation, knowledge of the current secret) rather than on an + // admin action. Giving them one would be wrong in both directions: it would + // stop an ordinary user from managing their own password, and it would let + // any holder of that action manage somebody else's. + deferred( + HttpMethod::Get, + "/rustfs/admin/v3/account/info", + DeferredRoutePolicyReason::CredentialOnly, + ), + deferred( + HttpMethod::Post, + "/rustfs/admin/v3/account/password", + DeferredRoutePolicyReason::CredentialOnly, + ), + // The MFA self-service family gates the same way, plus a proof of the + // second factor (and, for disable, of the account password) inside the + // handler. + deferred(HttpMethod::Get, "/rustfs/admin/v3/account/mfa", DeferredRoutePolicyReason::CredentialOnly), + deferred( + HttpMethod::Post, + "/rustfs/admin/v3/account/mfa/enroll", + DeferredRoutePolicyReason::CredentialOnly, + ), + deferred( + HttpMethod::Post, + "/rustfs/admin/v3/account/mfa/activate", + DeferredRoutePolicyReason::CredentialOnly, + ), + deferred( + HttpMethod::Post, + "/rustfs/admin/v3/account/mfa/disable", + DeferredRoutePolicyReason::CredentialOnly, + ), + deferred( + HttpMethod::Post, + "/rustfs/admin/v3/account/mfa/recovery-codes", + DeferredRoutePolicyReason::CredentialOnly, + ), + // The login challenge is signed with the identity's own credentials, so + // possession of them is the whole authorization. + deferred( + HttpMethod::Get, + "/rustfs/admin/v3/mfa/challenge", + DeferredRoutePolicyReason::CredentialOnly, + ), deferred( HttpMethod::Get, "/rustfs/admin/v3/user-info", diff --git a/rustfs/src/admin/route_registration_test.rs b/rustfs/src/admin/route_registration_test.rs index 8dcb5d901..e9d1ce39b 100644 --- a/rustfs/src/admin/route_registration_test.rs +++ b/rustfs/src/admin/route_registration_test.rs @@ -125,11 +125,22 @@ fn expected_admin_route_matrix() -> Vec { route(Method::POST, "/"), admin_route(Method::GET, "/v3/is-admin"), admin_route(Method::GET, "/v3/accountinfo"), + admin_route(Method::GET, "/v3/account/info"), + admin_route(Method::POST, "/v3/account/password"), admin_route(Method::GET, "/v3/list-users"), admin_route(Method::GET, "/v3/user-info"), admin_route(Method::DELETE, "/v3/remove-user"), admin_route(Method::PUT, "/v3/add-user"), admin_route(Method::PUT, "/v3/set-user-status"), + admin_route(Method::PUT, "/v3/set-user-secret-key"), + admin_route(Method::GET, "/v3/account/mfa"), + admin_route(Method::POST, "/v3/account/mfa/enroll"), + admin_route(Method::POST, "/v3/account/mfa/activate"), + admin_route(Method::POST, "/v3/account/mfa/disable"), + admin_route(Method::POST, "/v3/account/mfa/recovery-codes"), + admin_route(Method::GET, "/v3/mfa/challenge"), + admin_route(Method::GET, "/v3/user/mfa"), + admin_route(Method::DELETE, "/v3/user/mfa"), admin_route(Method::GET, "/v3/groups"), admin_route(Method::GET, "/v3/group"), admin_route_sample(Method::DELETE, "/v3/group/{group}", "/v3/group/test-group"), diff --git a/rustfs/src/admin/service/caller_identity.rs b/rustfs/src/admin/service/caller_identity.rs new file mode 100644 index 000000000..28cebaa4e --- /dev/null +++ b/rustfs/src/admin/service/caller_identity.rs @@ -0,0 +1,432 @@ +// 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. + +//! Resolves the long-term identity behind an authenticated admin request. +//! +//! Self-service endpoints (`/v3/account/*`, `/v3/mfa/*`) act on "whoever is +//! calling" rather than on a target named in the request, so they all need the +//! same answer to two questions: which durable identity owns this credential, +//! and may that credential mutate the identity's own authentication material? +//! +//! Both answers are subtle. The Console operates entirely with STS session +//! credentials, so "the caller" is almost never the access key that signed the +//! request. Service accounts and OIDC sessions also present as derived +//! credentials but must *not* be allowed to rewrite the parent's secret. This +//! module is the single place those distinctions are made. + +use crate::admin::auth::authenticate_request; +use crate::admin::runtime_sources::current_action_credentials; +use crate::admin::storage_api::s3::{self, Body, S3ErrorCode, S3Request, S3Result}; +use crate::auth::constant_time_eq; +use rustfs_credentials::Credentials; +use rustfs_iam::federation::OIDC_VIRTUAL_PARENT_CLAIM; +use rustfs_iam::sys::is_rustfs_oidc_claims; +use rustfs_madmin::account::{AccountMutability, CredentialsSource, IdentityType}; + +/// Claim written by the Keystone middleware onto its synthesized credentials. +const KEYSTONE_ROLES_CLAIM: &str = "keystone_roles"; + +/// The durable identity that owns a session credential. +/// +/// Prefers the `parent_user` field and falls back to the JWT `parent` claim: +/// some stores persist the parent only inside the session token, so checking +/// just one of the two silently misidentifies the caller. This mirrors the +/// resolution order used by the user-management handlers. +pub(crate) fn session_parent_identity(credentials: &Credentials) -> Option<&str> { + if !credentials.parent_user.is_empty() { + return Some(credentials.parent_user.as_str()); + } + credentials + .claims + .as_ref() + .and_then(|claims| claims.get("parent")) + .and_then(|value| value.as_str()) +} + +/// Why a credential may not change its own authentication material. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CredentialMutationDenial { + /// Root credentials are pinned by a process-wide `OnceLock` and also feed + /// the derived internode RPC secret, so they cannot be rotated at runtime. + RootIsEnvironmentProvisioned, + /// The identity lives in an external IdP; RustFS holds no secret to change + /// and no TOTP enrollment of its own would be authoritative. + FederatedIdentity, + /// Machine credentials must not be able to take over the human identity + /// they were minted from. + ServiceAccount, + /// A derived credential whose parent could not be determined. + UnresolvedParent, +} + +impl CredentialMutationDenial { + pub(crate) const fn message(self) -> &'static str { + match self { + Self::RootIsEnvironmentProvisioned => { + "the root identity is provisioned from the server environment and cannot be changed at runtime" + } + Self::FederatedIdentity => "federated identities are managed by their identity provider", + Self::ServiceAccount => "service account credentials cannot change the credentials of their parent identity", + Self::UnresolvedParent => "the parent identity of this session could not be resolved", + } + } +} + +/// An authenticated caller, resolved to the identity it acts as. +#[derive(Debug, Clone)] +pub(crate) struct CallerIdentity { + /// The durable identity. For STS and service-account credentials this is + /// the parent, not the ephemeral access key that signed the request. + pub(crate) access_key: String, + pub(crate) identity_type: IdentityType, + /// The access key actually presented, when it differs from `access_key`. + pub(crate) session_access_key: Option, + pub(crate) credentials_source: CredentialsSource, + /// The verified credentials of the presented key. + pub(crate) credentials: Credentials, + pub(crate) is_owner: bool, + /// Set when this credential kind may not rotate its own secret. + pub(crate) mutation_denial: Option, + /// Set when this credential kind may not manage its own second factor. + /// + /// Distinct from [`Self::mutation_denial`], because the two questions have + /// different answers for the root identity: its secret key is pinned by a + /// process-wide `OnceLock`, but its *second factor* is an ordinary record + /// keyed on its access key. Conflating them would leave the default + /// deployment — a root administrator signing into the console — unable to + /// protect the one login that matters most. + pub(crate) mfa_denial: Option, +} + +impl CallerIdentity { + /// Authenticate the request and resolve who it acts as. + /// + /// Authentication only: callers that need an authorization decision must + /// still gate on an admin action. Self-service endpoints deliberately do + /// not, because every authenticated identity may inspect and manage itself. + pub(crate) async fn resolve(req: &S3Request) -> S3Result { + let Some(input_cred) = req.credentials.as_ref() else { + return Err(s3::error(S3ErrorCode::InvalidRequest, "authentication required")); + }; + + let (credentials, is_owner) = authenticate_request(&req.headers, &req.uri, input_cred).await?; + Ok(Self::from_credentials(credentials, is_owner)) + } + + fn from_credentials(credentials: Credentials, is_owner: bool) -> Self { + let presented_access_key = credentials.access_key.clone(); + let is_service_account = credentials.is_service_account(); + let is_temp = credentials.is_temp(); + let federated = is_federated_session(&credentials); + + let parent = session_parent_identity(&credentials).map(str::to_owned); + + // A derived credential acts as its parent; a long-term one acts as + // itself. `is_service_account` is checked first because service-account + // credentials also carry a session token and would otherwise look + // temporary. + let (identity_type, access_key, unresolved_parent) = if is_service_account { + match parent { + Some(parent) => (IdentityType::ServiceAccount, parent, false), + None => (IdentityType::ServiceAccount, presented_access_key.clone(), true), + } + } else if is_temp { + match parent { + Some(parent) => (IdentityType::Sts, parent, false), + None => (IdentityType::Sts, presented_access_key.clone(), true), + } + } else { + (IdentityType::Iam, presented_access_key.clone(), false) + }; + + let is_root = current_action_credentials().is_some_and(|root| constant_time_eq(&root.access_key, &access_key)); + + let identity_type = if is_root && matches!(identity_type, IdentityType::Iam) { + IdentityType::Root + } else { + identity_type + }; + + let credentials_source = if is_root { + CredentialsSource::Env + } else { + CredentialsSource::Iam + }; + + // Order matters: report the most specific reason a caller will act on. + // "You are a service account" is more actionable than "your parent is + // root", and an unresolved parent must never fall through to a + // permissive answer. + // + // These three denials apply to both capabilities: a machine credential + // must not take over its parent, a federated identity is owned by its + // IdP, and an unresolvable parent fails closed. + let shared_denial = if unresolved_parent { + Some(CredentialMutationDenial::UnresolvedParent) + } else if is_service_account { + Some(CredentialMutationDenial::ServiceAccount) + } else if federated { + Some(CredentialMutationDenial::FederatedIdentity) + } else { + None + }; + + // Root additionally cannot rotate its secret — but it can still enroll a + // second factor, which is the whole point of the feature for a default + // deployment. + let mutation_denial = shared_denial.or(if is_root { + Some(CredentialMutationDenial::RootIsEnvironmentProvisioned) + } else { + None + }); + let mfa_denial = shared_denial; + + let session_access_key = (presented_access_key != access_key).then_some(presented_access_key); + + Self { + access_key, + identity_type, + session_access_key, + credentials_source, + credentials, + is_owner, + mutation_denial, + mfa_denial, + } + } + + /// Which self-service mutations the server will accept for this caller. + /// + /// Reported to clients so they can disable a control instead of offering a + /// request that is guaranteed to fail. + pub(crate) const fn mutability(&self) -> AccountMutability { + match self.mutation_denial { + Some(_) => AccountMutability { + password: false, + username: false, + }, + // Renaming an identity is not a supported mutation for anyone yet: + // the access key is the primary key for policy mappings, group + // membership, service-account parents and bucket-policy principals, + // so a rename is a migration rather than an edit. + None => AccountMutability { + password: true, + username: false, + }, + } + } + + /// `Ok(())` when this caller may rotate its own secret. + pub(crate) fn ensure_credential_mutation_allowed(&self) -> S3Result<()> { + match self.mutation_denial { + None => Ok(()), + Some(denial) => Err(s3::error(S3ErrorCode::InvalidRequest, denial.message())), + } + } + + /// `Ok(())` when this caller may manage its own second factor. + /// + /// Deliberately more permissive than [`Self::ensure_credential_mutation_allowed`]: + /// a root identity may enroll even though it cannot change its password. + pub(crate) fn ensure_mfa_management_allowed(&self) -> S3Result<()> { + match self.mfa_denial { + None => Ok(()), + Some(denial) => Err(s3::error(S3ErrorCode::InvalidRequest, denial.message())), + } + } +} + +/// Whether the session was minted by an external identity provider. +/// +/// Such sessions have no RustFS-held long-term secret, so a password change has +/// nothing to change and a TOTP enrollment would not be consulted at login — +/// the IdP owns both. +fn is_federated_session(credentials: &Credentials) -> bool { + let Some(claims) = credentials.claims.as_ref() else { + return false; + }; + + is_rustfs_oidc_claims(claims) || claims.contains_key(OIDC_VIRTUAL_PARENT_CLAIM) || claims.contains_key(KEYSTONE_ROLES_CLAIM) +} + +#[cfg(test)] +mod tests { + use super::*; + use rustfs_credentials::IAM_POLICY_CLAIM_NAME_SA; + use serde_json::Value; + use std::collections::HashMap; + + fn long_term(access_key: &str) -> Credentials { + Credentials { + access_key: access_key.to_string(), + secret_key: "secret-key-value".to_string(), + ..Default::default() + } + } + + fn sts_session(access_key: &str, parent: &str) -> Credentials { + Credentials { + access_key: access_key.to_string(), + secret_key: "session-secret".to_string(), + session_token: "token".to_string(), + parent_user: parent.to_string(), + ..Default::default() + } + } + + fn service_account(access_key: &str, parent: &str) -> Credentials { + let mut claims = HashMap::new(); + claims.insert(IAM_POLICY_CLAIM_NAME_SA.to_string(), Value::String("inherited".to_string())); + Credentials { + access_key: access_key.to_string(), + secret_key: "svc-secret".to_string(), + session_token: "token".to_string(), + parent_user: parent.to_string(), + claims: Some(claims), + ..Default::default() + } + } + + #[test] + fn long_term_iam_user_acts_as_itself_and_may_change_its_password() { + let caller = CallerIdentity::from_credentials(long_term("sinan"), false); + + assert_eq!(caller.access_key, "sinan"); + assert_eq!(caller.identity_type, IdentityType::Iam); + assert!(caller.session_access_key.is_none()); + assert_eq!(caller.credentials_source, CredentialsSource::Iam); + assert!(caller.mutation_denial.is_none()); + assert!(caller.mutability().password); + // Rename stays unsupported even for the cases password change allows. + assert!(!caller.mutability().username); + assert!(caller.ensure_credential_mutation_allowed().is_ok()); + } + + #[test] + fn sts_session_acts_as_its_parent() { + // The Console only ever holds STS credentials, so this is the path that + // every real "change my password" request takes. + let caller = CallerIdentity::from_credentials(sts_session("TEMPKEY", "sinan"), false); + + assert_eq!(caller.access_key, "sinan"); + assert_eq!(caller.identity_type, IdentityType::Sts); + assert_eq!(caller.session_access_key.as_deref(), Some("TEMPKEY")); + assert!(caller.mutation_denial.is_none()); + assert!(caller.mutability().password); + } + + #[test] + fn sts_session_falls_back_to_the_jwt_parent_claim() { + let mut credentials = sts_session("TEMPKEY", ""); + let mut claims = HashMap::new(); + claims.insert("parent".to_string(), Value::String("sinan".to_string())); + credentials.claims = Some(claims); + + let caller = CallerIdentity::from_credentials(credentials, false); + + assert_eq!(caller.access_key, "sinan"); + assert_eq!(caller.identity_type, IdentityType::Sts); + assert!(caller.mutation_denial.is_none()); + } + + #[test] + fn a_root_identity_may_enroll_a_second_factor_even_though_its_password_is_fixed() { + // The case that matters most: the default deployment signs into the + // console as root, so refusing enrollment here would leave the one login + // the feature exists to protect unprotected. + let mut caller = CallerIdentity::from_credentials(long_term("rustfsadmin"), true); + caller.identity_type = IdentityType::Root; + caller.credentials_source = CredentialsSource::Env; + caller.mutation_denial = Some(CredentialMutationDenial::RootIsEnvironmentProvisioned); + caller.mfa_denial = None; + + assert!(caller.ensure_credential_mutation_allowed().is_err()); + assert!(caller.ensure_mfa_management_allowed().is_ok()); + assert!(!caller.mutability().password); + } + + #[test] + fn a_service_account_may_manage_neither() { + let caller = CallerIdentity::from_credentials(service_account("SVCKEY", "sinan"), false); + + assert!(caller.ensure_credential_mutation_allowed().is_err()); + assert!(caller.ensure_mfa_management_allowed().is_err()); + } + + #[test] + fn an_ordinary_iam_user_may_manage_both() { + let caller = CallerIdentity::from_credentials(long_term("sinan"), false); + + assert!(caller.ensure_credential_mutation_allowed().is_ok()); + assert!(caller.ensure_mfa_management_allowed().is_ok()); + } + + #[test] + fn service_account_may_not_mutate_its_parent() { + let caller = CallerIdentity::from_credentials(service_account("SVCKEY", "sinan"), false); + + assert_eq!(caller.access_key, "sinan"); + assert_eq!(caller.identity_type, IdentityType::ServiceAccount); + assert_eq!(caller.mutation_denial, Some(CredentialMutationDenial::ServiceAccount)); + assert!(!caller.mutability().password); + assert!(caller.ensure_credential_mutation_allowed().is_err()); + } + + #[test] + fn oidc_session_is_reported_as_federated() { + let mut credentials = sts_session("TEMPKEY", "oidc-parent"); + let mut claims = HashMap::new(); + claims.insert("iss".to_string(), Value::String("rustfs-oidc".to_string())); + claims.insert("oidc_provider".to_string(), Value::String("keycloak".to_string())); + claims.insert("sub".to_string(), Value::String("user-123".to_string())); + credentials.claims = Some(claims); + + let caller = CallerIdentity::from_credentials(credentials, false); + + assert_eq!(caller.mutation_denial, Some(CredentialMutationDenial::FederatedIdentity)); + assert!(!caller.mutability().password); + } + + #[test] + fn keystone_session_is_reported_as_federated() { + let mut credentials = sts_session("TEMPKEY", "keystone-parent"); + let mut claims = HashMap::new(); + claims.insert(KEYSTONE_ROLES_CLAIM.to_string(), Value::Array(vec![])); + credentials.claims = Some(claims); + + let caller = CallerIdentity::from_credentials(credentials, false); + + assert_eq!(caller.mutation_denial, Some(CredentialMutationDenial::FederatedIdentity)); + } + + #[test] + fn derived_credential_without_a_parent_is_denied_rather_than_allowed() { + // Fail closed: an unresolvable parent must not be treated as a + // long-term identity acting on itself. + let caller = CallerIdentity::from_credentials(sts_session("TEMPKEY", ""), false); + + assert_eq!(caller.mutation_denial, Some(CredentialMutationDenial::UnresolvedParent)); + assert!(!caller.mutability().password); + } + + #[test] + fn session_parent_identity_prefers_the_parent_user_field() { + let mut credentials = sts_session("TEMPKEY", "field-parent"); + let mut claims = HashMap::new(); + claims.insert("parent".to_string(), Value::String("claim-parent".to_string())); + credentials.claims = Some(claims); + + assert_eq!(session_parent_identity(&credentials), Some("field-parent")); + } +} diff --git a/rustfs/src/admin/service/mod.rs b/rustfs/src/admin/service/mod.rs index 569802ed0..403530f5c 100644 --- a/rustfs/src/admin/service/mod.rs +++ b/rustfs/src/admin/service/mod.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +pub(crate) mod caller_identity; pub mod config; pub(crate) mod federated_identity; pub(crate) mod session_policy; diff --git a/rustfs/src/admin/storage_api.rs b/rustfs/src/admin/storage_api.rs index fdb6b8a4b..c4a597b08 100644 --- a/rustfs/src/admin/storage_api.rs +++ b/rustfs/src/admin/storage_api.rs @@ -958,7 +958,18 @@ pub(crate) mod runtime { } pub(crate) mod s3 { - pub(crate) use s3s::{S3Error, S3ErrorCode, S3Result}; + pub(crate) use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, header}; + + /// Build an `S3Error` without reaching for the `s3s` error macro. + /// + /// The macro expands to the very constructor this calls, but it puts an + /// `s3s` dependency in every file that reports an error. Routing the + /// construction through here keeps that dependency in this facade, which is + /// the boundary the s3gate migration replaces + /// (`scripts/check_s3s_footprint.sh`, rustfs/backlog#1677 F1). + pub(crate) fn error(code: S3ErrorCode, message: impl Into>) -> S3Error { + S3Error::with_message(code, message) + } } pub(crate) mod tier {