use core::fmt::Write as _;
use md5::{Digest, Md5};
use subtle::ConstantTimeEq;
use crate::{Error, ExposeSecret, Result, otp::OtpSecret, secret::SecretString};
pub const PERIOD: u64 = 10;
pub const CODE_LEN: usize = 6;
pub struct MotpPin {
pin: SecretString,
}
impl MotpPin {
#[must_use]
pub fn new(pin: String) -> Self {
Self { pin: pin.into() }
}
pub(crate) fn expose(&self) -> &str {
self.pin.expose_secret()
}
#[must_use]
pub fn len(&self) -> usize {
self.expose().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.expose().is_empty()
}
}
impl core::fmt::Debug for MotpPin {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("MotpPin")
.field("len", &self.len())
.field("pin", &"<redacted>")
.finish()
}
}
impl PartialEq for MotpPin {
fn eq(&self, other: &Self) -> bool {
self.expose()
.as_bytes()
.ct_eq(other.expose().as_bytes())
.into()
}
}
impl Eq for MotpPin {}
fn md5_hex(input: &[u8]) -> String {
let digest = Md5::digest(input);
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
write!(out, "{byte:02x}").expect("writing to a String cannot fail");
}
out
}
pub fn motp_code(
secret: &OtpSecret,
pin: Option<&MotpPin>,
period: u64,
unix_time: u64,
) -> Result<String> {
if period == 0 {
return Err(Error::InvalidPeriod);
}
let Some(pin) = pin else {
return Err(Error::PinRequired { otp_type: "mOTP" });
};
let counter = unix_time / period;
let mut input = String::with_capacity(20 + secret.len() * 2 + pin.len());
write!(input, "{counter}").expect("writing to a String cannot fail");
for byte in secret.expose() {
write!(input, "{byte:02x}").expect("writing to a String cannot fail");
}
input.push_str(pin.expose());
let digest = md5_hex(input.as_bytes());
Ok(digest.chars().take(CODE_LEN).collect())
}
#[cfg(test)]
mod tests {
use super::*;
const VECTORS: [(&str, &str, u64, &str); 6] = [
("e3152afee62599c8", "1234", 165_892_298, "e7d8b6"),
("e3152afee62599c8", "1234", 123_456_789, "4ebfb2"),
("bbb1912bb5c515be", "9999", 1_659_540_020, "ced7b1"),
("bbb1912bb5c515be", "9999", 1_659_540_022, "ced7b1"),
("bbb1912bb5c515be", "9999", 1_659_539_870, "1a14f8"),
("bbb1912bb5c515be", "9999", 1_659_539_878, "1a14f8"),
];
const DIGEST_VECTORS: [(&str, &str); 2] = [
("BOB", "355938cfe3b73a624297591972d27c01"),
("test1234", "16d7a4fca7442dda3ad93c9a726597e4"),
];
fn unhex(text: &str) -> Vec<u8> {
assert!(
text.len().is_multiple_of(2),
"hex input must have an even length"
);
text.as_bytes()
.chunks(2)
.map(|pair| {
let text = core::str::from_utf8(pair).expect("hex is ASCII");
u8::from_str_radix(text, 16).expect("hex digits")
})
.collect()
}
#[test]
fn armed_vectors_from_the_reference_implementation() {
for (secret_hex, pin, time, expected) in VECTORS {
let secret = OtpSecret::from_bytes(unhex(secret_hex)).expect("a non-empty secret");
let pin = MotpPin::new(pin.to_owned());
let code = motp_code(&secret, Some(&pin), PERIOD, time).expect("valid inputs");
assert_eq!(
code, expected,
"secret={secret_hex} pin={pin:?} time={time}"
);
}
}
#[test]
fn digest_vectors_pin_the_hexadecimal_encoding() {
for (input, expected) in DIGEST_VECTORS {
assert_eq!(md5_hex(input.as_bytes()), expected, "input={input:?}");
}
}
#[test]
fn counter_is_floor_divided() {
let secret = OtpSecret::from_bytes(unhex("bbb1912bb5c515be")).expect("a secret");
let pin = MotpPin::new("9999".to_owned());
let first = motp_code(&secret, Some(&pin), PERIOD, 1_659_539_870).expect("valid");
let last = motp_code(&secret, Some(&pin), PERIOD, 1_659_539_879).expect("valid");
let next = motp_code(&secret, Some(&pin), PERIOD, 1_659_539_880).expect("valid");
assert_eq!(first, last, "the whole 10-second step must give one code");
assert_ne!(first, next, "the next step must give a different code");
}
#[test]
fn missing_pin_is_rejected_and_empty_pin_is_not() {
let secret = OtpSecret::from_bytes(unhex("e3152afee62599c8")).expect("a secret");
assert!(matches!(
motp_code(&secret, None, PERIOD, 1_700_000_000),
Err(Error::PinRequired { otp_type: "mOTP" })
));
let empty = MotpPin::new(String::new());
let code = motp_code(&secret, Some(&empty), PERIOD, 1_700_000_000).expect("valid inputs");
assert_eq!(code.len(), CODE_LEN);
}
#[test]
fn zero_period_is_rejected() {
let secret = OtpSecret::from_bytes(unhex("e3152afee62599c8")).expect("a secret");
let pin = MotpPin::new("1234".to_owned());
assert!(matches!(
motp_code(&secret, Some(&pin), 0, 1_700_000_000),
Err(Error::InvalidPeriod)
));
}
#[test]
fn code_shape_is_lower_case_hex() {
let secret = OtpSecret::from_bytes(unhex("e3152afee62599c8")).expect("a secret");
let pin = MotpPin::new("1234".to_owned());
for time in (1_600_000_000..1_600_000_000 + 100 * PERIOD).step_by(PERIOD as usize) {
let code = motp_code(&secret, Some(&pin), PERIOD, time).expect("valid inputs");
assert_eq!(code.len(), CODE_LEN, "time={time}");
assert!(
code.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
"not a lower-case hex code: {code}"
);
}
}
#[test]
fn secret_and_pin_both_affect_the_code() {
let secret = OtpSecret::from_bytes(unhex("e3152afee62599c8")).expect("a secret");
let other = OtpSecret::from_bytes(unhex("e3152afee62599c9")).expect("a secret");
let pin = MotpPin::new("1234".to_owned());
let other_pin = MotpPin::new("1235".to_owned());
let base = motp_code(&secret, Some(&pin), PERIOD, 1_700_000_000).expect("valid");
assert_eq!(
base,
motp_code(&secret, Some(&pin), PERIOD, 1_700_000_000).expect("valid")
);
assert_ne!(
base,
motp_code(&other, Some(&pin), PERIOD, 1_700_000_000).expect("valid")
);
assert_ne!(
base,
motp_code(&secret, Some(&other_pin), PERIOD, 1_700_000_000).expect("valid")
);
}
#[test]
fn debug_never_leaks_the_pin() {
let pin = MotpPin::new("1234".to_owned());
let debug = format!("{pin:?}");
assert!(
debug.contains("redacted"),
"not marked as redacted: {debug}"
);
assert!(!debug.contains("1234"), "Debug leaks the PIN: {debug}");
}
#[test]
fn pin_equality() {
let a = MotpPin::new("1234".to_owned());
let b = MotpPin::new("1234".to_owned());
let c = MotpPin::new("12345".to_owned());
assert_eq!(a, b);
assert_ne!(a, c);
assert!(MotpPin::new(String::new()).is_empty());
assert_eq!(MotpPin::new(String::new()).len(), 0);
}
}