From ed6b882fa07dfd3f1ac9d001561ec4da7ee07072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Wed, 27 Feb 2019 13:10:40 +0100 Subject: [PATCH] code import --- .gitignore | 5 + Cargo.toml | 11 + src/digest.rs | 1018 +++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 99 +++++ src/utils.rs | 18 + 5 files changed, 1151 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/digest.rs create mode 100644 src/lib.rs create mode 100644 src/utils.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..04dbbd3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +**/*.rs.bk +Cargo.lock +.idea + diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2d1aff2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "digest_auth" +version = "0.1.0" +authors = ["Ondřej Hruška "] +edition = "2018" + +[dependencies] +rust-crypto = "0.2" +rand = "0.6" +hex = "0.3.2" +failure = "0.1.5" diff --git a/src/digest.rs b/src/digest.rs new file mode 100644 index 0000000..f0bde20 --- /dev/null +++ b/src/digest.rs @@ -0,0 +1,1018 @@ +use crate::utils::QuoteForDigest; +use std::collections::HashMap; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; +use failure::{Error,Fallible}; + +use crypto::{ + digest::Digest, + md5::Md5, + sha2::Sha256, + sha2::Sha512Trunc256 +}; + +use rand::Rng; + +//region Algorithm + +/// Algorithm type +#[derive(Debug, PartialEq)] +#[allow(non_camel_case_types)] +pub enum AlgorithmType { + MD5, + SHA2_256, + SHA2_512_256, +} + +/// Algorithm and the -sess flag pair +#[derive(Debug, PartialEq)] +pub struct Algorithm { + algo: AlgorithmType, + sess: bool, +} + +impl Algorithm { + /// Compose from algorithm type and the -sess flag + pub fn new(algo: AlgorithmType, sess: bool) -> Algorithm { + Algorithm { algo, sess } + } + + /// Calculate a hash of bytes using the selected algorithm + pub fn hash(&self, bytes: &[u8]) -> String { + let mut hash: Box = match self.algo { + AlgorithmType::MD5 => Box::new(Md5::new()), + AlgorithmType::SHA2_256 => Box::new(Sha256::new()), + AlgorithmType::SHA2_512_256 => Box::new(Sha512Trunc256::new()), + }; + + hash.input(bytes); + hash.result_str() + } + + /// Calculate a hash of string's bytes using the selected algorithm + pub fn hash_str(&self, bytes: &str) -> String { + self.hash(bytes.as_bytes()) + } +} + +impl FromStr for Algorithm { + type Err = Error; + + /// Parse from the format used in WWW-Authorization + fn from_str(s: &str) -> Fallible { + match s { + "MD5" => Ok(Algorithm::new(AlgorithmType::MD5, false)), + "MD5-sess" => Ok(Algorithm::new(AlgorithmType::MD5, true)), + "SHA-256" => Ok(Algorithm::new(AlgorithmType::SHA2_256, false)), + "SHA-256-sess" => Ok(Algorithm::new(AlgorithmType::SHA2_256, true)), + "SHA-512-256" => Ok(Algorithm::new(AlgorithmType::SHA2_512_256, false)), + "SHA-512-256-sess" => Ok(Algorithm::new(AlgorithmType::SHA2_512_256, true)), + _ => Err(format_err!("Unknown algorithm: {}", s)), + } + } +} + +impl Default for Algorithm { + /// Get a MD5 instance + fn default() -> Self { + Algorithm::new(AlgorithmType::MD5, false) + } +} + +impl Display for Algorithm { + /// Format to the form used in HTTP headers + fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> { + f.write_str(match self.algo { + AlgorithmType::MD5 => "MD5", + AlgorithmType::SHA2_256 => "SHA-256", + AlgorithmType::SHA2_512_256 => "SHA-512-256", + })?; + + if self.sess { + f.write_str("-sess")?; + } + + Ok(()) + } +} + +//endregion + +//region Qop + +/// QOP field values +#[derive(Debug, PartialEq)] +#[allow(non_camel_case_types)] +pub enum Qop { + /// QOP field not set by server + AUTH, + AUTH_INT, +} + +impl FromStr for Qop { + type Err = Error; + + /// Parse from "auth" or "auth-int" as used in HTTP headers + fn from_str(s: &str) -> Fallible { + match s { + "auth" => Ok(Qop::AUTH), + "auth-int" => Ok(Qop::AUTH_INT), + _ => Err(format_err!("Unknown QOP value: {}", s)), + } + } +} + +impl Display for Qop { + /// Convert to "auth" or "auth-int" as used in HTTP headers + fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> { + f.write_str(match self { + Qop::AUTH => "auth", + Qop::AUTH_INT => "auth-int", + })?; + + Ok(()) + } +} + +#[derive(Debug)] +#[allow(non_camel_case_types)] +enum QopAlgo<'a> { + NONE, + AUTH, + AUTH_INT(&'a [u8]), +} + +// casting back... +impl<'a> Into> for QopAlgo<'a> { + /// Convert to ?Qop + fn into(self) -> Option { + match self { + QopAlgo::NONE => None, + QopAlgo::AUTH => Some(Qop::AUTH), + QopAlgo::AUTH_INT(_) => Some(Qop::AUTH_INT), + } + } +} + +//endregion + +//region Charset + +/// Charset field value as specified by the server +#[derive(Debug, PartialEq)] +pub enum Charset { + ASCII, + UTF8, +} + +impl FromStr for Charset { + type Err = Error; + + /// Parse from string (only UTF-8 supported, as prescribed by the specification) + fn from_str(s: &str) -> Fallible { + match s { + "UTF-8" => Ok(Charset::UTF8), + _ => Err(format_err!("Unknown charset value: {}", s)), + } + } +} + +//endregion + +//region HttpMethod + +/// HTTP method (used when generating the response hash for some Qop options) +#[derive(Debug)] +pub enum HttpMethod { + GET, + POST, + HEAD, + OTHER(&'static str), +} + +impl Default for HttpMethod { + fn default() -> Self { + HttpMethod::GET + } +} + +impl Display for HttpMethod { + /// Convert to uppercase string + fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> { + f.write_str(match self { + HttpMethod::GET => "GET", + HttpMethod::POST => "POST", + HttpMethod::HEAD => "HEAD", + HttpMethod::OTHER(s) => s, + })?; + + Ok(()) + } +} + +//endregion + +/// Login attempt context +/// +/// All fields are borrowed to reduce runtime overhead; this struct should not be stored anywhere, +/// it is normally meaningful only for the one request. +#[derive(Debug)] +pub struct AuthContext<'a> { + /// Login username + username: &'a str, + /// Login password (plain) + password: &'a str, + /// Requested URI (not a domain! should start with a slash) + uri: &'a str, + /// Request payload body - used for auth-int (auth with integrity check) + /// May be left out if not using auth-int + body: Option<&'a [u8]>, + /// HTTP method used (defaults to GET) + method: HttpMethod, + /// Spoofed client nonce (use only for tests; a random nonce is generated automatically) + cnonce: Option<&'a str>, +} + +impl<'a> AuthContext<'a> { + /// Construct a new context with the GET verb and no payload body. + /// See the other constructors if this does not fit your situation. + pub fn new<'n:'a, 'p:'a, 's:'a, 'u:'a>(username : &'n str, password : &'p str, uri : &'u str) -> Self { + Self::new_with_method(username, password, uri, None, HttpMethod::GET) + } + + /// Construct a new context with the POST verb and a payload body (may be None). + /// See the other constructors if this does not fit your situation. + pub fn new_post<'n:'a, 'p:'a, 's:'a, 'u:'a, 'b:'a>( + username : &'n str, + password : &'p str, + uri : &'u str, + body : Option<&'b [u8]> + ) -> Self { + Self::new_with_method(username, password, uri, body, HttpMethod::GET) + } + + /// Construct a new context with arbitrary verb and, optionally, a payload body + pub fn new_with_method<'n:'a, 'p:'a, 's:'a, 'u:'a, 'b:'a>( + username : &'n str, + password : &'p str, + uri : &'u str, + body : Option<&'b [u8]>, + method : HttpMethod + ) -> Self { + Self { + username, + password, + uri, + body, + method, + cnonce: None + } + } + + pub fn set_custom_cnonce<'x:'a>(&mut self, cnonce : &'x str) { + self.cnonce = Some(cnonce); + } +} + +//endregion + +//region WwwAuthenticateHeader + +/// WWW-Authenticate header parsed from HTTP header value +/// +/// Use .from_str() to create it from data in a WWW-Authenticate HTTP header +#[derive(Debug, PartialEq)] +pub struct WwwAuthenticateHeader { + /// Domain is a list of URIs that will accept the same digest. None if not given (i.e applies to all) + pub domain: Option>, + /// Authorization realm (i.e. hostname, serial number...) + pub realm: String, + /// Server nonce + pub nonce: String, + /// Server opaque string + pub opaque: Option, + /// True if the server nonce expired. + /// This is sent in response to an auth attempt with an older digest. + /// The response should contain a new WWW-Authenticate header. + pub stale: bool, + /// Hashing algo + pub algorithm: Algorithm, + /// Digest algorithm variant + pub qop: Option>, + /// Flag that the server supports user-hashes + pub userhash: bool, + /// Server-supported charset + pub charset: Charset, + /// NC - not part of the received header, but kept here for convenience and incremented each time + /// a response is composed with the same nonce. + pub nc: u32, +} + +impl WwwAuthenticateHeader { + pub fn respond<'re, 'a:'re, 'c:'re>(&'a mut self, secrets : &'c AuthContext) -> Fallible> { + AuthorizationHeader::new(self, secrets) + } +} + +/// Helper func that parses the key-value string received from server +/// +/// # Panics +/// if the input is malformed +pub fn parse_header_map(input: &str) -> HashMap { + #[derive(Debug)] + #[allow(non_camel_case_types)] + enum ParserState { + P_WHITE, + P_NAME(usize), + P_VALUE_BEGIN, + P_VALUE_QUOTED, + P_VALUE_QUOTED_NEXTLITERAL, + P_VALUE_PLAIN, + } + + let mut state = ParserState::P_WHITE; + + let mut parsed = HashMap::::new(); + let mut current_token = None; + let mut current_value = String::new(); + + for (char_n, c) in input.chars().enumerate() { + match state { + ParserState::P_WHITE => { + if c.is_alphabetic() { + state = ParserState::P_NAME(char_n); + } + } + ParserState::P_NAME(name_start) => { + if c == '=' { + current_token = Some(&input[name_start..char_n]); + state = ParserState::P_VALUE_BEGIN; + } + } + ParserState::P_VALUE_BEGIN => { + current_value.clear(); + state = match c { + '"' => ParserState::P_VALUE_QUOTED, + _ => { + current_value.push(c); + ParserState::P_VALUE_PLAIN + } + }; + } + ParserState::P_VALUE_QUOTED => { + match c { + '"' => { + parsed.insert(current_token.unwrap().to_string(), current_value.clone()); + + current_token = None; + current_value.clear(); + + state = ParserState::P_WHITE; + } + '\\' => { + state = ParserState::P_VALUE_QUOTED_NEXTLITERAL; + } + _ => { + current_value.push(c); + } + }; + } + ParserState::P_VALUE_PLAIN => { + if c == ',' || c.is_ascii_whitespace() { + parsed.insert(current_token.unwrap().to_string(), current_value.clone()); + + current_token = None; + current_value.clear(); + + state = ParserState::P_WHITE; + } else { + current_value.push(c); + } + } + ParserState::P_VALUE_QUOTED_NEXTLITERAL => { + current_value.push(c); + state = ParserState::P_VALUE_QUOTED + } + } + } + + match state { + ParserState::P_VALUE_PLAIN => { + parsed.insert(current_token.unwrap().to_string(), current_value); // consume the value here + } + ParserState::P_WHITE => {} + _ => panic!("Unexpected end state {:?}", state), + } + + parsed +} + +/// Parse the WWW-Authenticate header value into a struct +impl FromStr for WwwAuthenticateHeader { + type Err = Error; + + /// Parse HTTP header + fn from_str(input: &str) -> Fallible { + let mut input = input.trim(); + if input.starts_with("Digest") { + input = &input["Digest".len()..]; + } + + let mut kv = parse_header_map(input); + + //println!("Parsed map: {:#?}", kv); + + let algo = match kv.get("algorithm") { + Some(a) => Algorithm::from_str(&a)?, + _ => Algorithm::default(), + }; + + Ok(Self { + domain: if let Some(domains) = kv.get("domain") { + let domains: Vec<&str> = domains.split(" ").collect(); + Some(domains.iter().map(|x| x.trim().to_string()).collect()) + } else { + None + }, + realm: match kv.remove("realm") { + Some(v) => v, + None => bail!("realm not given"), + }, + nonce: match kv.remove("nonce") { + Some(v) => v, + None => bail!("nonce not given"), + }, + opaque: kv.remove("opaque"), + stale: match kv.get("stale") { + Some(v) => v.to_ascii_lowercase() == "true", + None => false, + }, + charset: match kv.get("charset") { + Some(v) => Charset::from_str(v)?, + None => Charset::ASCII, + }, + algorithm: algo, + qop: if let Some(domains) = kv.get("qop") { + let domains: Vec<&str> = domains.split(",").collect(); + let mut qops = vec![]; + for d in domains { + qops.push(Qop::from_str(d.trim())?); + } + Some(qops) + } else { + None + }, + userhash: match kv.get("userhash") { + Some(v) => v.to_ascii_lowercase() == "true", + None => false, + }, + nc : 0 + }) + } +} + +//endregion + +//region Authentication + +/// Header sent back to the server, including password hashes +/// Always create it using ::new(), the hash calculation is done in the constructor. +/// +/// This can also be obtained from the WwwAuthentication header with `WwwAuthenticateHeader::respond()` +/// +/// Use .to_string() to generate the Authenticate HTTP header value +#[derive(Debug)] +pub struct AuthorizationHeader<'ctx> { + /// The server header that triggered the authentication flow; used to retrieve some additional + /// fields when serializing to the header string + pub prompt: &'ctx WwwAuthenticateHeader, + /// Computed digest + pub response: String, + /// Username or hash (owned because of the computed hash) + pub username: String, + /// Requested URI + pub uri: &'ctx str, + /// QOP chosen from the list offered by server, if any + /// None in legacy compat mode (RFC 2069) + pub qop: Option, + /// Client nonce + /// None in legacy compat mode (RFC 2069) + pub cnonce: Option, + /// How many requests have been signed with this server nonce + /// Not used in legacy compat mode (RFC 2069) - it's still incremented though + pub nc: u32, +} + +impl<'a> AuthorizationHeader<'a> { + /// Create from a prompt header and auth context, selecting suitable algorithm options. + /// The header contains a 'nc' field that is incremented by this method. + /// + /// For subsequent requests, simply reuse the same parsed WwwAuthenticateHeader, and - if the + /// server supports nonce reuse - it will work automatically + /// + /// Returns Error if the source header is malformed so much that we can't figure out + /// a proper response. + pub fn new<'p:'a, 's:'a>( + prompt: &'p mut WwwAuthenticateHeader, context: &'s AuthContext + ) -> Fallible> { + // figure out which QOP option to use + let empty_vec = vec![]; + let qop_algo = match &prompt.qop { + None => QopAlgo::NONE, + Some(vec) => { + // this is at least RFC2617, qop was given + if vec.contains(&Qop::AUTH_INT) { + if let Some(b) = context.body { + QopAlgo::AUTH_INT(b) + } else { + // we have no body. Fall back to regular auth if possible, or use empty + if vec.contains(&Qop::AUTH) { + QopAlgo::AUTH + } else { + QopAlgo::AUTH_INT(&empty_vec) + } + } + } else if vec.contains(&Qop::AUTH) { + // "auth" is the second best after "auth-int" + QopAlgo::AUTH + } else { + // parser bug - prompt.qop should have been None + bail!("Bad QOP options - {:#?}", vec); + } + } + }; + + let h = &prompt.algorithm; + + let cnonce = { + match context.cnonce { + Some(cnonce) => cnonce.to_owned(), + None => { + let mut rng = rand::thread_rng(); + let nonce_bytes: [u8; 16] = rng.gen(); + hex::encode(nonce_bytes) + } + } + }; + + // a1 value for the hash algo. cnonce is generated if needed + let a1 = { + let a = format!( + "{name}:{realm}:{pw}", + name = context.username, + realm = prompt.realm, + pw = context.password + ); + + let sess = prompt.algorithm.sess; + if sess { + format!( + "{hash}:{nonce}:{cnonce}", + hash = h.hash(a.as_bytes()), + nonce = prompt.nonce, + cnonce = cnonce + ) + } else { + a + } + }; + + // a2 value for the hash algo + let a2 = match qop_algo { + QopAlgo::AUTH | QopAlgo::NONE => { + format!("{method}:{uri}", method = context.method, uri = context.uri) + } + QopAlgo::AUTH_INT(body) => format!( + "{method}:{uri}:{bodyhash}", + method = context.method, + uri = context.uri, + bodyhash = h.hash(body) + ), + }; + + // hashed or unhashed username - always hash if server wants it + let username = match prompt.userhash { + true => h.hash( + format!( + "{username}:{realm}", + username = context.username, + realm = prompt.realm + ) + .as_bytes(), + ), + false => context.username.to_owned(), + }; + + let qop : Option = qop_algo.into(); + + let ha1 = h.hash_str(&a1); + let ha2 = h.hash_str(&a2); + + // Increment nonce counter + prompt.nc = prompt.nc + 1; + + // Compute the response + let response = match &qop { + Some(q) => { + let tmp = format!( + "{ha1}:{nonce}:{nc:08x}:{cnonce}:{qop}:{ha2}", + ha1 = ha1, + nonce = prompt.nonce, + nc = prompt.nc, + cnonce = cnonce, + qop = q, + ha2 = ha2 + ); + h.hash(tmp.as_bytes()) + } + None => { + let tmp = format!( + "{ha1}:{nonce}:{ha2}", + ha1 = ha1, + nonce = prompt.nonce, + ha2 = ha2 + ); + h.hash(tmp.as_bytes()) + } + }; + + Ok(AuthorizationHeader { + prompt, + response, + username, + uri: context.uri, + qop, + cnonce: Some(cnonce), + nc: prompt.nc, + }) + } + + /// Produce a header string (also accessible through the Display trait) + pub fn to_header_string(&self) -> String { + // TODO move impl from Display here & clean it up + self.to_string() + } +} + +impl<'a> Display for AuthorizationHeader<'a> { + fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> { + f.write_str("Digest ")?; + + //TODO charset shenanigans with username* (UTF-8 charset) + f.write_fmt(format_args!( + "username=\"{}\"", + self.username.quote_for_digest() + ))?; + + f.write_fmt(format_args!( + ", realm=\"{}\"", + self.prompt.realm.quote_for_digest() + ))?; + + f.write_fmt(format_args!( + ", nonce=\"{}\"", + self.prompt.nonce.quote_for_digest() + ))?; + + f.write_fmt(format_args!(", uri=\"{}\"", self.uri))?; + + if self.prompt.qop.is_some() { + f.write_fmt(format_args!( + ", qop={qop}, nc={nc:08x}, cnonce=\"{cnonce}\"", + qop = self.qop.as_ref().unwrap(), + cnonce = self.cnonce.as_ref().unwrap().quote_for_digest(), + nc = self.nc + ))?; + } + + f.write_fmt(format_args!( + ", response=\"{}\"", + self.response.quote_for_digest() + ))?; + + if let Some(opaque) = &self.prompt.opaque { + f.write_fmt(format_args!(", opaque=\"{}\"", opaque.quote_for_digest()))?; + } + + // algorithm can be omitted if it is the default value (or in legacy compat mode) + if self.qop.is_some() || self.prompt.algorithm.algo != AlgorithmType::MD5 { + f.write_fmt(format_args!(", algorithm={}", self.prompt.algorithm))?; + } + + if self.prompt.userhash { + f.write_str(", userhash=true")?; + } + + Ok(()) + } +} + +//endregion + +//region TESTS + +#[cfg(test)] +mod tests { + use std::str::FromStr; + use super::WwwAuthenticateHeader; + use super::AuthorizationHeader; + use super::Algorithm; + use super::Charset; + use super::Qop; + use super::AlgorithmType; + use super::parse_header_map; + use crate::digest::AuthContext; + + #[test] + fn test_parse_header_map() { + { + let src = r#" + realm="api@example.org", + qop="auth", + algorithm=SHA-512-256, + nonce="5TsQWLVdgBdmrQ0XsxbDODV+57QdFR34I9HAbC/RVvkK", + opaque="HRPCssKJSGjCrkzDg8OhwpzCiGPChXYjwrI2QmXDnsOS", + charset=UTF-8, + userhash=true + "#; + + let map = parse_header_map(src); + + assert_eq!(map.get("realm").unwrap(), "api@example.org"); + assert_eq!(map.get("qop").unwrap(), "auth"); + assert_eq!(map.get("algorithm").unwrap(), "SHA-512-256"); + assert_eq!( + map.get("nonce").unwrap(), + "5TsQWLVdgBdmrQ0XsxbDODV+57QdFR34I9HAbC/RVvkK" + ); + assert_eq!( + map.get("opaque").unwrap(), + "HRPCssKJSGjCrkzDg8OhwpzCiGPChXYjwrI2QmXDnsOS" + ); + assert_eq!(map.get("charset").unwrap(), "UTF-8"); + assert_eq!(map.get("userhash").unwrap(), "true"); + } + + { + let src = r#"realm="api@example.org""#; + let map = parse_header_map(src); + assert_eq!(map.get("realm").unwrap(), "api@example.org"); + } + + { + let src = r#"realm=api@example.org"#; + let map = parse_header_map(src); + assert_eq!(map.get("realm").unwrap(), "api@example.org"); + } + + { + let src = ""; + let map = parse_header_map(src); + assert_eq!(map.is_empty(), true); + } + } + + #[test] + fn test_www_hdr_parse() { + { + // most things are parsed here... + let src = r#" + realm="api@example.org", + qop="auth", + domain="/my/nice/url /login /logout" + algorithm=SHA-512-256, + nonce="5TsQWLVdgBdmrQ0XsxbDODV+57QdFR34I9HAbC/RVvkK", + opaque="HRPCssKJSGjCrkzDg8OhwpzCiGPChXYjwrI2QmXDnsOS", + charset=UTF-8, + userhash=true + "#; + + let parsed = WwwAuthenticateHeader::from_str(src).unwrap(); + + assert_eq!( + parsed, + WwwAuthenticateHeader { + domain: Some(vec![ + "/my/nice/url".to_string(), + "/login".to_string(), + "/logout".to_string(), + ]), + realm: "api@example.org".to_string(), + nonce: "5TsQWLVdgBdmrQ0XsxbDODV+57QdFR34I9HAbC/RVvkK".to_string(), + opaque: Some("HRPCssKJSGjCrkzDg8OhwpzCiGPChXYjwrI2QmXDnsOS".to_string()), + stale: false, + algorithm: Algorithm::new(AlgorithmType::SHA2_512_256, false), + qop: Some(vec![Qop::AUTH]), + userhash: true, + charset: Charset::UTF8, + nc: 0 + } + ) + } + + { + // verify some defaults + let src = r#" + realm="a long realm with\\, weird \" characters", + qop="auth-int", + nonce="bla bla nonce aaaaa", + stale=TRUE + "#; + + let parsed = WwwAuthenticateHeader::from_str(src).unwrap(); + + assert_eq!( + parsed, + WwwAuthenticateHeader { + domain: None, + realm: "a long realm with\\, weird \" characters".to_string(), + nonce: "bla bla nonce aaaaa".to_string(), + opaque: None, + stale: true, + algorithm: Algorithm::default(), + qop: Some(vec![Qop::AUTH_INT]), + userhash: false, + charset: Charset::ASCII, + nc: 0 + } + ) + } + + { + // check that it correctly ignores leading Digest + let src = r#"Digest realm="aaa", nonce="bbb""#; + + let parsed = WwwAuthenticateHeader::from_str(src).unwrap(); + + assert_eq!( + parsed, + WwwAuthenticateHeader { + domain: None, + realm: "aaa".to_string(), + nonce: "bbb".to_string(), + opaque: None, + stale: false, + algorithm: Algorithm::default(), + qop: None, + userhash: false, + charset: Charset::ASCII, + nc: 0 + } + ) + } + } + + #[test] + fn test_rfc2069() { + let src = r#" + Digest + realm="testrealm@host.com", + nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", + opaque="5ccc069c403ebaf9f0171e9517f40e41" + "#; + + let context = AuthContext::new("Mufasa", "CircleOfLife", "/dir/index.html"); + + let mut prompt = WwwAuthenticateHeader::from_str(src).unwrap(); + let answer = AuthorizationHeader::new(&mut prompt, &context).unwrap(); + + // The spec has a wrong hash in the example, see errata + let str = answer.to_string().replace(", ", ",\n "); + assert_eq!( + str, + r#" +Digest username="Mufasa", + realm="testrealm@host.com", + nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", + uri="/dir/index.html", + response="1949323746fe6a43ef61f9606e7febea", + opaque="5ccc069c403ebaf9f0171e9517f40e41" +"# + .trim() + ); + } + + #[test] + fn test_rfc2617() { + let src = r#" + Digest + realm="testrealm@host.com", + qop="auth,auth-int", + nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", + opaque="5ccc069c403ebaf9f0171e9517f40e41" + "#; + + let mut context = AuthContext::new("Mufasa", "Circle Of Life", "/dir/index.html"); + context.set_custom_cnonce("0a4f113b"); + + let mut prompt = WwwAuthenticateHeader::from_str(src).unwrap(); + let answer = AuthorizationHeader::new(&mut prompt, &context).unwrap(); + + let str = answer.to_string().replace(", ", ",\n "); + //println!("{}", str); + + assert_eq!( + str, + r#" +Digest username="Mufasa", + realm="testrealm@host.com", + nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", + uri="/dir/index.html", + qop=auth, + nc=00000001, + cnonce="0a4f113b", + response="6629fae49393a05397450978507c4ef1", + opaque="5ccc069c403ebaf9f0171e9517f40e41", + algorithm=MD5 +"# + .trim() + ); + } + + #[test] + fn test_rfc7616_md5() { + let src = r#" + Digest + realm="http-auth@example.org", + qop="auth, auth-int", + algorithm=MD5, + nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", + opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS" + "#; + + let mut context = AuthContext::new("Mufasa", "Circle of Life", "/dir/index.html"); + context.set_custom_cnonce("f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ"); + + let mut prompt = WwwAuthenticateHeader::from_str(src).unwrap(); + let answer = AuthorizationHeader::new(&mut prompt, &context).unwrap(); + + let str = answer.to_string().replace(", ", ",\n "); + + assert_eq!( + str, + r#" +Digest username="Mufasa", + realm="http-auth@example.org", + nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", + uri="/dir/index.html", + qop=auth, + nc=00000001, + cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", + response="8ca523f5e9506fed4657c9700eebdbec", + opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS", + algorithm=MD5 +"# + .trim() + ); + } + + #[test] + fn test_rfc7616_sha256() { + let src = r#" + Digest + realm="http-auth@example.org", + qop="auth, auth-int", + algorithm=SHA-256, + nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", + opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS" + "#; + + let mut context = AuthContext::new("Mufasa", "Circle of Life", "/dir/index.html"); + context.set_custom_cnonce("f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ"); +// +// let secrets = AuthSecrets { +// username: "Mufasa".to_string(), +// password: "Circle of Life".to_string(), +// uri: "/dir/index.html".to_string(), +// body: None, +// method: HttpMethod::GET, +// nc: 1, +// cnonce: Some("f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ".to_string()), +// }; + + let mut prompt = WwwAuthenticateHeader::from_str(src).unwrap(); + let answer = AuthorizationHeader::new(&mut prompt, &context).unwrap(); + + let str = answer.to_string().replace(", ", ",\n "); + //println!("{}", str); + + assert_eq!( + str, + r#" +Digest username="Mufasa", + realm="http-auth@example.org", + nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", + uri="/dir/index.html", + qop=auth, + nc=00000001, + cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", + response="753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1", + opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS", + algorithm=SHA-256 +"# + .trim() + ); + } +} + +//endregion diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f78a5f4 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,99 @@ +//! This crate implements Digest Auth headers as specified by IETF RFCs 2069, 2617, and 7616. +//! It can be used in conjunction with libraries like reqwest to access e.g. IP cameras +//! that use this authentication scheme. +//! +//! This library is intended for the http client. The algorithm is symmetrical, +//! it's just not optimized for / tested on the server side yet. +//! +//! # Examples +//! +//! Basic usage: +//! +//! ``` +//! use digest_auth::AuthContext; +//! +//! // Value from the WWW-Authenticate HTTP header (usually in a HTTP 401 response) +//! let www_authenticate = r#"Digest realm="http-auth@example.org", qop="auth, auth-int", algorithm=MD5, nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS""#; +//! +//! // Prepare an authorization context. Note that this is a GET request. There are different +//! // constructors available for POST or other request types. You can re-use it, but +//! // it's cheap to create a fresh one each time, as the struct uses references only. +//! let mut context = AuthContext::new("Mufasa", "Circle of Life", "/dir/index.html"); +//! // For this test, we inject a custom cnonce. It's generated for you otherwise +//! // - you don't need `mut` in that case and needn't worry about this at all. +//! context.set_custom_cnonce("f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ"); +//! +//! // Parse the prompt header. You can inspect the parsed object, its fields are public. +//! let mut prompt = digest_auth::parse(www_authenticate).unwrap(); +//! +//! // Compute a value for the Authorization header that we'll send back to the server +//! let answer = prompt.respond(&context).unwrap().to_string(); +//! assert_eq!(answer, r#"Digest username="Mufasa", realm="http-auth@example.org", nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", uri="/dir/index.html", qop=auth, nc=00000001, cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", response="8ca523f5e9506fed4657c9700eebdbec", opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS", algorithm=MD5"#); +//! +//! // The `prompt` variable is mutable, because the 'nc' counter (nonce reuse count) +//! // is inside the struct and updated automatically. +//! +//! // You can re-use it for subsequent requests, assuming the server allows nonce re-use. +//! // Some poorly implemented servers will reject it and give you 401 again, in which case +//! // you should parse the new "WWW-Authenticate" header and use that instead. +//! +//! let answer2 = prompt.respond(&context).unwrap().to_string(); +//! // notice how the 'response' field changed - the 'nc' counter is included in the hash +//! assert_eq!(answer2, r#"Digest username="Mufasa", realm="http-auth@example.org", nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", uri="/dir/index.html", qop=auth, nc=00000002, cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", response="4b5d595ecf2db9df612ea5b45cd97101", opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS", algorithm=MD5"#); +//! ``` +//! + +#[macro_use] extern crate failure; +use failure::Fallible; + +mod digest; +mod utils; + +use std::str::FromStr; + +pub use crate::digest::{ + Algorithm, AuthContext, AuthorizationHeader, HttpMethod, Qop, WwwAuthenticateHeader, +}; + +/// Parse the WWW-Authorization header value. +/// It's just a convenience method to call `WwwAuthenticateHeader::from_str()`. +pub fn parse(www_authorize : &str) -> Fallible { + WwwAuthenticateHeader::from_str(www_authorize) +} + +#[test] +fn test_parse_respond() { + let src = r#" + Digest + realm="http-auth@example.org", + qop="auth, auth-int", + algorithm=MD5, + nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", + opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS" + "#; + + let mut context = AuthContext::new("Mufasa", "Circle of Life", "/dir/index.html"); + context.set_custom_cnonce("f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ"); + + let mut prompt = crate::parse(src).unwrap(); + let answer = prompt.respond(&context).unwrap(); + + let str = answer.to_string().replace(", ", ",\n "); + + assert_eq!( + str, + r#" +Digest username="Mufasa", + realm="http-auth@example.org", + nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", + uri="/dir/index.html", + qop=auth, + nc=00000001, + cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", + response="8ca523f5e9506fed4657c9700eebdbec", + opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS", + algorithm=MD5 +"# + .trim() + ); +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..28a8682 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,18 @@ +use std::string::ToString; + +/// slash quoting for digest strings +pub trait QuoteForDigest { + fn quote_for_digest(&self) -> String; +} + +impl QuoteForDigest for &str { + fn quote_for_digest(&self) -> String { + self.to_string().quote_for_digest() + } +} + +impl QuoteForDigest for String { + fn quote_for_digest(&self) -> String { + self.replace("\\", "\\\\").replace("\"", "\\\"") + } +}