Skip to main content

keyroost_import/
otpauth.rs

1//! Minimal `otpauth://` URI parser.
2//!
3//! Format (RFC-style spec at https://github.com/google/google-authenticator/wiki/Key-Uri-Format):
4//!
5//!   otpauth://TYPE/LABEL?secret=BASE32&issuer=ISSUER&algorithm=ALGO&digits=N&period=N
6//!
7//! TYPE is `totp` or `hotp` — this parser accepts only `totp` (Molto2 is TOTP).
8//! LABEL is typically `Issuer:account@example.com`; we use it for the title.
9
10use 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/// A parsed otpauth:// URI, normalized to the subset Molto2 can store.
53#[derive(Clone)]
54pub struct OtpAuth {
55    /// Issuer name from the `issuer=` query param, or extracted from the label prefix.
56    pub issuer: Option<String>,
57    /// Account name from the label (after the optional `Issuer:` prefix).
58    pub account: Option<String>,
59    /// Raw secret bytes (base32-decoded).
60    pub secret: Vec<u8>,
61    pub algorithm: HmacAlgo,
62    pub digits: OtpDigits,
63    pub time_step: TimeStep,
64}
65
66/// Manual Debug so a stray `{:?}` in logs or error context can't print the
67/// seed; only its length is shown.
68impl 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
81/// The decoded seed is wiped when the parse result is dropped. (Buffer
82/// reallocations and the URI string the caller holds are out of reach —
83/// callers own those.)
84impl Drop for OtpAuth {
85    fn drop(&mut self) {
86        use zeroize::Zeroize;
87        self.secret.zeroize();
88    }
89}
90
91impl OtpAuth {
92    /// Best-effort 12-byte title: prefer issuer, fall back to account, truncate hard.
93    /// Caller can also override before sending to the device.
94    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    /// Build a Molto2 ProfileConfig from this URI, given a UTC timestamp and display timeout.
104    /// Display timeout isn't carried in otpauth:// — caller picks (default 30s here).
105    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
120/// Parse an otpauth:// URI into a normalized form. Returns specific errors for
121/// each kind of mismatch with what Molto2 can program.
122pub 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    // TYPE/LABEL?QUERY
127    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    // Defaults per the spec.
141    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            _ => {} // ignore unknown params (counter, image, ...)
173        }
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    // The percent-decoded base32 text is the seed in another spelling — wipe
185    // it as soon as the binary copy exists (the binary copy rides in `OtpAuth`,
186    // which wipes on drop).
187    {
188        use zeroize::Zeroize;
189        secret_b32.zeroize();
190    }
191    let mut secret = decoded.map_err(|_| OtpAuthError::InvalidSecret)?;
192    // The Molto2 caps seeds at 63 bytes, and the protocol layer asserts the
193    // same range; reject here so a malformed URI in an imported file fails
194    // with an error instead of panicking mid-import (after some slots were
195    // already written). Real TOTP secrets are 10–64 base32 chars (6–40 bytes).
196    if secret.is_empty() || secret.len() > 63 {
197        use zeroize::Zeroize;
198        secret.zeroize();
199        return Err(OtpAuthError::InvalidSecret);
200    }
201
202    // Label may be "Issuer:account" — split on the first colon if present.
203    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    // Prefer the explicit issuer= query param over the label prefix.
210    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
260/// Truncate a `&str` to at most `max` bytes, on a UTF-8 char boundary.
261fn 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        // 64 decoded bytes — one past the Molto2's 63-byte cap. Must error
353        // here rather than trip the protocol layer's assert mid-import.
354        let b32 = "A".repeat(103); // ceil(64*8/5) chars -> 64 bytes
355        let r = parse(&format!("otpauth://totp/x?secret={}", b32));
356        assert!(matches!(r, Err(OtpAuthError::InvalidSecret)));
357        // 63 bytes stays accepted.
358        let b32_ok = "A".repeat(101); // floor(63*8/5) chars -> 63 bytes
359        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"); // 12 bytes
367    }
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}