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 --- Cargo.lock | 51 +++++++++++++++++++++++ Cargo.toml | 2 + src/main.rs | 132 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 179 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 277ad6e..008d2ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,11 +22,26 @@ name = "fuchsia-zircon-sys" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "idna" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "matches 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)", + "unicode-bidi 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", + "unicode-normalization 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "libc" version = "0.2.46" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "matches" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "mktemp" version = "0.3.1" @@ -41,8 +56,15 @@ version = "0.1.0" dependencies = [ "argparse 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", "mktemp 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", + "percent-encoding 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "url 1.7.2 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "percent-encoding" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "rand" version = "0.3.22" @@ -68,6 +90,29 @@ name = "rustc-serialize" version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "unicode-bidi" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "matches 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "unicode-normalization" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "url" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "idna 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)", + "matches 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)", + "percent-encoding 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "uuid" version = "0.1.18" @@ -101,11 +146,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum bitflags 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "228047a76f468627ca71776ecdebd732a3423081fcf5125585bcd7c49886ce12" "checksum fuchsia-zircon 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82" "checksum fuchsia-zircon-sys 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" +"checksum idna 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "38f09e0f0b1fb55fdee1f17470ad800da77af5186a1a76c026b679358b7e844e" "checksum libc 0.2.46 (registry+https://github.com/rust-lang/crates.io-index)" = "023a4cd09b2ff695f9734c1934145a315594b7986398496841c7031a5a1bbdbd" +"checksum matches 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)" = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" "checksum mktemp 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "77001ceb9eed65439f3dc2a2543f9ba1417d912686bf224a7738d0966e6dcd69" +"checksum percent-encoding 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "31010dd2e1ac33d5b46a5b413495239882813e0369f8ed8a5e266f173602f831" "checksum rand 0.3.22 (registry+https://github.com/rust-lang/crates.io-index)" = "15a732abf9d20f0ad8eeb6f909bf6868722d9a06e1e50802b6a70351f40b4eb1" "checksum rand 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)" = "8356f47b32624fef5b3301c1be97e5944ecdd595409cc5da11d05f211db6cfbd" "checksum rustc-serialize 0.3.24 (registry+https://github.com/rust-lang/crates.io-index)" = "dcf128d1287d2ea9d80910b5f1120d0b8eede3fbf1abe91c40d39ea7d51e6fda" +"checksum unicode-bidi 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)" = "49f2bd0c6468a8230e1db229cff8029217cf623c767ea5d60bfbd42729ea54d5" +"checksum unicode-normalization 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "6a0180bc61fc5a987082bfa111f4cc95c4caff7f9799f3e46df09163a937aa25" +"checksum url 1.7.2 (registry+https://github.com/rust-lang/crates.io-index)" = "dd4e7c0d531266369519a4aa4f399d748bd37043b00bde1e4ff1f60a120b355a" "checksum uuid 0.1.18 (registry+https://github.com/rust-lang/crates.io-index)" = "78c590b5bd79ed10aad8fb75f078a59d8db445af6c743e55c4a53227fc01c13f" "checksum winapi 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)" = "92c1eb33641e276cfa214a0522acad57be5c56b10cb348b3c5117db75f3ac4b0" "checksum winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" diff --git a/Cargo.toml b/Cargo.toml index 8315b8f..86135cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,5 @@ license = "MIT" [dependencies] argparse = "0.2" mktemp = "0.3" +percent-encoding = "1.0" +url = "1.7" 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.3