From b93e7b2355134762894c3688a68b7206b234cc22 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Wed, 26 Aug 2026 04:35:29 +0300 Subject: [PATCH] feat(admin): self-service account management and TOTP two-factor authentication (#6596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(madmin): add account and two-factor wire contract Defines the self-service account and MFA API shapes in one place so the console and the `rc` CLI decode identical payloads instead of each carrying its own copy of the contract. `AccountMutability` is part of the contract on purpose: a client needs to know whether the server will accept a password change for this identity before offering the control, rather than discovering it from a rejected request. * feat(s3-types): add IAM identity audit events Adds `iam:Identity:CredentialChanged` and `iam:Identity:AuthChallenge` so account and authentication activity reaches the audit pipeline in its own namespace, the way the KMS events already do. Neither is reachable from a bucket notification config. Two variants for the whole surface rather than one per operation: `mask()` gives every variant its own bit in a `u64`, and the budget is nearly spent (63 of 64 used after this). The per-operation detail lives in `AuditEntry::api.name` and the `iamOperation` tag, which is what a SIEM filters on anyway. Splitting these further needs `mask()` widened first. * feat(iam): add two-factor authentication primitives Implements the state machine behind TOTP enrollment and verification in the IAM domain, so the admin handlers stay HTTP plumbing and the console and CLI drive identical logic. * `totp`: RFC 6238 over the workspace's existing hmac/sha1, pinned to the published Appendix B vectors. SHA-1, 6 digits, 30s: the parameters every mainstream authenticator app implements. Verification returns the matched time step so the caller can burn it. * `recovery`: ten single-use codes, 100 bits each, in a Crockford base32 alphabet without I/L/O/U. Stored as domain-separated SHA-256 digests — a password KDF would have to run once per stored code on every attempt, turning each guess into an attacker-controlled cost, and with uniform 100-bit input there is no dictionary for it to defend against. * `challenge`: stateless HMAC tokens. A TTL cache would be node-local, so a cluster without session affinity would issue on one node and verify on another; nothing here needs replicating. * `record`: two-phase enrollment, replay high-water mark, and lockout. Pending enrollment never gates a login, so a mis-scanned QR cannot lock an operator out, and re-configuring keeps the old factor working until the new one is confirmed. * `store`: one object per identity under `config/mfa/`, a sibling of `config/iam/` so the IAM cache loader's startup walk does not sweep it up. Optimistic `If-Match` writes; deliberately uncached, because a cache would need cluster-wide invalidation to keep the replay mark and the lockout counter honest. * `qr`: server-side rendering, so neither client needs a QR encoder. Enrollment is refused without `RUSTFS_IAM_MASTER_KEY`. A TOTP secret is credential-equivalent, and one written in plaintext could be lifted off a disk — worse than no second factor, because the user believes they have one. IAM identities tolerate a missing master key for backward compatibility; a new feature has no such history to honour. Also adds `IamSys::revoke_sts_sessions_for_parent`, so a credential rotation can invalidate the sessions minted under the old secret. * feat(admin): add self-service account endpoints and the two-factor login gate Adds the account surface (`/v3/account/*`), the second-factor endpoints, the administrative reset (`/v3/user/mfa`), and `PUT /v3/set-user-secret-key`, plus the gate on `AssumeRole`. What the gate covers, and what it deliberately does not: * `AssumeRole` is the only interactive login RustFS has, so it is where a second factor can be enforced. With one enrolled it requires `TokenCode`; without an enrollment the code path is unchanged, so existing deployments are untouched. * A request signed directly with a long-term access key stays ungated. Gating it would break every script and CLI the moment a human enabled 2FA on their own account, and would add no protection: whoever holds the secret key already has full access without presenting a code. This is the division AWS draws; making 2FA meaningful for API access needs an `aws:MultiFactorAuthPresent` policy condition, tracked separately. `SerialNumber`/`TokenCode` are STS's own parameters, so an SDK or script authenticates the same way the console does. `caller_identity` resolves who a request acts as. The console signs with a short-lived STS session, so "the caller" is almost never the key that signed. It reports two separate capabilities: root cannot rotate its secret (a process-wide `OnceLock` that also derives the internode RPC secret) but *can* enroll a second factor — conflating the two would leave the default deployment's console login unprotectable. 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 let any holder of that action change someone else's. They gate on possession of the credential plus, for the mutations, knowledge of the current secret — a signature only proves a credential was used, so without that a hijacked tab could rewrite the account's credentials or strip its second factor. `set-user-secret-key` exists because the only prior way to change a password was to re-POST the whole user through `add-user`, which rewrote `status` and dropped the policy field — a password reset that silently re-enabled a disabled account. Wrong, replayed and malformed codes are indistinguishable on the wire; the distinction survives only in the audit trail, where no submitted value, secret or code is ever recorded. * test(e2e): cover the two-factor lifecycle and its regressions Unit tests cover the state machine at its edges; only an end-to-end test proves the pieces are wired together and that the existing authentication paths still behave. Asserts, against a real server: enrollment is refused without a master key; the full enroll/activate flow works with a genuine RFC 6238 code; `AssumeRole` refuses without a factor and accepts a valid one; a recovery code works exactly once; a direct SigV4 admin request keeps working with a factor enrolled; `AssumeRole` for an unenrolled identity is unchanged; and a password rotation invalidates the old secret. The test computes TOTP codes itself rather than calling the server's implementation — a shared helper could agree with a bug on both sides. This suite caught a real defect during development: enrollment was refused for root because its *password* is immutable, which would have left the default deployment — an administrator signing into the console as root — unable to protect the one login the feature exists for. * docs(operations): document the two-factor authentication model Records what the second factor protects and what it deliberately does not, because several of the boundaries look like gaps until the alternative is spelled out: why direct SigV4 access stays ungated, why root credentials cannot be rotated at runtime, why secret keys cannot be hashed in an S3 server, and why at-rest protection is mandatory for a TOTP secret but optional for an IAM identity. Also states the limitations plainly, including that GHSA-m77q-r63m-pj89 is unaffected: a holder of the root secret can still forge a session token, 2FA claim included. Placed alongside the other authentication and KMS security documents rather than under a new `docs/security/`, which `.gitignore` excludes. * fix(admin): route the new account handlers through the admin s3 facade Two of the guardrails in the CI "Quick Checks" job rejected the previous commits, so the required check would have gone red as soon as a maintainer approved the workflow run. `check_architecture_migration_rules.sh` requires everything under `rustfs/src/admin` to reach `ECStore` through a domain module rather than the root of `storage_api`. The MFA handler and the two `AssumeRole` signatures now use `storage_api::runtime::ECStore`, which is where the other ten admin handlers already take it from. `check_s3s_footprint.sh` ratchets two counters that new code may not grow: files referencing `s3s` and error-macro invocation lines. This branch added four files and thirty-two lines to them. The ratchet is lower-only and its header forbids raising a baseline to get green, so the construction moves behind the facade instead: `storage_api::s3` now re-exports the request and body types these handlers need and gains an `error` constructor over `S3Error::with_message`. That is the same constructor the macro expands to and the one `handlers/mod.rs`, `rebalance_internal_error` and `invalid_object_lock_configuration` already call, so this is the existing practice rather than a new one, and it keeps the `s3s` dependency in the boundary file the s3gate migration replaces. Every error code and message is carried over unchanged. In `sts.rs` only the call site this branch added is converted; the sixteen that predate it are left alone, because rewriting them would put unrelated churn in a feature PR and push the counter below the baseline it is meant to hold. --- Cargo.lock | 17 + Cargo.toml | 7 + crates/e2e_test/Cargo.toml | 7 + crates/e2e_test/src/admin_mfa_test.rs | 449 ++++++++++++ crates/e2e_test/src/lib.rs | 1 + crates/iam/Cargo.toml | 9 + crates/iam/src/lib.rs | 1 + crates/iam/src/mfa/challenge.rs | 252 +++++++ crates/iam/src/mfa/mod.rs | 34 + crates/iam/src/mfa/qr.rs | 162 +++++ crates/iam/src/mfa/record.rs | 696 ++++++++++++++++++ crates/iam/src/mfa/recovery.rs | 400 +++++++++++ crates/iam/src/mfa/service.rs | 391 ++++++++++ crates/iam/src/mfa/store.rs | 268 +++++++ crates/iam/src/mfa/totp.rs | 430 +++++++++++ crates/iam/src/store/object.rs | 15 + crates/iam/src/sys.rs | 70 ++ crates/madmin/src/account.rs | 332 +++++++++ crates/madmin/src/lib.rs | 2 + crates/s3-types/src/event_name.rs | 71 +- docs/operations/two-factor-auth.md | 299 ++++++++ rustfs/src/admin/handlers/account.rs | 518 ++++++++++++++ rustfs/src/admin/handlers/account_audit.rs | 426 +++++++++++ rustfs/src/admin/handlers/idp_compat.rs | 13 +- rustfs/src/admin/handlers/mfa.rs | 747 ++++++++++++++++++++ rustfs/src/admin/handlers/mod.rs | 24 + rustfs/src/admin/handlers/sts.rs | 101 ++- rustfs/src/admin/handlers/system.rs | 10 + rustfs/src/admin/handlers/user_lifecycle.rs | 7 + rustfs/src/admin/mod.rs | 8 +- rustfs/src/admin/route_policy.rs | 57 ++ rustfs/src/admin/route_registration_test.rs | 11 + rustfs/src/admin/service/caller_identity.rs | 432 +++++++++++ rustfs/src/admin/service/mod.rs | 1 + rustfs/src/admin/storage_api.rs | 13 +- 35 files changed, 6263 insertions(+), 18 deletions(-) create mode 100644 crates/e2e_test/src/admin_mfa_test.rs create mode 100644 crates/iam/src/mfa/challenge.rs create mode 100644 crates/iam/src/mfa/mod.rs create mode 100644 crates/iam/src/mfa/qr.rs create mode 100644 crates/iam/src/mfa/record.rs create mode 100644 crates/iam/src/mfa/recovery.rs create mode 100644 crates/iam/src/mfa/service.rs create mode 100644 crates/iam/src/mfa/store.rs create mode 100644 crates/iam/src/mfa/totp.rs create mode 100644 crates/madmin/src/account.rs create mode 100644 docs/operations/two-factor-auth.md create mode 100644 rustfs/src/admin/handlers/account.rs create mode 100644 rustfs/src/admin/handlers/account_audit.rs create mode 100644 rustfs/src/admin/handlers/mfa.rs create mode 100644 rustfs/src/admin/service/caller_identity.rs 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 {