From 516ee5886f7ddcd60670aa5bdb2edc98cbec257d Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Mon, 7 Jan 2019 22:05:03 +0000 Subject: Implement otpauth URI parsing --- src/main.rs | 132 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 126 insertions(+), 6 deletions(-) (limited to 'src/main.rs') diff --git a/src/main.rs b/src/main.rs index 9c2a8c1..9fa1a72 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,18 +2,20 @@ //! Reads OTP configuration from a QR code and writes it to an OTP slot on a Nitrokey device. +use std::borrow; use std::fmt; use std::fs; use std::io; use std::path; use std::process; -use std::string; +use std::str; #[derive(Debug)] enum Error { IoError(io::Error), Error(String), - Utf8Error(string::FromUtf8Error), + UrlParseError(url::ParseError), + Utf8Error(str::Utf8Error), } impl fmt::Display for Error { @@ -21,6 +23,7 @@ impl fmt::Display for Error { match *self { Error::IoError(ref err) => write!(f, "IO error: {}", err), Error::Error(ref string) => write!(f, "Error: {}", string), + Error::UrlParseError(ref err) => write!(f, "URL parse error: {}", err), Error::Utf8Error(ref err) => write!(f, "UTF-8 error: {}", err), } } @@ -49,12 +52,29 @@ impl From<(&str, process::ExitStatus)> for Error { } } -impl From for Error { - fn from(error: string::FromUtf8Error) -> Error { +impl From for Error { + fn from(error: url::ParseError) -> Error { + Error::UrlParseError(error) + } +} + +impl From for Error { + fn from(error: str::Utf8Error) -> Error { Error::Utf8Error(error) } } +#[derive(Debug, PartialEq)] +struct UrlData { + otp_type: String, + label: String, + secret: String, + issuer: Option, + digits: Option, + counter: Option, + period: Option, +} + #[derive(Debug)] struct Options { slot: u8, @@ -113,7 +133,7 @@ fn decode_qr_code(path: &path::Path) -> Result { .arg(path) .output()?; if output.status.success() { - let output = String::from_utf8(output.stdout)?; + let output = String::from_utf8(output.stdout).map_err(|err| err.utf8_error())?; let urls = output .split("\n") .filter(|url| url.starts_with("otpauth://")) @@ -131,12 +151,74 @@ fn decode_qr_code(path: &path::Path) -> Result { } } +fn strip_issuer_prefix(label: String, issuer: &str) -> String { + let prefix = format!("{}:", issuer); + if label.starts_with(&prefix) { + let label = label.trim_left_matches(&prefix); + let label = label.trim_left_matches(" "); + label.to_string() + } else { + label + } +} + +fn parse_url(url: &str) -> Result { + let url = url::Url::parse(url)?; + let scheme = url.scheme(); + if scheme != "otpauth" { + return Err(Error::Error(format!("Unexpected URL scheme: {}", scheme))); + } + let otp_type = match url.host_str() { + Some(host) => host.to_string(), + None => return Err(Error::from("otpauth URL does not contain type")), + }; + let label = url.path(); + let label = percent_encoding::percent_decode(label.as_bytes()).decode_utf8()?; + let label = label.trim_start_matches("/").to_string(); + let mut secret: Option = None; + let mut issuer: Option = None; + let mut digits: Option = None; + let mut counter: Option = None; + let mut period: Option = None; + for (key, value) in url.query_pairs() { + let field = match key { + borrow::Cow::Borrowed("secret") => Some(&mut secret), + borrow::Cow::Borrowed("issuer") => Some(&mut issuer), + borrow::Cow::Borrowed("digits") => Some(&mut digits), + borrow::Cow::Borrowed("counter") => Some(&mut counter), + borrow::Cow::Borrowed("period") => Some(&mut period), + _ => None, + }; + if let Some(field) = field { + *field = Some(value.into_owned()); + } + } + let label = match issuer { + Some(ref issuer) => strip_issuer_prefix(label, issuer), + None => label, + }; + match secret { + Some(secret) => Ok(UrlData { + otp_type, + label, + secret, + issuer, + digits, + counter, + period, + }), + None => Err(Error::from("otpauth URL did not contain a secret")), + } +} + fn run(options: Options) -> Result<(), Error> { let path = match options.file { Some(ref file) => path::PathBuf::from(file), None => import_qr_code()?, }; - println!("{}", decode_qr_code(&path)?); + let url = decode_qr_code(&path)?; + let url_data = parse_url(&url)?; + println!("{:?}", url_data); if options.file.is_none() { fs::remove_file(&path)?; } @@ -156,3 +238,41 @@ fn main() { }; process::exit(status); } + +#[cfg(test)] +mod tests { + #[test] + fn parse_url() -> Result<(), super::Error> { + let result = super::parse_url( + "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", + )?; + let expected = super::UrlData { + otp_type: "totp".to_string(), + label: "alice@google.com".to_string(), + secret: "JBSWY3DPEHPK3PXP".to_string(), + issuer: Some("Example".to_string()), + digits: None, + counter: None, + period: None, + }; + assert_eq!(result, expected); + + let result = super::parse_url("otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")?; + let expected = super::UrlData { + otp_type: "totp".to_string(), + label: "john.doe@email.com".to_string(), + secret: "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ".to_string(), + issuer: Some("ACME Co".to_string()), + digits: Some("6".to_string()), + counter: None, + period: Some("30".to_string()), + }; + assert_eq!(result, expected); + + assert!(super::parse_url("otpauth://totp/test?secret=blubb").is_ok()); + assert!(super::parse_url("otauth://totp/test?secret=blubb").is_err()); + assert!(super::parse_url("otpauth://totp/test").is_err()); + + Ok(()) + } +} -- cgit v1.2.1