authenticator/models/
otp_uri.rs

1use std::{fmt::Write, str::FromStr};
2
3use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
4use url::Url;
5use zeroize::{Zeroize, ZeroizeOnDrop};
6
7use crate::{
8    backup::RestorableItem,
9    models::{Account, Algorithm, Method, OTP},
10};
11
12#[allow(clippy::upper_case_acronyms)]
13#[derive(Debug, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
14pub struct OTPUri {
15    #[zeroize(skip)]
16    pub(crate) algorithm: Algorithm,
17    #[zeroize(skip)]
18    pub(crate) label: String,
19    pub(crate) secret: String,
20    #[zeroize(skip)]
21    pub(crate) issuer: String,
22    #[zeroize(skip)]
23    pub(crate) method: Method,
24    #[zeroize(skip)]
25    pub(crate) digits: Option<u32>,
26    #[zeroize(skip)]
27    pub(crate) period: Option<u32>,
28    #[zeroize(skip)]
29    pub(crate) counter: Option<u32>,
30}
31
32impl RestorableItem for OTPUri {
33    fn account(&self) -> String {
34        self.label.clone()
35    }
36
37    fn issuer(&self) -> String {
38        self.issuer.clone()
39    }
40
41    fn secret(&self) -> String {
42        self.secret.clone()
43    }
44
45    fn period(&self) -> Option<u32> {
46        self.period
47    }
48
49    fn method(&self) -> Method {
50        self.method
51    }
52
53    fn algorithm(&self) -> Algorithm {
54        self.algorithm
55    }
56
57    fn digits(&self) -> Option<u32> {
58        self.digits
59    }
60
61    fn counter(&self) -> Option<u32> {
62        self.counter
63    }
64}
65
66impl TryFrom<Url> for OTPUri {
67    type Error = anyhow::Error;
68    fn try_from(url: Url) -> Result<Self, Self::Error> {
69        if url.scheme() != "otpauth" {
70            anyhow::bail!(
71                "Invalid OTP uri format, expected otpauth, got {}",
72                url.scheme()
73            );
74        }
75
76        let mut period = None;
77        let mut counter = None;
78        let mut digits = None;
79        let mut provider_name = None;
80        let mut algorithm = None;
81        let mut secret = None;
82
83        let pairs = url.query_pairs();
84
85        let method = Method::from_str(url.host_str().unwrap())?;
86
87        let account_info = url
88            .path()
89            .trim_start_matches('/')
90            .split(':')
91            .collect::<Vec<&str>>();
92
93        let account_name = if account_info.len() == 1 {
94            account_info.first().unwrap()
95        } else {
96            // If we have "Provider:Account"
97            provider_name = Some(account_info.first().unwrap().to_string());
98            account_info.get(1).unwrap()
99        };
100
101        pairs.for_each(|(key, value)| match key.into_owned().as_str() {
102            "period" => {
103                period = value.parse::<u32>().ok();
104            }
105            "digits" => {
106                digits = value.parse::<u32>().ok();
107            }
108            "counter" => {
109                counter = value.parse::<u32>().ok();
110            }
111            "issuer" => {
112                provider_name = Some(value.to_string());
113            }
114            "algorithm" => {
115                algorithm = Algorithm::from_str(&value).ok();
116            }
117            "secret" => {
118                secret = Some(value.to_string());
119            }
120            _ => (),
121        });
122
123        if secret.is_none() {
124            anyhow::bail!("OTP uri must contain a secret");
125        }
126
127        let label = percent_decode_str(account_name).decode_utf8()?.into_owned();
128        let issuer = if let Some(n) = provider_name {
129            percent_decode_str(&n).decode_utf8()?.into_owned()
130        } else {
131            "Default".to_string()
132        };
133
134        Ok(Self {
135            method,
136            label,
137            secret: secret.unwrap(),
138            issuer,
139            algorithm: algorithm.unwrap_or_default(),
140            digits,
141            period,
142            counter,
143        })
144    }
145}
146
147impl FromStr for OTPUri {
148    type Err = anyhow::Error;
149    fn from_str(uri: &str) -> Result<Self, Self::Err> {
150        let url = Url::parse(uri)?;
151        OTPUri::try_from(url)
152    }
153}
154
155impl From<OTPUri> for String {
156    fn from(val: OTPUri) -> Self {
157        let mut otp_uri = format!(
158            "otpauth://{}/{}?secret={}&issuer={}&algorithm={}",
159            val.method,
160            utf8_percent_encode(&val.label, NON_ALPHANUMERIC),
161            val.secret,
162            utf8_percent_encode(&val.issuer, NON_ALPHANUMERIC),
163            val.algorithm.to_string().to_uppercase(),
164        );
165        if let Some(digits) = val.digits {
166            write!(otp_uri, "&digits={digits}").unwrap();
167        }
168        if val.method.is_event_based() {
169            write!(
170                otp_uri,
171                "&counter={}",
172                val.counter.unwrap_or(OTP::DEFAULT_COUNTER)
173            )
174            .unwrap();
175        } else {
176            write!(
177                otp_uri,
178                "&period={}",
179                val.period.unwrap_or(OTP::DEFAULT_PERIOD)
180            )
181            .unwrap();
182        }
183        otp_uri
184    }
185}
186
187impl From<&Account> for OTPUri {
188    fn from(a: &Account) -> Self {
189        Self {
190            method: a.provider().method(),
191            label: a.name(),
192            secret: a.otp().secret(),
193            issuer: a.provider().name(),
194            algorithm: a.provider().algorithm(),
195            digits: Some(a.provider().digits()),
196            period: Some(a.provider().period()),
197            counter: Some(a.counter()),
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use std::str::FromStr;
205
206    use super::OTPUri;
207    use crate::{
208        backup::RestorableItem,
209        models::{Algorithm, Method},
210    };
211
212    #[test]
213    fn decode() {
214        let uri = OTPUri::from_str(
215            "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
216        )
217        .unwrap();
218        assert_eq!(uri.method(), Method::TOTP);
219        assert_eq!(uri.issuer(), "Example");
220        assert_eq!(uri.secret(), "JBSWY3DPEHPK3PXP");
221        assert_eq!(uri.account(), "alice@google.com");
222
223        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();
224        assert_eq!(uri.period(), Some(30));
225        assert_eq!(uri.digits(), Some(6));
226        assert_eq!(uri.algorithm(), Algorithm::SHA1);
227        assert_eq!(uri.issuer(), "ACME Co");
228        assert_eq!(uri.secret(), "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ");
229        assert_eq!(uri.account(), "john.doe@email.com");
230        assert_eq!(uri.method(), Method::TOTP);
231
232        let uri = OTPUri::from_str("otpauth://totp/GitLab:sbeve72?secret=[secret]&issuer=GitLab")
233            .unwrap();
234        assert_eq!(uri.issuer(), "GitLab");
235        assert_eq!(uri.account(), "sbeve72");
236        assert_eq!(uri.secret(), "[secret]");
237        assert_eq!(uri.method(), Method::TOTP);
238    }
239
240    #[test]
241    fn encode() {
242        let uri = OTPUri {
243            algorithm: Algorithm::SHA1,
244            label: "account test".to_owned(),
245            secret: "dznF36H0IIg17rK".to_owned(),
246            issuer: "Test".to_owned(),
247            method: Method::TOTP,
248            digits: Some(6),
249            period: Some(30),
250            counter: None,
251        };
252        assert_eq!(
253            String::from(uri),
254            "otpauth://totp/account%20test?secret=dznF36H0IIg17rK&issuer=Test&algorithm=SHA1&digits=6&period=30"
255        );
256    }
257
258    #[test]
259    fn ljl_issue() {
260        let uri = OTPUri {
261            algorithm: Algorithm::SHA512,
262            label: "SpidItalia%3REDACTED@REDACTED (persona fisica)".to_owned(),
263            secret: "REDACTED".to_owned(),
264            issuer: "SpidItalia".to_owned(),
265            method: Method::TOTP,
266            digits: Some(6),
267            period: Some(30),
268            counter: None,
269        };
270        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);
271    }
272}