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 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}