use rand_core::CryptoRng;
use time::OffsetDateTime;
use totp_rs::{Algorithm, Builder, Secret, Totp};
use zeroize::Zeroize;
use crate::Error;
pub const DIGITS: u8 = 6;
pub const STEP_SECS: u64 = 30;
pub const SKEW: u16 = 1;
pub const SECRET_BYTES: usize = 20;
#[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)
}
#[must_use]
pub fn from_bytes(bytes: Vec<u8>) -> Self {
Self(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) -> Result<String, Error> {
let at = u64::try_from(at.unix_timestamp())
.map_err(|_| Error::InvalidRequest("time_before_epoch"))?;
Ok(build(self, "latchkey", "")?.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("negative_totp_step"))
}
}
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("totp_step_overflow"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum TotpRefusal {
#[error("not a six-digit code")]
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)]
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 build(secret: &TotpSecret, issuer: &str, account: &str) -> Result<Totp, Error> {
Builder::new()
.with_algorithm(Algorithm::SHA1)
.with_digits(DIGITS)
.with_skew(SKEW)
.with_step_duration(STEP_SECS)
.with_secret(secret.0.clone())
.with_issuer(Some(issuer.replace(':', "")))
.with_account_name(account.replace(':', ""))
.build()
.map_err(|_| Error::InvalidRequest("totp_secret"))
}
pub fn enrolment(secret: &TotpSecret, issuer: &str, account: &str) -> Result<Enrolment, Error> {
let totp = build(secret, issuer, account)?;
Ok(Enrolment {
secret_base32: secret.to_base32(),
otpauth_url: totp
.to_url()
.map_err(|_| Error::InvalidRequest("totp_url"))?,
})
}
#[must_use]
pub fn looks_like_code(code: &str) -> 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(DIGITS)
}
pub fn verify(
secret: &TotpSecret,
code: &str,
last_used_step: Option<TotpStep>,
now: OffsetDateTime,
) -> Result<TotpStep, TotpRefusal> {
if !looks_like_code(code) {
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", "").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("qr_too_long"))?;
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("qr_png"))
}
#[cfg(test)]
mod tests {
use super::*;
use time::Duration;
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)?, None, now), Ok(step));
let previous = now - Duration::seconds(30);
assert_eq!(
verify(&secret, &secret.code_at(previous)?, None, now),
Ok(TotpStep(step.get() - 1))
);
let old = now - Duration::seconds(120);
assert_eq!(
verify(&secret, &secret.code_at(old)?, None, now),
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)?;
let step = verify(&secret, &code, None, now)?;
assert_eq!(
verify(&secret, &code, Some(step), now),
Err(TotpRefusal::Replayed)
);
let previous = secret.code_at(now - Duration::seconds(30))?;
assert_eq!(
verify(&secret, &previous, Some(step), now),
Err(TotpRefusal::Replayed)
);
let next_time = now + Duration::seconds(30);
let next = secret.code_at(next_time)?;
assert_eq!(
verify(&secret, &next, Some(step), next_time),
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),
Err(TotpRefusal::Malformed)
);
assert_eq!(
verify(&secret, "abcdef", None, now),
Err(TotpRefusal::Malformed)
);
assert!(looks_like_code(" 123 456 "));
assert!(!looks_like_code("abcd-efgh-jkmn"));
}
#[test]
fn short_secrets_are_refused() {
let secret = TotpSecret::from_bytes(vec![1; 8]);
assert!(enrolment(&secret, "Home", "o@example.com").is_err());
assert_eq!(
verify(&secret, "123456", None, at(1_800_000_000)),
Err(TotpRefusal::Mismatch)
);
}
#[test]
fn enrolment_url_names_issuer_and_account() -> Result<(), Error> {
let enrolment = enrolment(
&TotpSecret::random(&mut crate::test_rng()),
"Home",
"owner@example.com",
)?;
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 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(())
}
}