Skip to main content

s4_server/
mfa.rs

1//! MFA Delete enforcement (v0.6 #42).
2//!
3//! AWS S3 MFA Delete: when a bucket is `Versioning = Enabled` AND
4//! `MfaDelete = Enabled`, every DELETE / DELETE-version / delete-marker
5//! producing request must carry the `x-amz-mfa: <serial> <code>` header,
6//! where `code` is a 6-digit RFC 6238 TOTP value computed against the
7//! authentication device's secret. Same gate applies to the
8//! `PutBucketVersioning` request itself when it tries to flip the MfaDelete
9//! state on or off (S3 spec).
10//!
11//! ## scope (v0.6 #42)
12//!
13//! - in-memory only (single-instance scope) with optional JSON snapshot for
14//!   restart-recoverable state — same shape as `versioning.rs`'s
15//!   `--versioning-state-file` and `object_lock.rs`'s
16//!   `--object-lock-state-file`.
17//! - one shared "default" secret that applies to every bucket whose
18//!   `MfaDelete` is `Enabled` and that has no per-bucket override
19//!   ([`MfaDeleteManager::set_default_secret`]).
20//! - per-bucket override is supported via [`MfaDeleteManager::set_bucket_secret`]
21//!   so an operator can isolate bucket families behind separate hardware
22//!   tokens.
23//! - **not in scope** for v0.6 #42: per-IAM-user secrets, hardware token
24//!   serial validation (we only match the serial string the client sends
25//!   against the configured one — we do not verify the token model /
26//!   device class), FIDO2 / WebAuthn (S3 MFA Delete is TOTP only on AWS
27//!   itself).
28//!
29//! ## semantics
30//!
31//! - `is_enabled(bucket)` → `false` for buckets that have never been
32//!   configured (S3 default — MFA Delete must be opt-in per bucket via
33//!   `PutBucketVersioning` with `MfaDelete = Enabled`).
34//! - `lookup_secret(bucket)` → per-bucket override if present, else the
35//!   default; `None` only when neither has been set (in which case any
36//!   `is_enabled(bucket) = true` request is rejected as `Missing` /
37//!   `InvalidCode` because there's no secret to verify against).
38//! - [`verify_totp`] uses RFC 6238 SHA-1, 6 digits, 30-second step, with
39//!   `±1` step skew tolerance (the AWS / Authenticator-app default — a
40//!   user typing the code at second 28 still works against the next
41//!   step, and a clock-drifted server still validates a freshly-minted
42//!   code).
43
44use std::collections::HashMap;
45use std::sync::RwLock;
46
47use serde::{Deserialize, Serialize};
48use totp_rs::{Algorithm, TOTP};
49
50/// One TOTP authentication device's worth of state. The base32 secret is
51/// shared between client and server and must be at least 128 bits (16
52/// bytes raw → 26 chars un-padded base32, RFC 6238 requirement) — shorter
53/// secrets are rejected by [`verify_totp`] when the underlying TOTP
54/// constructor refuses them.
55#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
56pub struct MfaSecret {
57    /// Base32-encoded TOTP secret (RFC 4648, un-padded). Length is
58    /// provider-defined (typically 16 / 32 chars).
59    pub secret_base32: String,
60    /// Serial — opaque identifier the client sends in `x-amz-mfa`. Used
61    /// only for matching; we do not validate it as a hardware-token serial
62    /// (AWS itself doesn't either at the protocol level — the serial is
63    /// a free-form string a sysadmin types into IAM).
64    pub serial: String,
65}
66
67/// Top-level manager. Owns the gateway-wide "default" secret + per-bucket
68/// overrides + per-bucket MFA-Delete enabled/disabled state. All public
69/// operations go through `RwLock` for thread safety; an `Arc<MfaDeleteManager>`
70/// is the expected handle shape (same pattern as `VersioningManager` /
71/// `ObjectLockManager`).
72#[derive(Debug, Default)]
73pub struct MfaDeleteManager {
74    /// Default secret applied to every bucket whose MFA Delete is
75    /// `Enabled` and that has no per-bucket override.
76    default_secret: RwLock<Option<MfaSecret>>,
77    /// Per-bucket override.
78    by_bucket: RwLock<HashMap<String, MfaSecret>>,
79    /// Per-bucket MFA-Delete state (Enabled / Disabled). When the entry
80    /// is absent the bucket inherits Disabled (S3 default — MFA Delete
81    /// must be opt-in per bucket).
82    enabled: RwLock<HashMap<String, bool>>,
83}
84
85/// Snapshot wrapper used by [`MfaDeleteManager::to_json`] /
86/// [`MfaDeleteManager::from_json`].
87#[derive(Debug, Default, Serialize, Deserialize)]
88struct MfaSnapshot {
89    default_secret: Option<MfaSecret>,
90    by_bucket: HashMap<String, MfaSecret>,
91    enabled: HashMap<String, bool>,
92}
93
94impl MfaDeleteManager {
95    /// Empty manager — no default secret, no per-bucket overrides, no
96    /// bucket has MFA Delete enabled.
97    #[must_use]
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// Install (or replace) the gateway-wide default secret. Buckets with
103    /// `is_enabled(bucket) == true` and no per-bucket override use this
104    /// secret to verify the client-supplied TOTP code.
105    pub fn set_default_secret(&self, secret: MfaSecret) {
106        *crate::lock_recovery::recover_write(&self.default_secret, "mfa.default_secret") =
107            Some(secret);
108    }
109
110    /// Install (or replace) a per-bucket override.
111    pub fn set_bucket_secret(&self, bucket: &str, secret: MfaSecret) {
112        crate::lock_recovery::recover_write(&self.by_bucket, "mfa.by_bucket")
113            .insert(bucket.to_owned(), secret);
114    }
115
116    /// Toggle MFA Delete on `bucket`. `true` enables enforcement (every
117    /// subsequent DELETE / DELETE-version / delete-marker request needs
118    /// `x-amz-mfa`); `false` disables (the bucket falls back to the
119    /// regular versioning DELETE flow).
120    pub fn set_bucket_state(&self, bucket: &str, enabled: bool) {
121        crate::lock_recovery::recover_write(&self.enabled, "mfa.enabled")
122            .insert(bucket.to_owned(), enabled);
123    }
124
125    /// `true` when `bucket` has explicitly enabled MFA Delete (default
126    /// `false` for never-configured buckets, matching S3 spec).
127    #[must_use]
128    pub fn is_enabled(&self, bucket: &str) -> bool {
129        crate::lock_recovery::recover_read(&self.enabled, "mfa.enabled")
130            .get(bucket)
131            .copied()
132            .unwrap_or(false)
133    }
134
135    /// Lookup the MFA secret to use when verifying a request against
136    /// `bucket`: per-bucket override takes precedence over the default.
137    /// Returns `None` when neither has been configured.
138    #[must_use]
139    pub fn lookup_secret(&self, bucket: &str) -> Option<MfaSecret> {
140        if let Some(s) = crate::lock_recovery::recover_read(&self.by_bucket, "mfa.by_bucket")
141            .get(bucket)
142            .cloned()
143        {
144            return Some(s);
145        }
146        crate::lock_recovery::recover_read(&self.default_secret, "mfa.default_secret").clone()
147    }
148
149    /// JSON snapshot for restart-recoverable state. Pair with
150    /// [`Self::from_json`].
151    pub fn to_json(&self) -> Result<String, serde_json::Error> {
152        let snap = MfaSnapshot {
153            default_secret: crate::lock_recovery::recover_read(
154                &self.default_secret,
155                "mfa.default_secret",
156            )
157            .clone(),
158            by_bucket: crate::lock_recovery::recover_read(&self.by_bucket, "mfa.by_bucket").clone(),
159            enabled: crate::lock_recovery::recover_read(&self.enabled, "mfa.enabled").clone(),
160        };
161        serde_json::to_string(&snap)
162    }
163
164    /// Restore from a JSON snapshot produced by [`Self::to_json`].
165    pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
166        let snap: MfaSnapshot = serde_json::from_str(s)?;
167        Ok(Self {
168            default_secret: RwLock::new(snap.default_secret),
169            by_bucket: RwLock::new(snap.by_bucket),
170            enabled: RwLock::new(snap.enabled),
171        })
172    }
173}
174
175/// Errors surfaced by [`check_mfa`] / [`parse_mfa_header`].
176#[derive(Debug, thiserror::Error)]
177pub enum MfaError {
178    #[error("missing x-amz-mfa header (MFA Delete is Enabled on this bucket)")]
179    Missing,
180    #[error("malformed x-amz-mfa header")]
181    Malformed,
182    #[error("MFA serial does not match configured device")]
183    SerialMismatch,
184    #[error("invalid MFA code")]
185    InvalidCode,
186}
187
188/// Parse the `x-amz-mfa` header value, format: `<serial> <code>` where
189/// `code` is a 6-digit ASCII numeric string. Whitespace runs of more than
190/// one ASCII space between serial and code are rejected; trailing /
191/// leading whitespace likewise. AWS itself accepts a single ASCII space
192/// here — clients always emit exactly one — so we keep the parser strict
193/// to surface caller bugs early.
194pub fn parse_mfa_header(value: &str) -> Result<(String, String), MfaError> {
195    let mut parts = value.splitn(2, ' ');
196    let serial = parts.next().ok_or(MfaError::Malformed)?;
197    let code = parts.next().ok_or(MfaError::Malformed)?;
198    if serial.is_empty() || code.is_empty() {
199        return Err(MfaError::Malformed);
200    }
201    // No further unsplit chunk allowed.
202    if value.split(' ').count() != 2 {
203        return Err(MfaError::Malformed);
204    }
205    if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
206        return Err(MfaError::Malformed);
207    }
208    Ok((serial.to_owned(), code.to_owned()))
209}
210
211/// Verify a 6-digit TOTP `code` against the base32-encoded `secret_base32`
212/// at the wall-clock time `now_unix_secs`. Allows ±1 30-second step for
213/// clock skew (RFC 6238 default). Returns `false` when the secret is
214/// shorter than RFC 6238's 128-bit minimum, when the base32 fails to
215/// decode, or when the code does not match any of the three checked
216/// windows.
217#[must_use]
218pub fn verify_totp(secret_base32: &str, code: &str, now_unix_secs: u64) -> bool {
219    let Some(raw) = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, secret_base32)
220    else {
221        return false;
222    };
223    let Ok(totp) = TOTP::new(Algorithm::SHA1, 6, 1, 30, raw) else {
224        return false;
225    };
226    totp.check(code, now_unix_secs)
227}
228
229/// Convenience: parse + verify in one call. Drives the full
230/// `is_enabled(bucket) ⇒ require header ⇒ parse ⇒ serial-match ⇒
231/// TOTP-verify` flow against `manager`. Returns `Ok(())` when the
232/// bucket has MFA Delete disabled (no-op) OR when every check passes;
233/// otherwise the first error encountered.
234pub fn check_mfa(
235    bucket: &str,
236    header_value: Option<&str>,
237    manager: &MfaDeleteManager,
238    now_unix_secs: u64,
239) -> Result<(), MfaError> {
240    if !manager.is_enabled(bucket) {
241        return Ok(());
242    }
243    let header = header_value.ok_or(MfaError::Missing)?;
244    let (serial, code) = parse_mfa_header(header)?;
245    let secret = manager.lookup_secret(bucket).ok_or(MfaError::InvalidCode)?;
246    if serial != secret.serial {
247        return Err(MfaError::SerialMismatch);
248    }
249    if !verify_totp(&secret.secret_base32, &code, now_unix_secs) {
250        return Err(MfaError::InvalidCode);
251    }
252    Ok(())
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    /// 16-byte raw secret encoded as un-padded base32. 26 chars (RFC 4648
260    /// without padding) — the minimum length the TOTP constructor will
261    /// accept. `JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP` is the standard
262    /// "Hello!" test secret padded out by repetition; any 16+ byte raw
263    /// string works.
264    const TEST_SECRET_B32: &str = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP";
265
266    fn raw_secret() -> Vec<u8> {
267        base32::decode(
268            base32::Alphabet::Rfc4648 { padding: false },
269            TEST_SECRET_B32,
270        )
271        .expect("decode test secret")
272    }
273
274    fn totp_at(time: u64) -> String {
275        let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, raw_secret()).expect("totp");
276        totp.generate(time)
277    }
278
279    #[test]
280    fn parse_mfa_header_happy_path() {
281        let (serial, code) = parse_mfa_header("SERIAL 123456").expect("parse");
282        assert_eq!(serial, "SERIAL");
283        assert_eq!(code, "123456");
284    }
285
286    #[test]
287    fn parse_mfa_header_rejects_no_space() {
288        let err = parse_mfa_header("SERIAL123456").expect_err("must fail");
289        assert!(matches!(err, MfaError::Malformed));
290    }
291
292    #[test]
293    fn parse_mfa_header_rejects_extra_token() {
294        let err = parse_mfa_header("SERIAL 123456 trailing").expect_err("must fail");
295        assert!(matches!(err, MfaError::Malformed));
296    }
297
298    #[test]
299    fn parse_mfa_header_rejects_non_digit_code() {
300        let err = parse_mfa_header("SERIAL 12345A").expect_err("must fail");
301        assert!(matches!(err, MfaError::Malformed));
302    }
303
304    #[test]
305    fn parse_mfa_header_rejects_wrong_length_code() {
306        for bad in ["SERIAL 12345", "SERIAL 1234567"] {
307            let err = parse_mfa_header(bad).expect_err("must fail");
308            assert!(matches!(err, MfaError::Malformed));
309        }
310    }
311
312    #[test]
313    fn parse_mfa_header_rejects_empty_serial_or_code() {
314        let err = parse_mfa_header(" 123456").expect_err("empty serial");
315        assert!(matches!(err, MfaError::Malformed));
316        let err = parse_mfa_header("SERIAL ").expect_err("empty code");
317        assert!(matches!(err, MfaError::Malformed));
318    }
319
320    #[test]
321    fn verify_totp_happy_path() {
322        let now = 1_700_000_000_u64;
323        let code = totp_at(now);
324        assert!(verify_totp(TEST_SECRET_B32, &code, now));
325    }
326
327    #[test]
328    fn verify_totp_clock_skew_within_one_step_ok() {
329        // Generate at t-30, verify at t → still within ±1 step skew.
330        let now = 1_700_000_000_u64;
331        let code_prev = totp_at(now - 30);
332        assert!(
333            verify_totp(TEST_SECRET_B32, &code_prev, now),
334            "previous 30s window must validate"
335        );
336        let code_next = totp_at(now + 30);
337        assert!(
338            verify_totp(TEST_SECRET_B32, &code_next, now),
339            "next 30s window must validate"
340        );
341    }
342
343    #[test]
344    fn verify_totp_clock_skew_beyond_window_fails() {
345        // Generate at t-90 (= 3 steps in the past), verify at t → outside
346        // the ±1 skew tolerance.
347        let now = 1_700_000_000_u64;
348        let code_old = totp_at(now - 90);
349        assert!(!verify_totp(TEST_SECRET_B32, &code_old, now));
350    }
351
352    #[test]
353    fn verify_totp_wrong_code_fails() {
354        let now = 1_700_000_000_u64;
355        assert!(!verify_totp(TEST_SECRET_B32, "000000", now));
356    }
357
358    #[test]
359    fn verify_totp_short_secret_rejected() {
360        // 8 bytes = below RFC 6238's 128-bit minimum.
361        let short_b32 = "JBSWY3DP";
362        let now = 1_700_000_000_u64;
363        assert!(!verify_totp(short_b32, "000000", now));
364    }
365
366    #[test]
367    fn check_mfa_disabled_bucket_is_noop() {
368        let m = MfaDeleteManager::new();
369        // No state set → is_enabled = false → check returns Ok regardless
370        // of header.
371        assert!(check_mfa("b", None, &m, 0).is_ok());
372        assert!(check_mfa("b", Some("garbage"), &m, 0).is_ok());
373    }
374
375    #[test]
376    fn check_mfa_enabled_correct_code_ok() {
377        let m = MfaDeleteManager::new();
378        m.set_default_secret(MfaSecret {
379            secret_base32: TEST_SECRET_B32.to_owned(),
380            serial: "SERIAL-A".to_owned(),
381        });
382        m.set_bucket_state("b", true);
383        let now = 1_700_000_000_u64;
384        let code = totp_at(now);
385        let header = format!("SERIAL-A {code}");
386        assert!(check_mfa("b", Some(&header), &m, now).is_ok());
387    }
388
389    #[test]
390    fn check_mfa_enabled_wrong_code_fails() {
391        let m = MfaDeleteManager::new();
392        m.set_default_secret(MfaSecret {
393            secret_base32: TEST_SECRET_B32.to_owned(),
394            serial: "SERIAL-A".to_owned(),
395        });
396        m.set_bucket_state("b", true);
397        let now = 1_700_000_000_u64;
398        let err = check_mfa("b", Some("SERIAL-A 000000"), &m, now).expect_err("must fail");
399        assert!(matches!(err, MfaError::InvalidCode), "got {err:?}");
400    }
401
402    #[test]
403    fn check_mfa_enabled_missing_header_fails() {
404        let m = MfaDeleteManager::new();
405        m.set_default_secret(MfaSecret {
406            secret_base32: TEST_SECRET_B32.to_owned(),
407            serial: "SERIAL-A".to_owned(),
408        });
409        m.set_bucket_state("b", true);
410        let err = check_mfa("b", None, &m, 0).expect_err("must fail");
411        assert!(matches!(err, MfaError::Missing), "got {err:?}");
412    }
413
414    #[test]
415    fn check_mfa_enabled_serial_mismatch_fails() {
416        let m = MfaDeleteManager::new();
417        m.set_default_secret(MfaSecret {
418            secret_base32: TEST_SECRET_B32.to_owned(),
419            serial: "SERIAL-A".to_owned(),
420        });
421        m.set_bucket_state("b", true);
422        let now = 1_700_000_000_u64;
423        let code = totp_at(now);
424        let header = format!("SERIAL-OTHER {code}");
425        let err = check_mfa("b", Some(&header), &m, now).expect_err("must fail");
426        assert!(matches!(err, MfaError::SerialMismatch), "got {err:?}");
427    }
428
429    #[test]
430    fn check_mfa_per_bucket_override_takes_precedence() {
431        let m = MfaDeleteManager::new();
432        m.set_default_secret(MfaSecret {
433            secret_base32: TEST_SECRET_B32.to_owned(),
434            serial: "DEFAULT".to_owned(),
435        });
436        m.set_bucket_secret(
437            "b",
438            MfaSecret {
439                secret_base32: TEST_SECRET_B32.to_owned(),
440                serial: "BUCKET-OVERRIDE".to_owned(),
441            },
442        );
443        m.set_bucket_state("b", true);
444        let now = 1_700_000_000_u64;
445        let code = totp_at(now);
446        // Default serial must NOT validate any more.
447        let header_default = format!("DEFAULT {code}");
448        assert!(matches!(
449            check_mfa("b", Some(&header_default), &m, now).expect_err("must fail"),
450            MfaError::SerialMismatch
451        ));
452        // Bucket-override serial does.
453        let header_override = format!("BUCKET-OVERRIDE {code}");
454        assert!(check_mfa("b", Some(&header_override), &m, now).is_ok());
455    }
456
457    #[test]
458    fn snapshot_roundtrip() {
459        let m = MfaDeleteManager::new();
460        m.set_default_secret(MfaSecret {
461            secret_base32: TEST_SECRET_B32.to_owned(),
462            serial: "DEFAULT".to_owned(),
463        });
464        m.set_bucket_secret(
465            "b1",
466            MfaSecret {
467                secret_base32: TEST_SECRET_B32.to_owned(),
468                serial: "B1-OVR".to_owned(),
469            },
470        );
471        m.set_bucket_state("b1", true);
472        m.set_bucket_state("b2", false);
473        let json = m.to_json().expect("to_json");
474        let m2 = MfaDeleteManager::from_json(&json).expect("from_json");
475        assert!(m2.is_enabled("b1"));
476        assert!(!m2.is_enabled("b2"));
477        let s = m2.lookup_secret("b1").expect("override survives");
478        assert_eq!(s.serial, "B1-OVR");
479        // Bucket without an override falls back to the default.
480        let s = m2.lookup_secret("other").expect("default survives");
481        assert_eq!(s.serial, "DEFAULT");
482    }
483
484    /// v0.8.4 #77 (audit H-8): a panic inside the `enabled` write
485    /// guard poisons the lock. `to_json` must recover via
486    /// [`crate::lock_recovery::recover_read`] and surface the data
487    /// instead of re-panicking on the SIGUSR1 dump-back path.
488    #[test]
489    fn mfa_to_json_after_panic_recovers_via_poison() {
490        let m = std::sync::Arc::new(MfaDeleteManager::new());
491        m.set_default_secret(MfaSecret {
492            secret_base32: TEST_SECRET_B32.to_owned(),
493            serial: "DEFAULT".to_owned(),
494        });
495        m.set_bucket_state("b", true);
496        let m_cl = std::sync::Arc::clone(&m);
497        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
498            let mut g = m_cl.enabled.write().expect("clean lock");
499            g.insert("b2".into(), true);
500            panic!("force-poison");
501        }));
502        assert!(
503            m.enabled.is_poisoned(),
504            "write panic must poison enabled lock"
505        );
506        let json = m.to_json().expect("to_json after poison must succeed");
507        let m2 = MfaDeleteManager::from_json(&json).expect("from_json");
508        assert!(m2.is_enabled("b"), "recovered snapshot keeps enabled flag");
509        let secret = m2.lookup_secret("b").expect("default secret survives");
510        assert_eq!(secret.serial, "DEFAULT");
511    }
512}