use crate::{Algorithm, Builder, Secret, Totp, TotpError};
use alloc::{
format,
string::{String, ToString},
vec,
};
use url::{Host, Url};
#[cfg_attr(docsrs, doc(cfg(feature = "otpauth")))]
impl crate::Totp {
pub fn from_url<S: AsRef<str>>(url: S) -> Result<Totp, TotpError> {
let builder = Self::parts_from_url(url)?;
if builder.secret.is_none() {
return Err(TotpError::SecretNotSet);
}
builder.build()
}
pub fn from_url_unchecked<S: AsRef<str>>(url: S) -> Result<Totp, TotpError> {
let builder = Self::parts_from_url(url)?;
if builder.secret.is_none() {
return Err(TotpError::SecretNotSet);
}
Ok(builder.build_noncompliant())
}
fn parts_from_url<S: AsRef<str>>(url: S) -> Result<Builder, TotpError> {
let url = Url::parse(url.as_ref()).map_err(TotpError::UrlParse)?;
if url.scheme() != "otpauth" {
return Err(TotpError::InvalidScheme {
scheme: url.scheme().to_string(),
});
}
let mut builder = match url.host() {
Some(Host::Domain("totp")) => Ok(Builder::new()),
#[cfg(feature = "steam")]
Some(Host::Domain("steam")) => Ok(Builder::new_steam()),
host => Err(TotpError::InvalidHost {
host: host.map(|host| host.to_string()).unwrap_or_default(),
}),
}?;
let path = url.path().trim_start_matches('/');
let (issuer_raw, account_raw) = split_label(path);
let account_name = percent_decode(account_raw)
.ok_or_else(|| TotpError::AccountNameDecode {
account_name: account_raw.to_string(),
})?
.to_string();
let mut issuer: Option<String> = None;
if let Some(issuer_raw) = issuer_raw {
let decoded = percent_decode(issuer_raw)
.ok_or_else(|| TotpError::IssuerDecode {
issuer: issuer_raw.to_string(),
})?
.to_string();
builder = builder.with_issuer(Some(&*decoded));
issuer = Some(decoded);
}
builder = builder.with_account_name(account_name);
for (key, value) in url.query_pairs() {
match key.as_ref() {
"algorithm" => {
let algorithm = Algorithm::try_from(value.to_string())
.map_err(|cause| TotpError::InvalidAlgorithm { cause })?;
builder = builder.with_algorithm(algorithm);
}
"digits" => {
let digits = value.parse::<u8>().map_err(|_| TotpError::DigitsParse {
digits: value.to_string(),
})?;
builder = builder.with_digits(digits);
}
"period" => {
let step_duration = value.parse::<u64>().map_err(|_| TotpError::StepParse {
step: value.to_string(),
})?;
builder = builder.with_step_duration(step_duration);
}
"secret" => {
let secret =
Secret::try_from_base32(value).map_err(|_| TotpError::InvalidSecret)?;
builder = builder.with_secret(secret);
}
"issuer" => {
let param_issuer: String = value.into();
if issuer.as_ref().is_some()
&& param_issuer.as_str() != issuer.as_ref().unwrap()
{
return Err(TotpError::IssuerMismatch {
path: issuer.as_ref().unwrap().to_string(),
query: param_issuer,
});
}
builder = builder.with_issuer(Some(&*param_issuer));
issuer = Some(param_issuer);
}
_ => {}
}
}
#[cfg(feature = "steam")]
if url.host().unwrap() == Host::Domain("steam")
|| builder.algorithm == Algorithm::Steam
|| issuer
.as_deref()
.is_some_and(|i| i.eq_ignore_ascii_case("steam"))
{
builder = builder
.with_algorithm(Algorithm::Steam)
.with_digits(5)
.with_issuer(Some("Steam"));
}
Ok(builder)
}
pub fn to_url(&self) -> Result<String, TotpError> {
#[cfg(feature = "otpauth")]
crate::rfc::assert_account_name_valid(&self.account_name)?;
#[allow(unused_mut)]
let mut host = "totp";
#[cfg(feature = "steam")]
if self.algorithm == Algorithm::Steam {
host = "steam";
}
let account_name = percent_encode(&self.account_name).to_string();
let mut params = vec![format!("secret={}", self.secret().to_base32())];
if self.digits != 6 {
params.push(format!("digits={}", self.digits));
}
if self.algorithm != Algorithm::SHA1 {
#[cfg(feature = "steam")]
let algorithm = if self.algorithm == Algorithm::Steam {
"SHA1"
} else {
self.algorithm.as_str()
};
#[cfg(not(feature = "steam"))]
let algorithm = self.algorithm.as_str();
params.push(format!("algorithm={algorithm}"));
}
let label = if let Some(issuer) = &self.issuer {
let issuer = percent_encode(issuer);
params.push(format!("issuer={}", issuer));
format!("{}:{}", issuer, account_name)
} else {
account_name
};
if self.step != 30 {
params.push(format!("period={}", self.step));
}
Ok(format!("otpauth://{}/{}?{}", host, label, params.join("&")))
}
}
fn split_label(path: &str) -> (Option<&str>, &str) {
let bytes = path.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b':' {
return (Some(&path[..i]), &path[i + 1..]);
}
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& bytes[i + 1] == b'3'
&& bytes[i + 2].eq_ignore_ascii_case(&b'A')
{
return (Some(&path[..i]), &path[i + 3..]);
}
i += 1;
}
(None, path)
}
fn percent_decode(input: &str) -> Option<impl core::fmt::Display + Clone + '_> {
let decoded = percent_encoding::percent_decode_str(input)
.decode_utf8()
.ok()?;
Some(decoded)
}
fn percent_encode(input: &str) -> impl core::fmt::Display + Clone + '_ {
const URL_INCOMPATIBLE: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'~');
percent_encoding::utf8_percent_encode(input, URL_INCOMPATIBLE)
}
#[cfg(test)]
mod tests {
use crate::{Algorithm, Builder, Secret, Totp, TotpError};
const GOOD_SECRET: &[u8] = "TestSecretSuperSecret".as_bytes();
const GOOD_ISSUER: &str = "Github";
const GOOD_ACCOUNT: &str = "constantoine@github.com";
#[test]
#[cfg(feature = "gen_secret")]
fn default_values() {
let totp = Totp::default();
assert_eq!(totp.algorithm, Algorithm::SHA1);
assert_eq!(totp.digits, 6);
assert_eq!(totp.skew, 1);
assert_eq!(totp.step, 30)
}
#[test]
fn to_url_without_account_name_reports_missing_not_invalid() {
let totp = Builder::new().with_secret(GOOD_SECRET).build().unwrap();
assert_eq!(totp.account_name(), "");
assert_eq!(totp.to_url().unwrap_err(), TotpError::AccountNameNotSet);
}
#[test]
fn url_for_secret_matches_sha1_without_issuer() {
let totp = Builder::new()
.with_account_name(GOOD_ACCOUNT)
.with_secret(GOOD_SECRET)
.build()
.unwrap();
let url = totp.to_url();
assert_eq!(
url.ok().as_deref(),
Some(
"otpauth://totp/constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ"
)
);
}
#[test]
fn url_for_secret_matches_sha1() {
let totp = Builder::new()
.with_algorithm(Algorithm::SHA1)
.with_account_name(GOOD_ACCOUNT)
.with_issuer(Some(GOOD_ISSUER))
.with_secret(GOOD_SECRET)
.build()
.unwrap();
let url = totp.to_url();
assert_eq!(
url.ok().as_deref(),
Some(
"otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&issuer=Github"
)
);
}
#[test]
fn url_for_secret_matches_sha256() {
let totp = Builder::new()
.with_algorithm(Algorithm::SHA256)
.with_account_name(GOOD_ACCOUNT)
.with_issuer(Some(GOOD_ISSUER))
.with_secret(GOOD_SECRET)
.build()
.unwrap();
let url = totp.to_url();
assert_eq!(
url.ok().as_deref(),
Some(
"otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&algorithm=SHA256&issuer=Github"
)
);
}
#[test]
fn url_for_secret_matches_sha512() {
let totp = Builder::new()
.with_algorithm(Algorithm::SHA512)
.with_account_name(GOOD_ACCOUNT)
.with_issuer(Some(GOOD_ISSUER))
.with_secret(GOOD_SECRET)
.build()
.unwrap();
let url = totp.to_url();
assert_eq!(
url.ok().as_deref(),
Some(
"otpauth://totp/Github:constantoine%40github.com?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&algorithm=SHA512&issuer=Github"
)
);
}
#[test]
fn from_url_err() {
assert!(Totp::from_url("otpauth://hotp/123").is_err());
assert!(Totp::from_url("otpauth://totp/GitHub:test").is_err());
assert!(
Totp::from_url(
"otpauth://totp/GitHub:test:?secret=ABC&digits=8&period=60&algorithm=SHA256"
)
.is_err()
);
assert!(Totp::from_url("otpauth://totp/Github:constantoine%40github.com?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").is_err())
}
#[test]
fn from_url_default() {
let totp =
Totp::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ")
.unwrap();
assert_eq!(
Ok(totp.secret()),
Secret::try_from_base32("KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ").as_ref(),
);
assert_eq!(totp.algorithm, Algorithm::SHA1);
assert_eq!(totp.digits, 6);
assert_eq!(totp.skew, 1);
assert_eq!(totp.step, 30);
}
#[test]
fn from_url_query() {
let totp = Totp::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256").unwrap();
assert_eq!(
Ok(totp.secret()),
Secret::try_from_base32("KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ").as_ref(),
);
assert_eq!(totp.algorithm, Algorithm::SHA256);
assert_eq!(totp.digits, 8);
assert_eq!(totp.skew, 1);
assert_eq!(totp.step, 60);
}
#[test]
fn from_url_query_sha512() {
let totp = Totp::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA512").unwrap();
assert_eq!(
Ok(totp.secret()),
Secret::try_from_base32("KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ").as_ref(),
);
assert_eq!(totp.algorithm, Algorithm::SHA512);
assert_eq!(totp.digits, 8);
assert_eq!(totp.skew, 1);
assert_eq!(totp.step, 60);
}
#[test]
fn from_url_to_url() {
let totp = Totp::from_url("otpauth://totp/Github:constantoine%40github.com?issuer=Github&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").unwrap();
let totp_bis = Builder::new()
.with_algorithm(Algorithm::SHA1)
.with_account_name(GOOD_ACCOUNT)
.with_issuer(Some(GOOD_ISSUER))
.with_secret(GOOD_SECRET)
.build()
.unwrap();
assert_eq!(totp.to_url(), totp_bis.to_url());
}
#[test]
fn from_url_unknown_param() {
let totp = Totp::from_url("otpauth://totp/GitHub:test?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256&foo=bar").unwrap();
assert_eq!(
Ok(totp.secret()),
Secret::try_from_base32("KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ").as_ref()
);
assert_eq!(totp.algorithm, Algorithm::SHA256);
assert_eq!(totp.digits, 8);
assert_eq!(totp.skew, 1);
assert_eq!(totp.step, 60);
}
#[test]
fn from_url_account_name_issuer() {
let totp = Totp::from_url("otpauth://totp/Github:constantoine?issuer=Github&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").unwrap();
let totp_bis = Builder::new()
.with_algorithm(Algorithm::SHA1)
.with_account_name("constantoine")
.with_issuer(Some(GOOD_ISSUER))
.with_secret(GOOD_SECRET)
.build()
.unwrap();
assert_eq!(totp.to_url(), totp_bis.to_url());
assert_eq!(&*totp.account_name, "constantoine");
assert_eq!(&**totp.issuer.as_ref().unwrap(), "Github");
}
#[test]
fn from_url_account_name_percent_escape_decoded_once() {
let totp = Totp::from_url(
"otpauth://totp/user%252Fadmin?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ",
)
.unwrap();
assert_eq!(totp.account_name(), "user%2Fadmin");
assert_eq!(totp.issuer(), None);
}
#[test]
fn from_url_round_trips_account_name_with_percent() {
let totp = Builder::new()
.with_account_name("user%2Fadmin")
.with_issuer(Some(GOOD_ISSUER))
.with_secret(GOOD_SECRET)
.build()
.unwrap();
let round_tripped = Totp::from_url(totp.to_url().unwrap()).unwrap();
assert_eq!(round_tripped.account_name(), "user%2Fadmin");
assert_eq!(round_tripped.issuer(), Some(GOOD_ISSUER));
}
#[test]
fn from_url_account_name_issuer_encoded() {
let totp = Totp::from_url("otpauth://totp/Github%3Aconstantoine?issuer=Github&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=6&algorithm=SHA1").unwrap();
let totp_bis = Builder::new()
.with_algorithm(Algorithm::SHA1)
.with_account_name("constantoine")
.with_issuer(Some(GOOD_ISSUER))
.with_secret(GOOD_SECRET)
.build()
.unwrap();
assert_eq!(totp.to_url(), totp_bis.to_url());
assert_eq!(&*totp.account_name, "constantoine");
assert_eq!(&**totp.issuer.as_ref().unwrap(), "Github");
}
#[test]
fn from_url_query_issuer() {
let totp = Totp::from_url("otpauth://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256").unwrap();
assert_eq!(
Ok(totp.secret()),
Secret::try_from_base32("KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ").as_ref(),
);
assert_eq!(totp.algorithm, Algorithm::SHA256);
assert_eq!(totp.digits, 8);
assert_eq!(totp.skew, 1);
assert_eq!(totp.step, 60);
assert_eq!(&**totp.issuer.as_ref().unwrap(), "GitHub");
}
#[test]
fn from_url_hostless_is_error_not_panic() {
for url in [
"otpauth:totp?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ",
"otpauth:foo",
"otpauth:/totp",
] {
let err = Totp::from_url(url).unwrap_err();
assert!(
matches!(err, TotpError::InvalidHost { .. }),
"expected InvalidHost for {url:?}, got {err:?}"
);
}
}
#[test]
fn from_url_wrong_scheme() {
let totp = Totp::from_url(
"http://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256",
);
assert!(totp.is_err());
let err = totp.unwrap_err();
assert!(matches!(err, TotpError::InvalidScheme { .. }));
}
#[test]
fn from_url_wrong_algo() {
let totp = Totp::from_url(
"otpauth://totp/GitHub:test?issuer=GitHub&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=MD5",
);
assert!(totp.is_err());
let err = totp.unwrap_err();
assert!(matches!(err, TotpError::InvalidAlgorithm { .. }));
}
#[test]
fn from_url_query_different_issuers() {
let totp = Totp::from_url(
"otpauth://totp/GitHub:test?issuer=Gitlab&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256",
);
assert!(totp.is_err());
assert!(matches!(
totp.unwrap_err(),
TotpError::IssuerMismatch { .. },
));
}
#[test]
fn from_url_no_issuer() {
let totp = Totp::from_url(
"otpauth://totp/test?&secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&digits=8&period=60&algorithm=SHA256",
).unwrap();
assert_eq!(
Ok(totp.secret()),
Secret::try_from_base32("KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ").as_ref(),
);
assert_eq!(totp.algorithm, Algorithm::SHA256);
assert_eq!(totp.digits, 8);
assert_eq!(totp.skew, 1);
assert_eq!(totp.step, 60);
assert_eq!(totp.issuer.as_ref(), None);
}
#[cfg(feature = "steam")]
#[test]
fn from_steam_format_1() {
let totp = Totp::from_url(
"otpauth://totp/Steam:username?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&issuer=Steam",
)
.unwrap();
assert_eq!(
Ok(totp.secret()),
Secret::try_from_base32("KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ").as_ref(),
);
assert_eq!(totp.algorithm, Algorithm::Steam);
assert_eq!(totp.digits, 5);
assert_eq!(totp.skew, 1);
assert_eq!(totp.step, 30);
assert_eq!(&**totp.issuer.as_ref().unwrap(), "Steam");
}
#[cfg(feature = "steam")]
#[test]
fn from_steam_format_2() {
let totp = Totp::from_url(
"otpauth://steam/Steam:username?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ",
)
.unwrap();
assert_eq!(
Ok(totp.secret()),
Secret::try_from_base32("KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ").as_ref(),
);
assert_eq!(totp.algorithm, Algorithm::Steam);
assert_eq!(totp.digits, 5);
assert_eq!(totp.skew, 1);
assert_eq!(totp.step, 30);
assert_eq!(&**totp.issuer.as_ref().unwrap(), "Steam");
}
#[cfg(feature = "steam")]
#[test]
fn from_steam_host_authoritative_over_algorithm() {
let totp = Totp::from_url(
"otpauth://steam/username?secret=KRSXG5CTMVRXEZLUKN2XAZLSKNSWG4TFOQ&algorithm=SHA1",
)
.unwrap();
assert_eq!(totp.algorithm, Algorithm::Steam);
assert_eq!(totp.digits, 5);
}
}