#![deny(missing_docs)]
#![deny(unsafe_code)]
#![doc = include_str!("../README.md")]
#![warn(missing_copy_implementations)]
#![warn(missing_debug_implementations)]
#![warn(clippy::pedantic)]
use totp_rs::{Algorithm, Secret, TOTP};
use base64::{Engine as _, engine::general_purpose};
use rand::{TryRngCore, rngs::OsRng};
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::fmt::{self};
use std::io::{Cursor, Write, stdout};
#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
struct EasyTotpError(String);
impl fmt::Display for EasyTotpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "EasyTotp encountered an error: {}", self.0)
}
}
impl Error for EasyTotpError {}
impl EasyTotpError {
fn new(message: &str) -> Self {
EasyTotpError(message.to_string())
}
}
#[repr(u8)]
#[derive(
Clone, Copy, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
)]
pub enum TerminalQRSize {
#[default]
Full = 0,
#[allow(dead_code)]
Mini = 1,
}
#[repr(u8)]
#[derive(
Clone, Copy, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
)]
pub enum QRColorMode {
Direct = 0,
#[default]
Inverted = 1,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct EasyTotp {
raw_secret: String,
issuer: Option<String>,
account_name: String,
}
impl EasyTotp {
pub fn new(
issuer: Option<String>,
account_name: String,
) -> Result<Self, <OsRng as TryRngCore>::Error> {
let mut secret_bytes = [0u8; 20];
OsRng.try_fill_bytes(&mut secret_bytes)?;
let raw_secret = String::from_utf8_lossy(&secret_bytes).to_string();
let issuer = match issuer {
Some(iss) => Some(iss.to_string()),
None => None,
};
Ok(EasyTotp {
raw_secret,
issuer,
account_name: account_name.to_string(),
})
}
fn new_totp(&self) -> Result<TOTP, EasyTotpError> {
let secret;
let result_secret = Secret::Raw(self.raw_secret.as_bytes().to_vec()).to_bytes();
if let Ok(okay_secret) = result_secret {
secret = okay_secret;
} else {
return Err(EasyTotpError::new("Failed to parse secret key"));
}
let result = TOTP::new(
Algorithm::SHA512,
6,
1,
30,
secret,
self.issuer.clone(),
self.account_name.clone(),
);
if let Ok(okay_result) = result {
Ok(okay_result)
} else {
Err(EasyTotpError::new("Error creating new TOTP instance"))
}
}
fn create_qr(&self) -> Result<String, EasyTotpError> {
let result = Self::new_totp(self)?.get_qr_base64();
if let Ok(okay_result) = result {
Ok(okay_result)
} else {
Err(EasyTotpError::new("Error creating QR code data"))
}
}
#[allow(clippy::cast_precision_loss)]
pub fn qr_text(
&self,
size: TerminalQRSize,
mode: QRColorMode,
) -> Result<Vec<String>, Box<dyn Error>> {
let mut lines = Vec::new();
let decoded_data = general_purpose::STANDARD.decode(Self::create_qr(self)?)?;
let img = image::load_from_memory(&decoded_data)?.to_luma8();
let width = img.width();
let height = img.height();
let terminal_width = 100; let scale_x = width / terminal_width;
let scale_y = scale_x * 2;
for y in (0..height).step_by(scale_y as usize) {
let mut line = String::new();
for x in (0..width).step_by(scale_x as usize) {
let block_darkness = (0..scale_x)
.flat_map(|dx| {
(0..scale_y).map({
let img_val = img.clone();
move |dy| {
let px = (x + dx).min(width - 1);
let py = (y + dy).min(height - 1);
img_val.get_pixel(px, py)[0]
}
})
})
.filter(|&p| p < 128)
.count();
let total_pixels = (scale_x * scale_y) as usize;
let symbol = match block_darkness as f32 / total_pixels as f32 {
d if d > 0.7 => '█', d if d > 0.4 => '▓', d if d > 0.2 => '▒', _ => ' ', };
line.push(symbol);
}
lines.push(line);
}
lines.push(String::from(
"Scan the above QR code with your authenticator app to set up TOTP.",
));
lines.push(String::from(
"BEWARE: this QR code contains your secret key! Handle with care.",
));
lines.push(String::from("Useful tips: if scanning fails, try inverting the QR code colors by adjusting your terminal's background color or "));
lines.push(String::from("using your mouse to select the entire QR code area. Also, ensure your terminal zoom is set to a level that allows "));
lines.push(String::from(
"the QR code to be completely visible onscreen.",
));
stdout().flush()?;
match mode {
QRColorMode::Direct => {}
QRColorMode::Inverted => {
for line in &mut lines {
if line.chars().any(|c| {
c.is_alphanumeric() || c == ':' || c == '.' || c == '@' || c == '!'
}) {
continue;
}
*line = line
.chars()
.map(|c| match c {
'█' => ' ',
'▓' => '░',
'▒' => '▓',
' ' => '█',
_ => c,
})
.collect();
}
}
}
match size {
TerminalQRSize::Full => Ok(lines),
TerminalQRSize::Mini => {
let mut mini_lines = Vec::new();
for line in lines.chunks(2) {
let mut mini_line = String::new();
for (c1, c2) in line[0]
.chars()
.zip(line.get(1).unwrap_or(&String::new()).chars())
{
let mini_char = match (c1, c2) {
('█' | '▓' | '▒', ' ') => '▀',
(' ', '█' | '▓' | '▒') => '▄',
('█', '█') => '█',
('▓', '▓') => '▓',
('▒', '▒') => '▒',
_ => ' ',
};
mini_line.push(mini_char);
}
mini_lines.push(mini_line);
}
Ok(mini_lines)
}
}
}
pub fn create_qr_png(&self) -> Result<Vec<u8>, Box<dyn Error>> {
let decoded_data = general_purpose::STANDARD.decode(Self::create_qr(self)?)?;
let image = image::load_from_memory(&decoded_data)?;
let mut buffer = Vec::new();
let mut cursor = Cursor::new(&mut buffer);
image.write_to(&mut cursor, image::ImageFormat::Png)?;
Ok(buffer)
}
pub fn print_qr_to_teminal(&self, user_mode: QRColorMode) -> Result<(), Box<dyn Error>> {
match user_mode {
QRColorMode::Direct => Self::render_qr_terminal_full_direct(self),
QRColorMode::Inverted => Self::render_qr_terminal_full_inverted(self),
}
}
fn render_qr_terminal_full_direct(&self) -> Result<(), Box<dyn Error>> {
for line in self.qr_text(TerminalQRSize::Full, QRColorMode::Direct)? {
println!("{line}");
}
Ok(())
}
#[allow(dead_code)]
fn render_qr_terminal_mini_direct(&self) -> Result<(), Box<dyn Error>> {
for line in self.qr_text(TerminalQRSize::Mini, QRColorMode::Direct)? {
println!("{line}");
}
Ok(())
}
fn render_qr_terminal_full_inverted(&self) -> Result<(), Box<dyn Error>> {
for line in self.qr_text(TerminalQRSize::Full, QRColorMode::Inverted)? {
println!("{line}");
}
Ok(())
}
#[allow(dead_code)]
fn render_qr_terminal_mini_inverted(&self) -> Result<(), Box<dyn Error>> {
for line in self.qr_text(TerminalQRSize::Mini, QRColorMode::Inverted)? {
println!("{line}");
}
Ok(())
}
pub fn generate_token(&self) -> Result<String, Box<dyn Error>> {
Ok(self.new_totp()?.generate_current()?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use image;
use rqrr;
use std::fs;
use std::io::Write;
use std::{thread, time};
#[test]
fn test_qr_png() {
let issuer = Some(String::from("McCormick"));
let account_name = String::from("test@test-email.com");
let filename = "./test_images/qr_code.png";
let et = EasyTotp::new(issuer, account_name).unwrap();
let my_qr_code = et.create_qr_png();
match my_qr_code {
Ok(png_data) => {
let mut file = fs::File::create(filename).unwrap();
file.write_all(&png_data).unwrap();
println!("QR code saved as 'qr_code.png'");
}
Err(e) => {
panic!("Error creating QR code: {:?}", e);
}
}
let img = image::open(filename).unwrap().to_luma8();
let mut img = rqrr::PreparedImage::prepare(img);
let grids = img.detect_grids();
assert_eq!(grids.len(), 1);
let (meta, content) = grids[0].decode().unwrap();
assert_eq!(meta.ecc_level, 0);
assert!(content.starts_with("otpauth://totp/McCormick:test%40test-email.com?secret="));
assert!(content.contains("&algorithm=SHA512&issuer=McCormick"));
let secret_start = content.find("secret=").unwrap() + 7;
let secret_end = content.find("&algorithm=").unwrap();
let secret = &content[secret_start..secret_end];
assert!(!secret.is_empty());
assert!(
secret.len() >= 20,
"Secret should be at least 20 characters long"
);
assert!(
secret
.chars()
.all(|c| c.is_ascii_uppercase() || "234567".contains(c)),
"Secret should only contain valid base32 characters"
);
fs::remove_file(filename).unwrap();
}
#[test]
fn test_qr_terminal() {
let raw_secret = String::from("SUPERSecretSecretSecret");
let issuer = Some(String::from("McCormick"));
let account_name = String::from("Account_name");
let et = EasyTotp {
raw_secret,
issuer,
account_name,
};
match et.render_qr_terminal_full_direct() {
Ok(_) => println!("QR code rendered in terminal successfully."),
Err(e) => panic!("Error rendering QR code in terminal: {:?}", e),
}
}
#[test]
fn test_code_generation() {
let raw_secret = String::from("SUPERSecretSecretSecret");
let issuer = Some(String::from("McCormick"));
let account_name = String::from("test@test-email.com");
let et = EasyTotp {
raw_secret: raw_secret.clone(),
issuer: issuer.clone(),
account_name: account_name.clone(),
};
let token1 = et.generate_token().unwrap();
let token2 = et.generate_token().unwrap();
assert_eq!(token1, token2);
thread::sleep(time::Duration::from_secs(30));
let token3 = et.generate_token().unwrap();
assert_ne!(token1, token3);
assert_eq!((6, 6, 6), (token1.len(), token2.len(), token3.len()));
assert_eq!(
(true, true, true),
(
token1.parse::<u32>().is_ok(),
token2.parse::<u32>().is_ok(),
token3.parse::<u32>().is_ok(),
)
);
}
}