use std::collections::HashMap;
use std::sync::RwLock;
use serde::{Deserialize, Serialize};
use totp_rs::{Algorithm, TOTP};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct MfaSecret {
pub secret_base32: String,
pub serial: String,
}
#[derive(Debug, Default)]
pub struct MfaDeleteManager {
default_secret: RwLock<Option<MfaSecret>>,
by_bucket: RwLock<HashMap<String, MfaSecret>>,
enabled: RwLock<HashMap<String, bool>>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct MfaSnapshot {
default_secret: Option<MfaSecret>,
by_bucket: HashMap<String, MfaSecret>,
enabled: HashMap<String, bool>,
}
impl MfaDeleteManager {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn set_default_secret(&self, secret: MfaSecret) {
*crate::lock_recovery::recover_write(&self.default_secret, "mfa.default_secret") =
Some(secret);
}
pub fn set_bucket_secret(&self, bucket: &str, secret: MfaSecret) {
crate::lock_recovery::recover_write(&self.by_bucket, "mfa.by_bucket")
.insert(bucket.to_owned(), secret);
}
pub fn set_bucket_state(&self, bucket: &str, enabled: bool) {
crate::lock_recovery::recover_write(&self.enabled, "mfa.enabled")
.insert(bucket.to_owned(), enabled);
}
#[must_use]
pub fn is_enabled(&self, bucket: &str) -> bool {
crate::lock_recovery::recover_read(&self.enabled, "mfa.enabled")
.get(bucket)
.copied()
.unwrap_or(false)
}
#[must_use]
pub fn lookup_secret(&self, bucket: &str) -> Option<MfaSecret> {
if let Some(s) = crate::lock_recovery::recover_read(&self.by_bucket, "mfa.by_bucket")
.get(bucket)
.cloned()
{
return Some(s);
}
crate::lock_recovery::recover_read(&self.default_secret, "mfa.default_secret").clone()
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
let snap = MfaSnapshot {
default_secret: crate::lock_recovery::recover_read(
&self.default_secret,
"mfa.default_secret",
)
.clone(),
by_bucket: crate::lock_recovery::recover_read(&self.by_bucket, "mfa.by_bucket").clone(),
enabled: crate::lock_recovery::recover_read(&self.enabled, "mfa.enabled").clone(),
};
serde_json::to_string(&snap)
}
pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
let snap: MfaSnapshot = serde_json::from_str(s)?;
Ok(Self {
default_secret: RwLock::new(snap.default_secret),
by_bucket: RwLock::new(snap.by_bucket),
enabled: RwLock::new(snap.enabled),
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum MfaError {
#[error("missing x-amz-mfa header (MFA Delete is Enabled on this bucket)")]
Missing,
#[error("malformed x-amz-mfa header")]
Malformed,
#[error("MFA serial does not match configured device")]
SerialMismatch,
#[error("invalid MFA code")]
InvalidCode,
}
pub fn parse_mfa_header(value: &str) -> Result<(String, String), MfaError> {
let mut parts = value.splitn(2, ' ');
let serial = parts.next().ok_or(MfaError::Malformed)?;
let code = parts.next().ok_or(MfaError::Malformed)?;
if serial.is_empty() || code.is_empty() {
return Err(MfaError::Malformed);
}
if value.split(' ').count() != 2 {
return Err(MfaError::Malformed);
}
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
return Err(MfaError::Malformed);
}
Ok((serial.to_owned(), code.to_owned()))
}
#[must_use]
pub fn verify_totp(secret_base32: &str, code: &str, now_unix_secs: u64) -> bool {
let Some(raw) = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, secret_base32)
else {
return false;
};
let Ok(totp) = TOTP::new(Algorithm::SHA1, 6, 1, 30, raw) else {
return false;
};
totp.check(code, now_unix_secs)
}
pub fn check_mfa(
bucket: &str,
header_value: Option<&str>,
manager: &MfaDeleteManager,
now_unix_secs: u64,
) -> Result<(), MfaError> {
if !manager.is_enabled(bucket) {
return Ok(());
}
let header = header_value.ok_or(MfaError::Missing)?;
let (serial, code) = parse_mfa_header(header)?;
let secret = manager.lookup_secret(bucket).ok_or(MfaError::InvalidCode)?;
if serial != secret.serial {
return Err(MfaError::SerialMismatch);
}
if !verify_totp(&secret.secret_base32, &code, now_unix_secs) {
return Err(MfaError::InvalidCode);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_SECRET_B32: &str = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP";
fn raw_secret() -> Vec<u8> {
base32::decode(
base32::Alphabet::Rfc4648 { padding: false },
TEST_SECRET_B32,
)
.expect("decode test secret")
}
fn totp_at(time: u64) -> String {
let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, raw_secret()).expect("totp");
totp.generate(time)
}
#[test]
fn parse_mfa_header_happy_path() {
let (serial, code) = parse_mfa_header("SERIAL 123456").expect("parse");
assert_eq!(serial, "SERIAL");
assert_eq!(code, "123456");
}
#[test]
fn parse_mfa_header_rejects_no_space() {
let err = parse_mfa_header("SERIAL123456").expect_err("must fail");
assert!(matches!(err, MfaError::Malformed));
}
#[test]
fn parse_mfa_header_rejects_extra_token() {
let err = parse_mfa_header("SERIAL 123456 trailing").expect_err("must fail");
assert!(matches!(err, MfaError::Malformed));
}
#[test]
fn parse_mfa_header_rejects_non_digit_code() {
let err = parse_mfa_header("SERIAL 12345A").expect_err("must fail");
assert!(matches!(err, MfaError::Malformed));
}
#[test]
fn parse_mfa_header_rejects_wrong_length_code() {
for bad in ["SERIAL 12345", "SERIAL 1234567"] {
let err = parse_mfa_header(bad).expect_err("must fail");
assert!(matches!(err, MfaError::Malformed));
}
}
#[test]
fn parse_mfa_header_rejects_empty_serial_or_code() {
let err = parse_mfa_header(" 123456").expect_err("empty serial");
assert!(matches!(err, MfaError::Malformed));
let err = parse_mfa_header("SERIAL ").expect_err("empty code");
assert!(matches!(err, MfaError::Malformed));
}
#[test]
fn verify_totp_happy_path() {
let now = 1_700_000_000_u64;
let code = totp_at(now);
assert!(verify_totp(TEST_SECRET_B32, &code, now));
}
#[test]
fn verify_totp_clock_skew_within_one_step_ok() {
let now = 1_700_000_000_u64;
let code_prev = totp_at(now - 30);
assert!(
verify_totp(TEST_SECRET_B32, &code_prev, now),
"previous 30s window must validate"
);
let code_next = totp_at(now + 30);
assert!(
verify_totp(TEST_SECRET_B32, &code_next, now),
"next 30s window must validate"
);
}
#[test]
fn verify_totp_clock_skew_beyond_window_fails() {
let now = 1_700_000_000_u64;
let code_old = totp_at(now - 90);
assert!(!verify_totp(TEST_SECRET_B32, &code_old, now));
}
#[test]
fn verify_totp_wrong_code_fails() {
let now = 1_700_000_000_u64;
assert!(!verify_totp(TEST_SECRET_B32, "000000", now));
}
#[test]
fn verify_totp_short_secret_rejected() {
let short_b32 = "JBSWY3DP";
let now = 1_700_000_000_u64;
assert!(!verify_totp(short_b32, "000000", now));
}
#[test]
fn check_mfa_disabled_bucket_is_noop() {
let m = MfaDeleteManager::new();
assert!(check_mfa("b", None, &m, 0).is_ok());
assert!(check_mfa("b", Some("garbage"), &m, 0).is_ok());
}
#[test]
fn check_mfa_enabled_correct_code_ok() {
let m = MfaDeleteManager::new();
m.set_default_secret(MfaSecret {
secret_base32: TEST_SECRET_B32.to_owned(),
serial: "SERIAL-A".to_owned(),
});
m.set_bucket_state("b", true);
let now = 1_700_000_000_u64;
let code = totp_at(now);
let header = format!("SERIAL-A {code}");
assert!(check_mfa("b", Some(&header), &m, now).is_ok());
}
#[test]
fn check_mfa_enabled_wrong_code_fails() {
let m = MfaDeleteManager::new();
m.set_default_secret(MfaSecret {
secret_base32: TEST_SECRET_B32.to_owned(),
serial: "SERIAL-A".to_owned(),
});
m.set_bucket_state("b", true);
let now = 1_700_000_000_u64;
let err = check_mfa("b", Some("SERIAL-A 000000"), &m, now).expect_err("must fail");
assert!(matches!(err, MfaError::InvalidCode), "got {err:?}");
}
#[test]
fn check_mfa_enabled_missing_header_fails() {
let m = MfaDeleteManager::new();
m.set_default_secret(MfaSecret {
secret_base32: TEST_SECRET_B32.to_owned(),
serial: "SERIAL-A".to_owned(),
});
m.set_bucket_state("b", true);
let err = check_mfa("b", None, &m, 0).expect_err("must fail");
assert!(matches!(err, MfaError::Missing), "got {err:?}");
}
#[test]
fn check_mfa_enabled_serial_mismatch_fails() {
let m = MfaDeleteManager::new();
m.set_default_secret(MfaSecret {
secret_base32: TEST_SECRET_B32.to_owned(),
serial: "SERIAL-A".to_owned(),
});
m.set_bucket_state("b", true);
let now = 1_700_000_000_u64;
let code = totp_at(now);
let header = format!("SERIAL-OTHER {code}");
let err = check_mfa("b", Some(&header), &m, now).expect_err("must fail");
assert!(matches!(err, MfaError::SerialMismatch), "got {err:?}");
}
#[test]
fn check_mfa_per_bucket_override_takes_precedence() {
let m = MfaDeleteManager::new();
m.set_default_secret(MfaSecret {
secret_base32: TEST_SECRET_B32.to_owned(),
serial: "DEFAULT".to_owned(),
});
m.set_bucket_secret(
"b",
MfaSecret {
secret_base32: TEST_SECRET_B32.to_owned(),
serial: "BUCKET-OVERRIDE".to_owned(),
},
);
m.set_bucket_state("b", true);
let now = 1_700_000_000_u64;
let code = totp_at(now);
let header_default = format!("DEFAULT {code}");
assert!(matches!(
check_mfa("b", Some(&header_default), &m, now).expect_err("must fail"),
MfaError::SerialMismatch
));
let header_override = format!("BUCKET-OVERRIDE {code}");
assert!(check_mfa("b", Some(&header_override), &m, now).is_ok());
}
#[test]
fn snapshot_roundtrip() {
let m = MfaDeleteManager::new();
m.set_default_secret(MfaSecret {
secret_base32: TEST_SECRET_B32.to_owned(),
serial: "DEFAULT".to_owned(),
});
m.set_bucket_secret(
"b1",
MfaSecret {
secret_base32: TEST_SECRET_B32.to_owned(),
serial: "B1-OVR".to_owned(),
},
);
m.set_bucket_state("b1", true);
m.set_bucket_state("b2", false);
let json = m.to_json().expect("to_json");
let m2 = MfaDeleteManager::from_json(&json).expect("from_json");
assert!(m2.is_enabled("b1"));
assert!(!m2.is_enabled("b2"));
let s = m2.lookup_secret("b1").expect("override survives");
assert_eq!(s.serial, "B1-OVR");
let s = m2.lookup_secret("other").expect("default survives");
assert_eq!(s.serial, "DEFAULT");
}
#[test]
fn mfa_to_json_after_panic_recovers_via_poison() {
let m = std::sync::Arc::new(MfaDeleteManager::new());
m.set_default_secret(MfaSecret {
secret_base32: TEST_SECRET_B32.to_owned(),
serial: "DEFAULT".to_owned(),
});
m.set_bucket_state("b", true);
let m_cl = std::sync::Arc::clone(&m);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut g = m_cl.enabled.write().expect("clean lock");
g.insert("b2".into(), true);
panic!("force-poison");
}));
assert!(
m.enabled.is_poisoned(),
"write panic must poison enabled lock"
);
let json = m.to_json().expect("to_json after poison must succeed");
let m2 = MfaDeleteManager::from_json(&json).expect("from_json");
assert!(m2.is_enabled("b"), "recovered snapshot keeps enabled flag");
let secret = m2.lookup_secret("b").expect("default secret survives");
assert_eq!(secret.serial, "DEFAULT");
}
}