use crate::error::InvalidRequestReason;
use rand_core::CryptoRng;
use time::OffsetDateTime;
use totp_rs::{Algorithm, Builder, Secret, Totp};
use zeroize::Zeroize;
use crate::Error;
use crate::error::ConfigReason;
pub const DEFAULT_DIGITS: u8 = 6;
pub const DEFAULT_STEP_SECS: u64 = 30;
pub const DEFAULT_SKEW: u8 = 1;
pub const SECRET_BYTES: usize = 20;
pub const MIN_SECRET_BYTES: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TotpParams {
digits: u8,
step_secs: u64,
skew: u8,
}
impl TotpParams {
pub fn new(digits: u8, step_secs: u64, skew: u8) -> Result<Self, Error> {
if !matches!(digits, 6 | 8) {
return Err(Error::Config(ConfigReason::TotpDigits));
}
if !(15..=120).contains(&step_secs) {
return Err(Error::Config(ConfigReason::TotpStep));
}
if skew > 3 {
return Err(Error::Config(ConfigReason::TotpSkew));
}
Ok(Self {
digits,
step_secs,
skew,
})
}
#[must_use]
pub const fn digits(self) -> u8 {
self.digits
}
#[must_use]
pub const fn step_secs(self) -> u64 {
self.step_secs
}
#[must_use]
pub const fn skew(self) -> u8 {
self.skew
}
}
impl Default for TotpParams {
fn default() -> Self {
Self {
digits: DEFAULT_DIGITS,
step_secs: DEFAULT_STEP_SECS,
skew: DEFAULT_SKEW,
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct TotpSecret(Vec<u8>);
impl TotpSecret {
pub fn random<R: CryptoRng + ?Sized>(rng: &mut R) -> Self {
let mut bytes = vec![0u8; SECRET_BYTES];
rng.fill_bytes(&mut bytes);
Self(bytes)
}
pub fn from_bytes(mut bytes: Vec<u8>) -> Result<Self, Error> {
if bytes.len() < MIN_SECRET_BYTES {
bytes.zeroize();
return Err(Error::InvalidRequest(
InvalidRequestReason::TotpSecretTooShort,
));
}
Ok(Self(bytes))
}
pub fn from_base32(encoded: &str) -> Result<Self, Error> {
let mut bytes = Vec::with_capacity(encoded.len() * 5 / 8);
let mut buffer: u32 = 0;
let mut bits: u32 = 0;
let symbols = encoded
.trim_end_matches(|c: char| c == '=' || c.is_whitespace())
.chars()
.filter(|c| !c.is_whitespace() && *c != '-');
for symbol in symbols {
let Some(value) = base32_value(symbol) else {
bytes.zeroize();
return Err(Error::InvalidRequest(
InvalidRequestReason::TotpSecretBase32,
));
};
buffer = (buffer << 5) | u32::from(value);
bits += 5;
if bits >= 8 {
bits -= 8;
bytes.push(u8::try_from((buffer >> bits) & 0xff).unwrap_or_default());
buffer &= (1 << bits) - 1;
}
}
buffer.zeroize();
Self::from_bytes(bytes)
}
#[must_use]
pub fn expose_secret(&self) -> &[u8] {
&self.0
}
#[must_use]
pub fn to_base32(&self) -> String {
Secret::from(self.0.clone()).to_base32()
}
pub fn code_at(&self, at: OffsetDateTime, params: TotpParams) -> Result<String, Error> {
let at = u64::try_from(at.unix_timestamp())
.map_err(|_| Error::InvalidRequest(InvalidRequestReason::TimeBeforeEpoch))?;
Ok(build(self, "latchkey", "", params)?
.generate(at)
.to_string())
}
}
impl Drop for TotpSecret {
fn drop(&mut self) {
self.0.zeroize();
}
}
impl std::fmt::Debug for TotpSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("TotpSecret(<redacted>)")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct TotpStep(u64);
impl TotpStep {
#[must_use]
pub const fn new(step: u64) -> Self {
Self(step)
}
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
impl TryFrom<i64> for TotpStep {
type Error = Error;
fn try_from(step: i64) -> Result<Self, Error> {
u64::try_from(step)
.map(Self)
.map_err(|_| Error::InvalidRequest(InvalidRequestReason::NegativeTotpStep))
}
}
impl TryFrom<TotpStep> for i64 {
type Error = Error;
fn try_from(step: TotpStep) -> Result<Self, Error> {
i64::try_from(step.0)
.map_err(|_| Error::InvalidRequest(InvalidRequestReason::TotpStepOverflow))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum TotpRefusal {
#[error("not a code of the configured length")]
Malformed,
#[error("code does not match")]
Mismatch,
#[error("code already used")]
Replayed,
}
impl From<TotpRefusal> for Error {
fn from(_: TotpRefusal) -> Self {
Error::SignInFailed
}
}
#[derive(Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Enrolment {
pub secret_base32: String,
pub otpauth_url: String,
}
impl std::fmt::Debug for Enrolment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Enrolment").finish_non_exhaustive()
}
}
impl Drop for Enrolment {
fn drop(&mut self) {
self.secret_base32.zeroize();
self.otpauth_url.zeroize();
}
}
fn base32_value(symbol: char) -> Option<u8> {
match symbol.to_ascii_uppercase() {
c @ 'A'..='Z' => u8::try_from(c).ok().map(|c| c - b'A'),
c @ '2'..='7' => u8::try_from(c).ok().map(|c| c - b'2' + 26),
_ => None,
}
}
fn build(
secret: &TotpSecret,
issuer: &str,
account: &str,
params: TotpParams,
) -> Result<Totp, Error> {
Builder::new()
.with_algorithm(Algorithm::SHA1)
.with_digits(params.digits)
.with_skew(params.skew.into())
.with_step_duration(params.step_secs)
.with_secret(secret.0.clone())
.with_issuer(Some(issuer.replace(':', "")))
.with_account_name(account.replace(':', ""))
.build()
.map_err(|_| Error::InvalidRequest(InvalidRequestReason::TotpSecret))
}
pub fn enrolment(
secret: &TotpSecret,
issuer: &str,
account: &str,
params: TotpParams,
) -> Result<Enrolment, Error> {
let totp = build(secret, issuer, account, params)?;
Ok(Enrolment {
secret_base32: secret.to_base32(),
otpauth_url: totp
.to_url()
.map_err(|_| Error::InvalidRequest(InvalidRequestReason::TotpUrl))?,
})
}
#[must_use]
pub fn looks_like_code(code: &str, params: TotpParams) -> bool {
let mut digits = 0usize;
for c in code.chars().filter(|c| !c.is_whitespace()) {
if !c.is_ascii_digit() {
return false;
}
digits += 1;
}
digits == usize::from(params.digits)
}
pub fn verify(
secret: &TotpSecret,
code: &str,
last_used_step: Option<TotpStep>,
now: OffsetDateTime,
params: TotpParams,
) -> Result<TotpStep, TotpRefusal> {
if !looks_like_code(code, params) {
return Err(TotpRefusal::Malformed);
}
let code: String = code.chars().filter(|c| !c.is_whitespace()).collect();
let now = u64::try_from(now.unix_timestamp()).map_err(|_| TotpRefusal::Mismatch)?;
let totp = build(secret, "latchkey", "", params).map_err(|_| TotpRefusal::Mismatch)?;
let step = totp
.check(&code, now)
.map(TotpStep)
.ok_or(TotpRefusal::Mismatch)?;
match last_used_step {
Some(last) if step <= last => Err(TotpRefusal::Replayed),
_ => Ok(step),
}
}
#[cfg(feature = "qr")]
#[cfg_attr(docsrs, doc(cfg(feature = "qr")))]
pub fn qr_svg(otpauth_url: &str) -> Result<String, Error> {
use std::fmt::Write as _;
let qr = qrcodegen::QrCode::encode_text(otpauth_url, qrcodegen::QrCodeEcc::Medium)
.map_err(|_| Error::InvalidRequest(InvalidRequestReason::QrTooLong))?;
let border = 4;
let size = qr.size() + border * 2;
let mut path = String::new();
for y in 0..qr.size() {
for x in 0..qr.size() {
if qr.get_module(x, y) {
let _ = write!(path, "M{},{}h1v1h-1z", x + border, y + border);
}
}
}
Ok(format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {size} {size}\" \
shape-rendering=\"crispEdges\"><rect width=\"100%\" height=\"100%\" fill=\"#fff\"/>\
<path d=\"{path}\" fill=\"#000\"/></svg>"
))
}
#[cfg(feature = "qr-png")]
#[cfg_attr(docsrs, doc(cfg(feature = "qr-png")))]
pub fn qr_png(otpauth_url: &str) -> Result<Vec<u8>, Error> {
qrcodegen_image::draw_png(otpauth_url)
.map_err(|_| Error::InvalidRequest(InvalidRequestReason::QrPng))
}
#[cfg(test)]
mod tests {
use super::*;
use time::Duration;
const P: TotpParams = TotpParams {
digits: DEFAULT_DIGITS,
step_secs: DEFAULT_STEP_SECS,
skew: DEFAULT_SKEW,
};
fn at(unix: i64) -> OffsetDateTime {
OffsetDateTime::from_unix_timestamp(unix).unwrap_or(OffsetDateTime::UNIX_EPOCH)
}
#[test]
fn accepts_current_and_adjacent_steps_only() -> Result<(), Error> {
let secret = TotpSecret::random(&mut crate::test_rng());
let now = at(1_800_000_000);
let step = TotpStep(1_800_000_000 / 30);
assert_eq!(
verify(&secret, &secret.code_at(now, P)?, None, now, P),
Ok(step)
);
let previous = now - Duration::seconds(30);
assert_eq!(
verify(&secret, &secret.code_at(previous, P)?, None, now, P),
Ok(TotpStep(step.get() - 1))
);
let old = now - Duration::seconds(120);
assert_eq!(
verify(&secret, &secret.code_at(old, P)?, None, now, P),
Err(TotpRefusal::Mismatch)
);
Ok(())
}
#[test]
fn a_step_is_accepted_once_and_never_an_older_one() -> Result<(), Error> {
let secret = TotpSecret::random(&mut crate::test_rng());
let now = at(1_800_000_000);
let code = secret.code_at(now, P)?;
let step = verify(&secret, &code, None, now, P)?;
assert_eq!(
verify(&secret, &code, Some(step), now, P),
Err(TotpRefusal::Replayed)
);
let previous = secret.code_at(now - Duration::seconds(30), P)?;
assert_eq!(
verify(&secret, &previous, Some(step), now, P),
Err(TotpRefusal::Replayed)
);
let next_time = now + Duration::seconds(30);
let next = secret.code_at(next_time, P)?;
assert_eq!(
verify(&secret, &next, Some(step), next_time, P),
Ok(TotpStep(step.get() + 1))
);
Ok(())
}
#[test]
fn rejects_malformed_codes() {
let secret = TotpSecret::random(&mut crate::test_rng());
let now = at(1_800_000_000);
assert_eq!(
verify(&secret, "12345", None, now, P),
Err(TotpRefusal::Malformed)
);
assert_eq!(
verify(&secret, "abcdef", None, now, P),
Err(TotpRefusal::Malformed)
);
assert!(looks_like_code(" 123 456 ", P));
assert!(!looks_like_code("abcd-efgh-jkmn", P));
}
#[test]
fn short_secrets_are_refused() {
assert_eq!(
TotpSecret::from_bytes(vec![1; MIN_SECRET_BYTES - 1]).err(),
Some(Error::InvalidRequest(
InvalidRequestReason::TotpSecretTooShort
))
);
assert!(TotpSecret::from_bytes(vec![1; MIN_SECRET_BYTES]).is_ok());
let secret = TotpSecret(vec![1; 8]);
assert!(enrolment(&secret, "Home", "o@example.com", P).is_err());
assert_eq!(
verify(&secret, "123456", None, at(1_800_000_000), P),
Err(TotpRefusal::Mismatch)
);
}
#[test]
fn base32_secrets_import_and_round_trip() -> Result<(), Error> {
let secret = TotpSecret::random(&mut crate::test_rng());
let encoded = secret.to_base32();
assert_eq!(TotpSecret::from_base32(&encoded)?, secret);
assert_eq!(TotpSecret::from_base32(&encoded.to_lowercase())?, secret);
let spaced: String = encoded
.chars()
.enumerate()
.flat_map(|(i, c)| (i > 0 && i % 4 == 0).then_some(' ').into_iter().chain([c]))
.collect();
assert_eq!(TotpSecret::from_base32(&spaced)?, secret);
let padded = TotpSecret::from_base32("MZXW6YTBOI======MZXW6YTBOI======");
assert_eq!(
padded.err(),
Some(Error::InvalidRequest(
InvalidRequestReason::TotpSecretBase32
)),
"padding only at the end"
);
let foobar = TotpSecret::from_base32("MZXW6YTBOJWW6YTBOJWW6YTBOI======")?;
assert_eq!(foobar.expose_secret(), b"foobarmobarmobar");
let now = at(1_800_000_000);
let imported = TotpSecret::from_base32(&encoded)?;
assert!(verify(&imported, &secret.code_at(now, P)?, None, now, P).is_ok());
Ok(())
}
#[test]
fn base32_secrets_must_be_valid_and_long_enough() {
assert_eq!(
TotpSecret::from_base32("JBSWY3DPEHPK3PXP").err(),
Some(Error::InvalidRequest(
InvalidRequestReason::TotpSecretTooShort
))
);
assert_eq!(
TotpSecret::from_base32("JBSWY3DPEHPK3PXP1BSWY3DPEHPK3PXP").err(),
Some(Error::InvalidRequest(
InvalidRequestReason::TotpSecretBase32
))
);
assert_eq!(
TotpSecret::from_base32("").err(),
Some(Error::InvalidRequest(
InvalidRequestReason::TotpSecretTooShort
))
);
assert!(TotpSecret::from_base32("JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PX\u{e9}").is_err());
}
#[test]
fn enrolment_url_names_issuer_and_account() -> Result<(), Error> {
let enrolment = enrolment(
&TotpSecret::random(&mut crate::test_rng()),
"Home",
"owner@example.com",
P,
)?;
assert!(enrolment.otpauth_url.starts_with("otpauth://totp/Home"));
assert!(enrolment.otpauth_url.contains("owner%40example.com"));
assert_eq!(enrolment.secret_base32.len(), 32);
assert!(!format!("{enrolment:?}").contains(&enrolment.secret_base32));
Ok(())
}
#[test]
fn eight_digits_and_a_sixty_second_step_take_effect_and_reach_the_url() -> Result<(), Error> {
let params = TotpParams::new(8, 60, 1)?;
let secret = TotpSecret::random(&mut crate::test_rng());
let now = at(1_800_000_000);
let code = secret.code_at(now, params)?;
assert_eq!(code.len(), 8);
assert_eq!(
verify(&secret, &code, None, now, params),
Ok(TotpStep(1_800_000_000 / 60))
);
assert_eq!(
verify(&secret, &secret.code_at(now, P)?, None, now, params),
Err(TotpRefusal::Malformed)
);
assert_eq!(
verify(&secret, &code, None, now, P),
Err(TotpRefusal::Malformed)
);
let earlier = now - Duration::seconds(60);
assert!(
verify(
&secret,
&secret.code_at(earlier, params)?,
None,
now,
params
)
.is_ok()
);
let too_old = now - Duration::seconds(180);
assert_eq!(
verify(
&secret,
&secret.code_at(too_old, params)?,
None,
now,
params
),
Err(TotpRefusal::Mismatch)
);
let url = enrolment(&secret, "Home", "o@example.com", params)?
.otpauth_url
.clone();
assert!(url.contains("digits=8"), "{url}");
assert!(url.contains("period=60"), "{url}");
let default_url = enrolment(&secret, "Home", "o@example.com", P)?
.otpauth_url
.clone();
assert!(!default_url.contains("digits="));
assert!(!default_url.contains("period="));
let only_step = enrolment(&secret, "Home", "o", TotpParams::new(6, 45, 0)?)?;
assert!(only_step.otpauth_url.contains("period=45"));
assert!(!only_step.otpauth_url.contains("digits="));
Ok(())
}
#[test]
fn a_skew_of_zero_accepts_only_the_current_step() -> Result<(), Error> {
let params = TotpParams::new(6, 30, 0)?;
let secret = TotpSecret::random(&mut crate::test_rng());
let now = at(1_800_000_010);
let previous = secret.code_at(now - Duration::seconds(30), params)?;
assert_eq!(
verify(&secret, &previous, None, now, params),
Err(TotpRefusal::Mismatch)
);
let three = TotpParams::new(6, 30, 3)?;
let old = secret.code_at(now - Duration::seconds(90), three)?;
assert!(verify(&secret, &old, None, now, three).is_ok());
Ok(())
}
#[test]
fn params_out_of_bounds_are_refused() {
assert_eq!(TotpParams::default(), P);
for (digits, step, skew, reason) in [
(7, 30, 1, ConfigReason::TotpDigits),
(10, 30, 1, ConfigReason::TotpDigits),
(6, 14, 1, ConfigReason::TotpStep),
(6, 121, 1, ConfigReason::TotpStep),
(6, 30, 4, ConfigReason::TotpSkew),
] {
assert_eq!(
TotpParams::new(digits, step, skew),
Err(Error::Config(reason))
);
}
assert!(TotpParams::new(8, 15, 0).is_ok());
assert!(TotpParams::new(6, 120, 3).is_ok());
}
#[test]
fn steps_convert_from_the_old_signed_column() {
assert_eq!(TotpStep::try_from(5i64), Ok(TotpStep(5)));
assert!(TotpStep::try_from(-1i64).is_err());
}
#[cfg(feature = "qr")]
#[test]
fn qr_svg_is_an_svg() -> Result<(), Error> {
let svg = qr_svg("otpauth://totp/Home:o?secret=ABC&issuer=Home")?;
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
Ok(())
}
#[cfg(feature = "qr-png")]
#[test]
fn qr_png_is_a_png() -> Result<(), Error> {
let png = qr_png("otpauth://totp/Home:o?secret=ABC&issuer=Home")?;
assert!(png.starts_with(b"\x89PNG"));
Ok(())
}
}