use std::{fmt::Write, str::FromStr};
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
use url::Url;
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::{
backup::RestorableItem,
models::{Account, Algorithm, Method, OTP},
};
#[allow(clippy::upper_case_acronyms)]
#[derive(Debug, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
pub struct OTPUri {
#[zeroize(skip)]
pub(crate) algorithm: Algorithm,
#[zeroize(skip)]
pub(crate) label: String,
pub(crate) secret: String,
#[zeroize(skip)]
pub(crate) issuer: String,
#[zeroize(skip)]
pub(crate) method: Method,
#[zeroize(skip)]
pub(crate) digits: Option<u32>,
#[zeroize(skip)]
pub(crate) period: Option<u32>,
#[zeroize(skip)]
pub(crate) counter: Option<u32>,
}
impl RestorableItem for OTPUri {
fn account(&self) -> String {
self.label.clone()
}
fn issuer(&self) -> String {
self.issuer.clone()
}
fn secret(&self) -> String {
self.secret.clone()
}
fn period(&self) -> Option<u32> {
self.period
}
fn method(&self) -> Method {
self.method
}
fn algorithm(&self) -> Algorithm {
self.algorithm
}
fn digits(&self) -> Option<u32> {
self.digits
}
fn counter(&self) -> Option<u32> {
self.counter
}
}
impl TryFrom<Url> for OTPUri {
type Error = anyhow::Error;
fn try_from(url: Url) -> Result<Self, Self::Error> {
if url.scheme() != "otpauth" {
anyhow::bail!(
"Invalid OTP uri format, expected otpauth, got {}",
url.scheme()
);
}
let mut period = None;
let mut counter = None;
let mut digits = None;
let mut provider_name = None;
let mut algorithm = None;
let mut secret = None;
let pairs = url.query_pairs();
let method = Method::from_str(url.host_str().unwrap())?;
let account_info = url
.path()
.trim_start_matches('/')
.split(':')
.collect::<Vec<&str>>();
let account_name = if account_info.len() == 1 {
account_info.first().unwrap()
} else {
provider_name = Some(account_info.first().unwrap().to_string());
account_info.get(1).unwrap()
};
pairs.for_each(|(key, value)| match key.into_owned().as_str() {
"period" => {
period = value.parse::<u32>().ok();
}
"digits" => {
digits = value.parse::<u32>().ok();
}
"counter" => {
counter = value.parse::<u32>().ok();
}
"issuer" => {
provider_name = Some(value.to_string());
}
"algorithm" => {
algorithm = Algorithm::from_str(&value).ok();
}
"secret" => {
secret = Some(value.to_string());
}
_ => (),
});
if secret.is_none() {
anyhow::bail!("OTP uri must contain a secret");
}
let label = percent_decode_str(account_name).decode_utf8()?.into_owned();
let issuer = if let Some(n) = provider_name {
percent_decode_str(&n).decode_utf8()?.into_owned()
} else {
"Default".to_string()
};
Ok(Self {
method,
label,
secret: secret.unwrap(),
issuer,
algorithm: algorithm.unwrap_or_default(),
digits,
period,
counter,
})
}
}
impl FromStr for OTPUri {
type Err = anyhow::Error;
fn from_str(uri: &str) -> Result<Self, Self::Err> {
let url = Url::parse(uri)?;
OTPUri::try_from(url)
}
}
impl From<OTPUri> for String {
fn from(val: OTPUri) -> Self {
let mut otp_uri = format!(
"otpauth://{}/{}?secret={}&issuer={}&algorithm={}",
val.method,
utf8_percent_encode(&val.label, NON_ALPHANUMERIC),
val.secret,
utf8_percent_encode(&val.issuer, NON_ALPHANUMERIC),
val.algorithm.to_string().to_uppercase(),
);
if let Some(digits) = val.digits {
write!(otp_uri, "&digits={digits}").unwrap();
}
if val.method.is_event_based() {
write!(
otp_uri,
"&counter={}",
val.counter.unwrap_or(OTP::DEFAULT_COUNTER)
)
.unwrap();
} else {
write!(
otp_uri,
"&period={}",
val.period.unwrap_or(OTP::DEFAULT_PERIOD)
)
.unwrap();
}
otp_uri
}
}
impl From<&Account> for OTPUri {
fn from(a: &Account) -> Self {
Self {
method: a.provider().method(),
label: a.name(),
secret: a.otp().secret(),
issuer: a.provider().name(),
algorithm: a.provider().algorithm(),
digits: Some(a.provider().digits()),
period: Some(a.provider().period()),
counter: Some(a.counter()),
}
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::OTPUri;
use crate::{
backup::RestorableItem,
models::{Algorithm, Method},
};
#[test]
fn decode() {
let uri = OTPUri::from_str(
"otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
)
.unwrap();
assert_eq!(uri.method(), Method::TOTP);
assert_eq!(uri.issuer(), "Example");
assert_eq!(uri.secret(), "JBSWY3DPEHPK3PXP");
assert_eq!(uri.account(), "alice@google.com");
let uri = OTPUri::from_str("otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30").unwrap();
assert_eq!(uri.period(), Some(30));
assert_eq!(uri.digits(), Some(6));
assert_eq!(uri.algorithm(), Algorithm::SHA1);
assert_eq!(uri.issuer(), "ACME Co");
assert_eq!(uri.secret(), "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ");
assert_eq!(uri.account(), "john.doe@email.com");
assert_eq!(uri.method(), Method::TOTP);
let uri = OTPUri::from_str("otpauth://totp/GitLab:sbeve72?secret=[secret]&issuer=GitLab")
.unwrap();
assert_eq!(uri.issuer(), "GitLab");
assert_eq!(uri.account(), "sbeve72");
assert_eq!(uri.secret(), "[secret]");
assert_eq!(uri.method(), Method::TOTP);
}
#[test]
fn encode() {
let uri = OTPUri {
algorithm: Algorithm::SHA1,
label: "account test".to_owned(),
secret: "dznF36H0IIg17rK".to_owned(),
issuer: "Test".to_owned(),
method: Method::TOTP,
digits: Some(6),
period: Some(30),
counter: None,
};
assert_eq!(String::from(uri), "otpauth://totp/account%20test?secret=dznF36H0IIg17rK&issuer=Test&algorithm=SHA1&digits=6&period=30");
}
#[test]
fn ljl_issue() {
let uri = OTPUri {
algorithm: Algorithm::SHA512,
label: "SpidItalia%3REDACTED@REDACTED (persona fisica)".to_owned(),
secret: "REDACTED".to_owned(),
issuer: "SpidItalia".to_owned(),
method: Method::TOTP,
digits: Some(6),
period: Some(30),
counter: None,
};
assert_eq!(OTPUri::from_str("otpauth://totp/SpidItalia%3REDACTED%40REDACTED%20(persona%20fisica)?period=30&digits=6&algorithm=SHA512&secret=REDACTED&issuer=SpidItalia").unwrap(), uri);
}
}