1use keyroost_proto::codec::base32_decode;
11use keyroost_proto::commands::{DisplayTimeout, HmacAlgo, OtpDigits, ProfileConfig, TimeStep};
12
13#[derive(Debug, PartialEq, Eq)]
14pub enum OtpAuthError {
15 NotOtpAuth,
16 UnsupportedType(String),
17 MissingSecret,
18 InvalidSecret,
19 UnsupportedAlgorithm(String),
20 UnsupportedDigits(u32),
21 UnsupportedPeriod(u32),
22 Malformed(&'static str),
23}
24
25impl core::fmt::Display for OtpAuthError {
26 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
27 match self {
28 OtpAuthError::NotOtpAuth => write!(f, "not an otpauth:// URI"),
29 OtpAuthError::UnsupportedType(t) => {
30 write!(f, "unsupported OTP type {:?} (only totp is supported)", t)
31 }
32 OtpAuthError::MissingSecret => write!(f, "URI is missing the `secret` parameter"),
33 OtpAuthError::InvalidSecret => write!(f, "`secret` is not valid base32"),
34 OtpAuthError::UnsupportedAlgorithm(a) => write!(
35 f,
36 "algorithm {:?} not supported by Molto2 (SHA1 or SHA256 only)",
37 a
38 ),
39 OtpAuthError::UnsupportedDigits(d) => {
40 write!(f, "digits={} not supported by Molto2 (4, 6, 8, or 10)", d)
41 }
42 OtpAuthError::UnsupportedPeriod(p) => {
43 write!(f, "period={}s not supported by Molto2 (30 or 60)", p)
44 }
45 OtpAuthError::Malformed(s) => write!(f, "malformed URI: {}", s),
46 }
47 }
48}
49
50impl std::error::Error for OtpAuthError {}
51
52#[derive(Clone)]
54pub struct OtpAuth {
55 pub issuer: Option<String>,
57 pub account: Option<String>,
59 pub secret: Vec<u8>,
61 pub algorithm: HmacAlgo,
62 pub digits: OtpDigits,
63 pub time_step: TimeStep,
64}
65
66impl std::fmt::Debug for OtpAuth {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 f.debug_struct("OtpAuth")
71 .field("issuer", &self.issuer)
72 .field("account", &self.account)
73 .field("secret", &format_args!("[{} bytes]", self.secret.len()))
74 .field("algorithm", &self.algorithm)
75 .field("digits", &self.digits)
76 .field("time_step", &self.time_step)
77 .finish()
78 }
79}
80
81impl Drop for OtpAuth {
85 fn drop(&mut self) {
86 use zeroize::Zeroize;
87 self.secret.zeroize();
88 }
89}
90
91impl OtpAuth {
92 pub fn suggested_title(&self) -> String {
95 let candidate = self
96 .issuer
97 .as_deref()
98 .or(self.account.as_deref())
99 .unwrap_or("");
100 truncate_bytes(candidate, 12).to_owned()
101 }
102
103 pub fn to_profile_config(
106 &self,
107 utc_time: u32,
108 display_timeout: DisplayTimeout,
109 ) -> ProfileConfig {
110 ProfileConfig {
111 display_timeout,
112 algorithm: self.algorithm,
113 digits: self.digits,
114 time_step: self.time_step,
115 utc_time,
116 }
117 }
118}
119
120pub fn parse(uri: &str) -> Result<OtpAuth, OtpAuthError> {
123 const PREFIX: &str = "otpauth://";
124 let rest = uri.strip_prefix(PREFIX).ok_or(OtpAuthError::NotOtpAuth)?;
125
126 let (typ_label, query) = match rest.split_once('?') {
128 Some((a, b)) => (a, b),
129 None => (rest, ""),
130 };
131 let (typ, label_raw) = typ_label
132 .split_once('/')
133 .ok_or(OtpAuthError::Malformed("missing label"))?;
134 if !typ.eq_ignore_ascii_case("totp") {
135 return Err(OtpAuthError::UnsupportedType(typ.to_owned()));
136 }
137 let label =
138 percent_decode(label_raw).map_err(|_| OtpAuthError::Malformed("label percent-encoding"))?;
139
140 let mut secret_b32: Option<String> = None;
142 let mut issuer_param: Option<String> = None;
143 let mut algorithm = HmacAlgo::Sha1;
144 let mut digits = OtpDigits::Six;
145 let mut period: u32 = 30;
146
147 for kv in query.split('&').filter(|s| !s.is_empty()) {
148 let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
149 let v = percent_decode(v)
150 .map_err(|_| OtpAuthError::Malformed("query value percent-encoding"))?;
151 match k {
152 "secret" => secret_b32 = Some(v),
153 "issuer" => issuer_param = Some(v),
154 "algorithm" => match v.to_ascii_uppercase().as_str() {
155 "SHA1" => algorithm = HmacAlgo::Sha1,
156 "SHA256" => algorithm = HmacAlgo::Sha256,
157 other => return Err(OtpAuthError::UnsupportedAlgorithm(other.to_owned())),
158 },
159 "digits" => {
160 let n: u32 = v.parse().map_err(|_| OtpAuthError::Malformed("digits"))?;
161 digits = match n {
162 4 => OtpDigits::Four,
163 6 => OtpDigits::Six,
164 8 => OtpDigits::Eight,
165 10 => OtpDigits::Ten,
166 other => return Err(OtpAuthError::UnsupportedDigits(other)),
167 };
168 }
169 "period" => {
170 period = v.parse().map_err(|_| OtpAuthError::Malformed("period"))?;
171 }
172 _ => {} }
174 }
175
176 let time_step = match period {
177 30 => TimeStep::Seconds30,
178 60 => TimeStep::Seconds60,
179 other => return Err(OtpAuthError::UnsupportedPeriod(other)),
180 };
181
182 let mut secret_b32 = secret_b32.ok_or(OtpAuthError::MissingSecret)?;
183 let decoded = base32_decode(&secret_b32);
184 {
188 use zeroize::Zeroize;
189 secret_b32.zeroize();
190 }
191 let mut secret = decoded.map_err(|_| OtpAuthError::InvalidSecret)?;
192 if secret.is_empty() || secret.len() > 63 {
197 use zeroize::Zeroize;
198 secret.zeroize();
199 return Err(OtpAuthError::InvalidSecret);
200 }
201
202 let (label_issuer, account) = match label.split_once(':') {
204 Some((i, a)) => (Some(i.trim().to_owned()), Some(a.trim().to_owned())),
205 None if label.is_empty() => (None, None),
206 None => (None, Some(label.trim().to_owned())),
207 };
208
209 let issuer = issuer_param.or(label_issuer).filter(|s| !s.is_empty());
211 let account = account.filter(|s| !s.is_empty());
212
213 Ok(OtpAuth {
214 issuer,
215 account,
216 secret,
217 algorithm,
218 digits,
219 time_step,
220 })
221}
222
223fn percent_decode(s: &str) -> Result<String, ()> {
224 let bytes = s.as_bytes();
225 let mut out = Vec::with_capacity(bytes.len());
226 let mut i = 0;
227 while i < bytes.len() {
228 match bytes[i] {
229 b'+' => {
230 out.push(b' ');
231 i += 1;
232 }
233 b'%' => {
234 if i + 2 >= bytes.len() {
235 return Err(());
236 }
237 let hi = hex_nibble(bytes[i + 1])?;
238 let lo = hex_nibble(bytes[i + 2])?;
239 out.push((hi << 4) | lo);
240 i += 3;
241 }
242 c => {
243 out.push(c);
244 i += 1;
245 }
246 }
247 }
248 String::from_utf8(out).map_err(|_| ())
249}
250
251fn hex_nibble(c: u8) -> Result<u8, ()> {
252 match c {
253 b'0'..=b'9' => Ok(c - b'0'),
254 b'a'..=b'f' => Ok(c - b'a' + 10),
255 b'A'..=b'F' => Ok(c - b'A' + 10),
256 _ => Err(()),
257 }
258}
259
260fn truncate_bytes(s: &str, max: usize) -> &str {
262 if s.len() <= max {
263 return s;
264 }
265 let mut end = max;
266 while end > 0 && !s.is_char_boundary(end) {
267 end -= 1;
268 }
269 &s[..end]
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn rejects_non_otpauth() {
278 assert!(matches!(
279 parse("https://example.com"),
280 Err(OtpAuthError::NotOtpAuth)
281 ));
282 }
283
284 #[test]
285 fn rejects_hotp() {
286 let r = parse("otpauth://hotp/x?secret=JBSWY3DP&counter=0");
287 assert!(matches!(r, Err(OtpAuthError::UnsupportedType(_))));
288 }
289
290 #[test]
291 fn minimal_uri() {
292 let p = parse("otpauth://totp/Acme?secret=JBSWY3DPEHPK3PXP").unwrap();
293 assert_eq!(p.issuer, None);
294 assert_eq!(p.account.as_deref(), Some("Acme"));
295 assert_eq!(p.secret, b"Hello!\xde\xad\xbe\xef");
296 assert_eq!(p.algorithm, HmacAlgo::Sha1);
297 assert_eq!(p.digits, OtpDigits::Six);
298 assert_eq!(p.time_step, TimeStep::Seconds30);
299 }
300
301 #[test]
302 fn full_uri_with_issuer_query_wins() {
303 let p = parse(
304 "otpauth://totp/OldName:alice%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA256&digits=8&period=60"
305 ).unwrap();
306 assert_eq!(p.issuer.as_deref(), Some("GitHub"));
307 assert_eq!(p.account.as_deref(), Some("alice@example.com"));
308 assert_eq!(p.algorithm, HmacAlgo::Sha256);
309 assert_eq!(p.digits, OtpDigits::Eight);
310 assert_eq!(p.time_step, TimeStep::Seconds60);
311 }
312
313 #[test]
314 fn issuer_from_label_when_query_missing() {
315 let p = parse("otpauth://totp/Google:bob@example.com?secret=JBSWY3DP").unwrap();
316 assert_eq!(p.issuer.as_deref(), Some("Google"));
317 assert_eq!(p.account.as_deref(), Some("bob@example.com"));
318 }
319
320 #[test]
321 fn rejects_unsupported_digits() {
322 let r = parse("otpauth://totp/x?secret=JBSWY3DP&digits=7");
323 assert!(matches!(r, Err(OtpAuthError::UnsupportedDigits(7))));
324 }
325
326 #[test]
327 fn rejects_unsupported_algo() {
328 let r = parse("otpauth://totp/x?secret=JBSWY3DP&algorithm=SHA512");
329 assert!(matches!(r, Err(OtpAuthError::UnsupportedAlgorithm(_))));
330 }
331
332 #[test]
333 fn rejects_unsupported_period() {
334 let r = parse("otpauth://totp/x?secret=JBSWY3DP&period=45");
335 assert!(matches!(r, Err(OtpAuthError::UnsupportedPeriod(45))));
336 }
337
338 #[test]
339 fn missing_secret() {
340 let r = parse("otpauth://totp/x");
341 assert!(matches!(r, Err(OtpAuthError::MissingSecret)));
342 }
343
344 #[test]
345 fn invalid_base32_secret() {
346 let r = parse("otpauth://totp/x?secret=NOT_BASE32!!");
347 assert!(matches!(r, Err(OtpAuthError::InvalidSecret)));
348 }
349
350 #[test]
351 fn oversized_secret_rejected() {
352 let b32 = "A".repeat(103); let r = parse(&format!("otpauth://totp/x?secret={}", b32));
356 assert!(matches!(r, Err(OtpAuthError::InvalidSecret)));
357 let b32_ok = "A".repeat(101); let p = parse(&format!("otpauth://totp/x?secret={}", b32_ok)).unwrap();
360 assert_eq!(p.secret.len(), 63);
361 }
362
363 #[test]
364 fn suggested_title_prefers_issuer_and_truncates() {
365 let p = parse("otpauth://totp/x?secret=JBSWY3DP&issuer=ABCDEFGHIJKLMNOP").unwrap();
366 assert_eq!(p.suggested_title(), "ABCDEFGHIJKL"); }
368
369 #[test]
370 fn percent_decoded_label_with_plus() {
371 let p =
372 parse("otpauth://totp/Co%20Inc:alice%2Bwork%40example.com?secret=JBSWY3DP").unwrap();
373 assert_eq!(p.issuer.as_deref(), Some("Co Inc"));
374 assert_eq!(p.account.as_deref(), Some("alice+work@example.com"));
375 }
376}