#![cfg_attr(docsrs, feature(doc_cfg))]
#![cfg_attr(not(test), no_std)]
#[cfg(feature = "alloc")]
extern crate alloc;
#[cfg(feature = "std")]
extern crate std;
mod algorithm;
mod builder;
mod custom_providers;
mod error;
mod rfc;
mod secret;
mod token;
#[cfg(feature = "otpauth")]
mod url;
#[cfg(feature = "migration")]
mod migration;
pub use algorithm::Algorithm;
pub use builder::Builder;
pub use error::TotpError;
pub use secret::{Secret, SecretParseError};
pub use token::Token;
#[cfg(feature = "migration")]
pub use migration::*;
use core::fmt;
#[cfg(feature = "std")]
use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(feature = "std")]
fn system_time() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time cannot be set before the unix epoch")
.as_secs()
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "zeroize", derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop))]
pub struct Totp {
#[cfg_attr(feature = "zeroize", zeroize(skip))]
pub(crate) algorithm: Algorithm,
pub(crate) digits: u8,
pub(crate) skew: u16,
pub(crate) step: u64,
pub(crate) secret: Secret,
#[cfg(feature = "otpauth")]
#[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
#[cfg_attr(feature = "serde", serde(default))]
pub(crate) issuer: Option<alloc::boxed::Box<str>>,
#[cfg(feature = "otpauth")]
#[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
#[cfg_attr(feature = "serde", serde(default))]
pub(crate) account_name: alloc::boxed::Box<str>,
}
impl Totp {
pub const fn algorithm(&self) -> Algorithm {
self.algorithm
}
pub const fn digits(&self) -> u8 {
self.digits
}
pub const fn skew(&self) -> u16 {
self.skew
}
pub const fn step(&self) -> u64 {
self.step
}
#[cfg(feature = "otpauth")]
#[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
pub fn issuer(&self) -> Option<&str> {
self.issuer.as_deref()
}
#[cfg(feature = "otpauth")]
#[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
pub const fn account_name(&self) -> &str {
&self.account_name
}
}
impl core::fmt::Display for Totp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut succeeded = true;
succeeded &= write!(
f,
"digits: {}; step: {}; alg: {}",
self.digits, self.step, self.algorithm,
)
.is_ok();
#[cfg(feature = "otpauth")]
{
succeeded &= write!(
f,
"; issuer: <{}>({})",
self.issuer.as_deref().unwrap_or("None"),
self.account_name
)
.is_ok();
}
succeeded.then_some(()).ok_or(fmt::Error)
}
}
#[cfg(feature = "gen_secret")]
#[cfg_attr(docsrs, doc(cfg(feature = "gen_secret")))]
impl Default for Totp {
fn default() -> Self {
use crate::Builder;
Builder::new().build_noncompliant()
}
}
impl Totp {
pub fn sign(&self, time: u64) -> impl AsRef<[u8]> {
self.algorithm.sign(self.secret.as_ref(), time / self.step)
}
pub fn generate(&self, time: u64) -> Token {
Token::from_signature(self.algorithm, self.digits, self.sign(time).as_ref())
}
pub fn next_step(&self, time: u64) -> u64 {
let step = time / self.step;
(step + 1) * self.step
}
#[cfg(feature = "std")]
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
pub fn next_step_current(&self) -> u64 {
self.next_step(system_time())
}
#[cfg(feature = "std")]
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
pub fn ttl(&self) -> u64 {
self.step - (system_time() % self.step)
}
#[cfg(feature = "std")]
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
pub fn generate_current(&self) -> Token {
self.generate(system_time())
}
pub fn check(&self, token: &str, time: u64) -> Option<u64> {
let token = Token::try_from_formatted_string(self.algorithm, self.digits, token)?;
let origin = time / self.step;
let mut window = origin.saturating_sub(self.skew as u64)..=(origin + self.skew as u64);
window.find(|&counter| self.generate(counter * self.step) == token)
}
#[cfg(feature = "std")]
#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
pub fn check_current(&self, token: &str) -> Option<u64> {
self.check(token, system_time())
}
pub const fn secret(&self) -> &Secret {
&self.secret
}
}
#[cfg(feature = "qr")]
#[cfg_attr(docsrs, doc(cfg(feature = "qr")))]
impl Totp {
pub fn to_qr_base64(&self) -> Result<alloc::string::String, TotpError> {
let url = self.to_url()?;
qrcodegen_image::draw_base64(&url).map_err(|url| TotpError::UrlTooLong { url })
}
pub fn to_qr_png(&self) -> Result<alloc::vec::Vec<u8>, TotpError> {
let url = self.to_url()?;
qrcodegen_image::draw_png(&url).map_err(|url| TotpError::UrlTooLong { url })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(feature = "gen_secret")]
fn default_values() {
let totp = Totp::default();
assert_eq!(totp.secret.len(), 20);
}
#[test]
#[cfg(feature = "alloc")]
fn generate_token() {
let totp = Builder::new()
.with_step_duration(1)
.with_secret("TestSecretSuperSecret".as_bytes())
.build_noncompliant();
assert_eq!(&totp.generate(1000).to_string(), "659761");
}
#[test]
#[cfg(feature = "std")]
fn generate_token_current() {
let totp = Builder::new()
.with_step_duration(1)
.with_secret("TestSecretSuperSecret".as_bytes())
.build_noncompliant();
let time = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
assert_eq!(totp.generate(time), totp.generate_current());
}
#[test]
#[cfg(feature = "alloc")]
fn generates_token_sha256() {
let totp = Builder::new()
.with_algorithm(Algorithm::SHA256)
.with_step_duration(1)
.with_secret("TestSecretSuperSecret".as_bytes())
.build_noncompliant();
assert_eq!(&totp.generate(1000).to_string(), "076417");
}
#[test]
#[cfg(feature = "alloc")]
fn generates_token_sha512() {
let totp = Builder::new()
.with_algorithm(Algorithm::SHA512)
.with_step_duration(1)
.with_secret("TestSecretSuperSecret".as_bytes())
.build_noncompliant();
assert_eq!(&totp.generate(1000).to_string(), "473536");
}
#[test]
#[cfg(feature = "alloc")]
fn checks_token() {
let totp = Builder::new()
.with_step_duration(1)
.with_skew(0)
.with_secret("TestSecretSuperSecret".as_bytes())
.build_noncompliant();
assert!(totp.check("659761", 1000).is_some());
}
#[test]
#[cfg(feature = "alloc")]
fn checks_token_big_skew() {
let totp = Builder::new()
.with_step_duration(1)
.with_skew(1001)
.with_secret("TestSecretSuperSecret".as_bytes())
.build_noncompliant();
assert!(totp.check("659761", 1000).is_some());
}
#[test]
#[cfg(feature = "std")]
fn checks_token_current() {
let totp = Builder::new()
.with_step_duration(1)
.with_skew(0)
.with_secret("TestSecretSuperSecret".as_bytes())
.build_noncompliant();
let current = totp.generate_current().to_string();
assert!(totp.check_current(¤t).is_some());
assert!(totp.check_current("bogus").is_none());
}
#[test]
#[cfg(feature = "std")]
fn check_ttl() {
let totp = Builder::new()
.with_step_duration(1)
.with_skew(0)
.with_secret("TestSecretSuperSecret".as_bytes())
.build_noncompliant();
let ttl = totp.ttl();
assert!((0..=totp.step).contains(&ttl));
}
#[test]
#[cfg(feature = "alloc")]
fn checks_token_with_skew() {
let totp = Builder::new()
.with_step_duration(1)
.with_secret("TestSecretSuperSecret".as_bytes())
.build_noncompliant();
assert!(
totp.check("174269", 1000).is_some()
&& totp.check("659761", 1000).is_some()
&& totp.check("260393", 1000).is_some()
);
}
#[test]
#[cfg(feature = "alloc")]
fn next_step() {
let totp = Builder::new()
.with_step_duration(30)
.with_secret("TestSecretSuperSecret".as_bytes())
.build_noncompliant();
assert!(totp.next_step(0) == 30);
assert!(totp.next_step(29) == 30);
assert!(totp.next_step(30) == 60);
}
#[test]
#[cfg(feature = "std")]
fn next_step_current() {
let totp = Builder::new()
.with_step_duration(30)
.with_secret("TestSecretSuperSecret".as_bytes())
.build_noncompliant();
assert!(totp.next_step_current() == totp.next_step(system_time()));
}
#[test]
#[cfg(feature = "qr")]
fn generates_qr() {
use qrcodegen_image::qrcodegen;
use sha2::{Digest, Sha512};
let totp = Builder::new()
.with_algorithm(Algorithm::SHA1)
.with_step_duration(30)
.with_skew(1)
.with_secret("TestSecretSuperSecret".as_bytes())
.with_issuer(Some("Github"))
.with_account_name("constantoine@github.com")
.build_noncompliant();
let url = totp.to_url().expect("could not generate url");
let qr = qrcodegen::QrCode::encode_text(&url, qrcodegen::QrCodeEcc::Medium)
.expect("could not generate qr");
let data = qrcodegen_image::draw_canvas(qr).into_raw();
let hash_digest = Sha512::digest(data);
let hash_hex: String = hash_digest.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(
hash_hex.as_str(),
"fbb0804f1e4f4c689d22292c52b95f0783b01b4319973c0c50dd28af23dbbbe663dce4eb05a7959086d9092341cb9f103ec5a9af4a973867944e34c063145328"
);
}
#[test]
#[cfg(feature = "qr")]
fn generates_qr_base64_ok() {
let totp = Builder::new()
.with_algorithm(Algorithm::SHA1)
.with_step_duration(1)
.with_skew(1)
.with_secret("TestSecretSuperSecret".as_bytes())
.with_issuer(Some("Github"))
.with_account_name("constantoine@github.com")
.build_noncompliant();
let qr = totp.to_qr_base64();
assert!(qr.is_ok());
}
#[test]
#[cfg(feature = "qr")]
fn generates_qr_png_ok() {
let totp = Builder::new()
.with_algorithm(Algorithm::SHA1)
.with_step_duration(1)
.with_skew(1)
.with_secret("TestSecretSuperSecret".as_bytes())
.with_issuer(Some("Github"))
.with_account_name("constantoine@github.com")
.build_noncompliant();
let qr = totp.to_qr_png();
assert!(qr.is_ok());
}
#[test]
#[cfg(feature = "qr")]
fn generates_qr_url_too_long() {
let totp = Builder::new()
.with_algorithm(Algorithm::SHA1)
.with_step_duration(30)
.with_skew(1)
.with_secret(vec![0xAA; 2048])
.with_issuer(Some("Github"))
.with_account_name("constantoine@github.com")
.build_noncompliant();
assert!(totp.to_url().is_ok());
let qr = totp.to_qr_base64();
assert!(matches!(&qr, &Err(TotpError::UrlTooLong { .. })));
let error_message = format!("{}", qr.unwrap_err());
assert!(
error_message.starts_with(
"Could not generate a QR code: the generated URL is too long to encode"
)
);
}
#[test]
#[cfg(target_pointer_width = "64")]
fn size_test() {
if cfg!(feature = "otpauth") {
assert_eq!(size_of::<Totp>(), 72);
} else {
assert_eq!(size_of::<Totp>(), 40);
}
}
#[test]
fn check_totp_display_implementation() {
let totp = Builder::new().build_noncompliant();
assert!(!totp.to_string().is_empty());
}
#[test]
#[cfg(all(feature = "serde", feature = "otpauth"))]
fn serde_totp_wire_format() {
use serde_test::{Configure, Token, assert_tokens};
let totp = Builder::new()
.with_secret("TestSecretSuperSecret".as_bytes())
.with_issuer(Some("Github"))
.with_account_name("constantoine@github.com")
.build()
.unwrap();
assert_tokens(
&totp.clone().readable(),
&[
Token::Struct {
name: "Totp",
len: 7,
},
Token::Str("algorithm"),
Token::Str("SHA1"),
Token::Str("digits"),
Token::U8(6),
Token::Str("skew"),
Token::U16(1),
Token::Str("step"),
Token::U64(30),
Token::Str("secret"),
Token::Str("KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"),
Token::Str("issuer"),
Token::Some,
Token::Str("Github"),
Token::Str("account_name"),
Token::Str("constantoine@github.com"),
Token::StructEnd,
],
);
assert_tokens(
&totp.compact(),
&[
Token::Struct {
name: "Totp",
len: 7,
},
Token::Str("algorithm"),
Token::Str("SHA1"),
Token::Str("digits"),
Token::U8(6),
Token::Str("skew"),
Token::U16(1),
Token::Str("step"),
Token::U64(30),
Token::Str("secret"),
Token::Bytes(b"TestSecretSuperSecret"),
Token::Str("issuer"),
Token::Some,
Token::Str("Github"),
Token::Str("account_name"),
Token::Str("constantoine@github.com"),
Token::StructEnd,
],
);
}
#[test]
#[cfg(all(feature = "serde", feature = "alloc", not(feature = "otpauth")))]
fn serde_totp_wire_format_without_otpauth() {
use serde_test::{Configure, Token, assert_tokens};
let totp = Builder::new()
.with_secret("TestSecretSuperSecret".as_bytes())
.build()
.unwrap();
assert_tokens(
&totp.readable(),
&[
Token::Struct {
name: "Totp",
len: 5,
},
Token::Str("algorithm"),
Token::Str("SHA1"),
Token::Str("digits"),
Token::U8(6),
Token::Str("skew"),
Token::U16(1),
Token::Str("step"),
Token::U64(30),
Token::Str("secret"),
Token::Str("KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"),
Token::StructEnd,
],
);
}
#[test]
#[cfg(all(feature = "serde", feature = "otpauth"))]
fn serde_totp_deserializes_data_without_otpauth_fields() {
use serde_test::{Configure, Token, assert_de_tokens};
let expected = Builder::new()
.with_secret("TestSecretSuperSecret".as_bytes())
.build()
.unwrap();
assert_eq!(expected.issuer(), None);
assert_eq!(expected.account_name(), "");
assert_de_tokens(
&expected.readable(),
&[
Token::Struct {
name: "Totp",
len: 5,
},
Token::Str("algorithm"),
Token::Str("SHA1"),
Token::Str("digits"),
Token::U8(6),
Token::Str("skew"),
Token::U16(1),
Token::Str("step"),
Token::U64(30),
Token::Str("secret"),
Token::Str("KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"),
Token::StructEnd,
],
);
}
#[test]
#[cfg(all(feature = "serde", feature = "steam", feature = "otpauth"))]
fn serde_totp_wire_format_steam() {
use serde_test::{Configure, Token, assert_tokens};
let totp = Builder::new_steam()
.with_secret("TestSecretSuperSecret".as_bytes())
.with_account_name("constantoine@github.com")
.build()
.unwrap();
assert_tokens(
&totp.readable(),
&[
Token::Struct {
name: "Totp",
len: 7,
},
Token::Str("algorithm"),
Token::Str("STEAM"),
Token::Str("digits"),
Token::U8(5),
Token::Str("skew"),
Token::U16(1),
Token::Str("step"),
Token::U64(30),
Token::Str("secret"),
Token::Str("KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"),
Token::Str("issuer"),
Token::Some,
Token::Str("Steam"),
Token::Str("account_name"),
Token::Str("constantoine@github.com"),
Token::StructEnd,
],
);
}
}