diff --git a/Cargo.toml b/Cargo.toml index 3736a52..e175e2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,7 @@ base64 = "0.13.0" ansi_term = "0.12" lazy_static = "1.4.0" rand = "0.8.5" +md5 = "0.7.0" +clap = { version = "3.0.5", features = ["derive"] } +ring = "0.16.20" +hex = "0.4" diff --git a/src/lib.rs b/src/lib.rs index 36d20e9..af3e823 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fs::File; use std::io::{self, Read, Write, Seek, SeekFrom}; use std::net::TcpStream; @@ -56,7 +57,8 @@ impl HTTPRequest { /// Reads the 'Authorization' header of this HTTP request, decodes it (Base64) and returns /// `Some((username, password))` or `None` when no (or an invalid) 'Authorization' header was /// provided. - /// Only the 'Basic' authentication scheme is supported. + /// + /// This only works with the 'Basic' authentication scheme! pub fn get_authorization(&self) -> Option<(String, String)> { // https://de.wikipedia.org/wiki/HTTP-Authentifizierung // Example: "Authorization: Basic d2lraTpwZWRpYQ==" @@ -69,6 +71,150 @@ impl HTTPRequest { let mut uname_and_pw = base64_decoded.split(":"); return Some((uname_and_pw.next()?.to_string(), uname_and_pw.next()?.to_string())); } + + + /// Verify the Authorization provided by the client in the "Authorization" request header. + /// Returns `Ok(true)` when the client successfully authorized itself. + /// Returns `Ok(false)` when the client provided no or an incorrect Authorization. + /// Returns `Err` when either an incomplete or an incorrectly formatted (syntax) Authorization was provided. + /// + /// This only works with the 'Digest' authentication scheme! + /// + /// The `nonce_opaque_verifier` takes the server nonce as returned by the client as its 1st + /// argument and the server's opaque as returned by the client as its 2nd argument. + /// It shall verify that the nonce actually came from the server and that it is not too old, + /// i.e. expired. One may also check whether it was intended for the correct ip address. + /// A common way to do that is to choose the *opaque* as an HMAC of the server *nonce*. + /// + /// When `opaque` is set to `None` it is not verified whether the client responded with + /// the same 'opaque' value in its request header or even whether the client gave an 'opaque' + /// value in its request header at all. + /// When `opaque` is set to `Some` and the client responded either with no or with a different + /// 'opaque' value in its request header, this functions returns `Some(false)` even when the client + /// otherwise correctly identified itself! + /// + /// When `last_counter` ist set to `Some` it is ensured that the hexadecimal counter (nc) + /// of this request is strictly larger! This is to prevent replay attacks. + /// This also means that the usage of RFC 2617 instead of the old RFC 2069 is required. + /// When `last_counter` ist set to `None` no such check is performed and an attacker could + /// request the same site/URI with the same credentials again. + /// This should only be a security issue for non-static websites. + /// When `last_counter` ist set to `None`, the legacy RFC 2069 may be used. + /// + /// Integrity protection ("auth-int") is currently **not** supported/checked! + pub fn verify_digest_authorization(&self, username: &str, password: impl Display, realm: &str, nonce_opaque_verifier: F, last_counter: Option) -> Result + where F: Fn(&str, &str) -> bool + { + /* + Example of a client request with username "Mufasa" and password "Circle Of Life" + (from https://en.wikipedia.org/wiki/Digest_access_authentication#Example_with_explanation): + + GET /dir/index.html HTTP/1.0 + Host: localhost + Authorization: Digest username="Mufasa", + realm="testrealm@host.com", + nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", + uri="/dir/index.html", + qop=auth, + nc=00000001, + cnonce="0a4f113b", + response="6629fae49393a05397450978507c4ef1", + opaque="5ccc069c403ebaf9f0171e9517f40e41" + */ + + if !self.http_request.contains("Authorization: Digest ") { + return Ok(false); // the client provided no (digest) Authorization at all. + } + + // 1.) parse the key value pairs provided in the Authorization HTTP header into a HashMap: + let given_key_value_pairs: HashMap<&str, &str> = self.http_request + .split("Authorization: Digest ") + .nth(1).ok_or("client's request header does not contain substring 'Authorization: Digest '")? // should never occur/always succeed due to check above + .split(",") + .map(|key_value_pair| key_value_pair.trim()) + .map(|kv_pair| (kv_pair.split("=").nth(0).unwrap_or(""), kv_pair.split("=").nth(1).unwrap_or(""))) + .map(|(key, value)| (key, value.strip_prefix("\"").map(|v| v.strip_suffix("\"")).flatten().unwrap_or(value))) + .collect(); + + // 2.) put all the values of interest into separate variables: + let given_username: &str = given_key_value_pairs.get("username").ok_or("client specified no 'username' in Authorization header field")?; + let given_realm: &str = given_key_value_pairs.get("realm").ok_or("client specified no 'realm' in Authorization header field")?; + let given_nonce: &str = given_key_value_pairs.get("nonce").ok_or("client specified no 'nonce' in Authorization header field")?; + let given_uri: &str = given_key_value_pairs.get("uri").ok_or("client specified no 'uri' in Authorization header field")?; + let given_qop: Option<&&str> = given_key_value_pairs.get("qop"); // qop was only added with RFC 2617, therefore it's optional + let given_nc: Option<&&str> = given_key_value_pairs.get("nc"); // nonce counter was only added with RFC 2617, therefore it's optional + let given_cnonce: Option<&&str> = given_key_value_pairs.get("cnonce"); // client-generated random nonce was only added with RFC 2617, therefore it's optional + let given_response: &str = given_key_value_pairs.get("response").ok_or("client specified no 'response' in Authorization header field")?; + let given_opaque: &str = given_key_value_pairs.get("opaque").ok_or("client specified no 'opaque' in Authorization header field")?; + + // 3.) verify some of the given values: + if given_username != username || given_realm != realm { + return Ok(false); // reject authorizations for the incorrect username or realm + } + if !nonce_opaque_verifier(given_nonce, given_opaque) { + return Ok(false); // reject incorrect nonce's (correctness of the nonce is verified using the opaque value) + } + if given_uri != self.get_get_path() { + return Ok(false); + } + if last_counter != None && (given_nc == None || u128::from_str_radix(given_nc.unwrap(), 16).ok().ok_or("could not parse 'nc' to an int")? <= last_counter.unwrap()) { + return Ok(false); // request counter (nc) not strictly increasing (or not even provided)! replay attack detected! + } + + // 4.) compute the expected value/md5 hash for the "response" value: + let ha1 = md5::compute(format!("{}:{}:{}", username, realm, password)); + let ha2 = md5::compute(format!("GET:{}", self.get_get_path())); + let expected_response = + if given_qop.is_some() && given_nc.is_some() && given_cnonce.is_some() { // new RFC 2617: + md5::compute( + format!("{:x}:{}:{}:{}:{}:{:x}", ha1, given_nonce, given_nc.unwrap(), given_cnonce.unwrap(), given_qop.unwrap(), ha2) + ) + } else if given_qop.is_none() && given_nc.is_none() && given_cnonce.is_none() { // old RFC 2069: + // Note when last_counter.is_some() this piece of code is unreachable!! + md5::compute( + format!("{:x}:{}:{:x}", ha1, given_nonce, ha2) + ) + } else { + return Err(String::from("an invalid mix between the old RFC 2069 and the new RFC 2617: qop, nc, cnonce are only partially specified")); + }; + let expected_response_hex = format!("{:x}", expected_response); // to hexadecimal + + // 5.) compare the expected "response" value to the value actually given and return the result as a bool: + return Ok(given_response == expected_response_hex); + + /* + From https://en.wikipedia.org/wiki/Digest_access_authentication#Example_with_explanation: + + The "response" value is calculated in three steps, as follows. Where values are combined, they are delimited by colons. + + 1. The MD5 hash of the combined username, authentication realm and password is calculated. + The result is referred to as HA1. + 2. The MD5 hash of the combined method and digest URI is calculated, e.g. of "GET" and + "/dir/index.html". The result is referred to as HA2. + 3. The MD5 hash of the combined HA1 result, server nonce (nonce), request counter (nc), + client nonce (cnonce), quality of protection code (qop) and HA2 result is calculated. + The result is the "response" value provided by the client. + + Since the server has the same information as the client, the response can be checked by + performing the same calculation. In the example given above the result is formed as follows, + where MD5() represents a function used to calculate an MD5 hash, backslashes represent a + continuation and the quotes shown are not used in the calculation. + + Completing the example given in RFC 2617 gives the following results for each step. + + HA1 = MD5( "Mufasa:testrealm@host.com:Circle Of Life" ) + = 939e7578ed9e3c518a452acee763bce9 + + HA2 = MD5( "GET:/dir/index.html" ) + = 39aff3a2bab6126f332b942af96d3366 + + Response = MD5( "939e7578ed9e3c518a452acee763bce9:\ + dcd98b7102dd2f0e8b11d0f600bfb0c093:\ + 00000001:0a4f113b:auth:\ + 39aff3a2bab6126f332b942af96d3366" ) + = 6629fae49393a05397450978507c4ef1 + */ + } } impl From for HTTPRequest { @@ -105,6 +251,13 @@ impl HTTPResponse { return Self { http_response }; } + /// Create a new '400 Bad Request' HTTP response. + pub fn new_400_bad_request(content: &mut Vec) -> Self { + let mut http_response: Vec = format!("HTTP/1.1 400 Bad Request\r\nContent-Length: {}\r\n\r\n", content.len()).as_bytes().into(); + http_response.append(content); + Self { http_response } + } + /// Create a new '401 Unauthorized' HTTP response. /// The "Basic" authentication scheme is requested. pub fn new_401_unauthorized(realm_name: impl Display) -> Self { @@ -112,6 +265,41 @@ impl HTTPResponse { Self { http_response } } + /// Create a new '401 Unauthorized' HTTP response. + /// The "Digest" authentication scheme is requested. + /// The `nonce` is the challenge to the client to authenticate itself. + /// `opaque` is a server-specified string that shall be returned unchanged in the Authorization + /// header by the client. + /// + /// The `qop_auth` and `qop_auth_int` parameters control the quality of protection (qop). + /// "auth-int" stands for *Authentication with integrity protection*. + /// When both are set to false, the qop directive is unspecified and the legacy RFC 2069 + /// will be used. Otherwise, the newer RFC 2617 will be used. + /// RFC 2617 adds "quality of protection" (qop), nonce counter incremented by client, + /// and a client-generated random nonce. + pub fn new_401_unauthorized_digest(realm_name: impl Display, nonce: impl Display, opaque: impl Display, qop_auth: bool, qop_auth_int: bool) -> Self { + // cf. https://en.wikipedia.org/wiki/Digest_access_authentication#Example_with_explanation + // and https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/WWW-Authenticate + let http_response: Vec = format!( + "HTTP/1.1 401 Unauthorized\r\n\ + WWW-Authenticate: Digest realm=\"{}\",\r\n\ + {}\ + nonce=\"{}\",\r\n\ + opaque=\"{}\"\r\n\ + \r\n", + realm_name, + match (qop_auth, qop_auth_int) { + (true, true) => "qop=\"auth,auth-int\",\r\n", + (true, false) => "qop=\"auth\",\r\n", + (false, true) => "qop=\"auth-int\",\r\n", + (false, false) => "" + }, + nonce, + opaque + ).as_bytes().into(); + Self { http_response } + } + /// Create a new '403 Forbidden' HTTP response. pub fn new_403_forbidden(content: &mut Vec) -> Self { let mut http_response: Vec = format!("HTTP/1.1 403 Forbidden\r\nContent-Length: {}\r\n\r\n", content.len()).as_bytes().into(); diff --git a/src/main.rs b/src/main.rs index d5c50e3..94455a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,12 +15,28 @@ use chrono::{DateTime, Utc}; use std::time::SystemTime; use ansi_term::Colour::Red; use lazy_static::lazy_static; -use rand::thread_rng; +use rand::{Rng, thread_rng}; use rand::seq::SliceRandom; use std::sync::RwLock; +use ring::hmac; +use ring::rand::SystemRandom; + +const REALM_NAME: &str = ""; + +lazy_static! { + /// A global constant secret only known to the server, generated once on server startup. + /// Used for the HMAC's used for the HTTP Digest Authentication. + /// + /// Source: https://docs.rs/ring/latest/ring/hmac/index.html + static ref GLOBAL_SERVER_SECRET: hmac::Key = hmac::Key::generate(hmac::HMAC_SHA256, &SystemRandom::new()).unwrap(); +} fn main() { println!(); // separator + + println!("Note: For a quick and simple setup, just hit ENTER repeatedly until the server is started!"); + + println!(); // separator println!("Please provide credentials or hit ENTER two times to not use any authorization:"); print!("Username: "); @@ -33,8 +49,15 @@ fn main() { let mut password = String::new(); io::stdin().read_line(&mut password).unwrap(); password = password.trim().to_string(); + let mut use_http_digest_access_authentication = true; // = false means use HTTP Basic Authentication if username != "" || password != "" { println!("Credentials set to: Username: \"{}\" & Password: \"{}\"", username, password); + print!("Do you wish to use the insecure/unencrypted HTTP Basic Authentication instead of the default Digest Access Authentication? [y/N]"); + io::stdout().flush().unwrap(); + let mut user_answer_str = String::new(); + io::stdin().read_line(&mut user_answer_str).unwrap(); + user_answer_str = user_answer_str.trim().to_string(); + use_http_digest_access_authentication = user_answer_str.to_lowercase() != "y"; // use HTTP Digest Access Authentication, unless the user entered "y" (or "Y") } else { println!("No credentials set."); } @@ -99,7 +122,7 @@ fn main() { let password = password.clone(); thread::spawn(move || { let ip_addr: String = stream.peer_addr().map_or("???".to_string(), |addr| addr.to_string()); - handle_connection(stream, username, password).unwrap_or_else( + handle_connection(stream, username, password, use_http_digest_access_authentication).unwrap_or_else( |err_str| {eprintln!("{}", Red.paint(format!("[{}] Error while serving {}: {}", date_time_str(), ip_addr, err_str)))} ); }); @@ -109,7 +132,7 @@ fn main() { /// Handles a connection coming from `stream`. /// When `username != "" || password != ""` it also checks whether the correct `username` and /// `password` were provided – if not, it responds with a '401 Unauthorized'. -fn handle_connection(mut stream: TcpStream, username: String, password: String) -> std::io::Result<()> { +fn handle_connection(mut stream: TcpStream, username: String, password: String, use_http_digest_access_authentication: bool) -> std::io::Result<()> { // Read and parse the HTTP request: let http_request: HTTPRequest = match HTTPRequest::read_from_tcp_stream(&mut stream) { Ok(http_request) => http_request, @@ -122,16 +145,49 @@ fn handle_connection(mut stream: TcpStream, username: String, password: String) // Do the HTTP Auth check: if username != "" || password != "" { // A username and password are necessary, i.e. auth protection is turned on: - match http_request.get_authorization() { - Some((provided_uname, provided_pw)) - if provided_uname == username && provided_pw == password => {}, // Uname & PW ok, do nothing and continue... - Some((provided_uname, provided_pw)) => { // An invalid authorization was provided: - HTTPResponse::new_401_unauthorized("").send_to_tcp_stream(&mut stream)?; - return Err(Error::new(ErrorKind::Other, format!("requested {} with incorrect credentials: {}:{}", get_path, provided_uname, provided_pw))); + if use_http_digest_access_authentication { // use HTTP Digest Access Authentication: + match http_request.verify_digest_authorization(&username, &password, REALM_NAME, + |nonce, opaque| + { + let expiration_timestamp_str = String::from_utf8(hex::decode(opaque.split("+").nth(0).unwrap()).unwrap_or(Vec::new())).unwrap_or(String::new()); + let expiration_timestamp = DateTime::parse_from_str(&expiration_timestamp_str, "%Y-%m-%d %H:%M:%S"); + if expiration_timestamp.is_err() || expiration_timestamp.unwrap() > Local::now() { // expired (or invalid): + return false; // expiration_timestamp has expired: do not accept signatures of this nonce anymore! + } + let signature = hex::decode(opaque.split("+").nth(1).unwrap_or("")).unwrap_or(Vec::new()); + let client_ip_addr = stream.peer_addr().expect(""); + let msg = format!("{}:{}:{}", nonce, expiration_timestamp_str, client_ip_addr); + return hmac::verify(&GLOBAL_SERVER_SECRET, msg.as_bytes(), &*signature).is_ok(); + }, + None) + { + Ok(true) => {}, // Authentication successful, do nothing and continue... + Ok(false) => { // No or an incorrect Authentication: + let nonce = hex::encode(rand::thread_rng().gen::<[u8; 32]>()); // = a new random nonce for every request + let expiration_timestamp = hex::encode((Local::now() + chrono::Duration::minutes(4)).format("%Y-%m-%d %H:%M:%S").to_string()); // = TIME_WAIT = 2*MSL = 2*120sec + let client_ip_addr = stream.peer_addr().expect("Could not get client ip address, necessary for HMAC used as opaque for HTTP Digest Authentication"); + let msg = format!("{}:{}:{}", nonce, expiration_timestamp, client_ip_addr); + let opaque = expiration_timestamp + "+" + &*hex::encode(hmac::sign(&GLOBAL_SERVER_SECRET, msg.as_bytes()).as_ref()); // = (expiration_timestamp, HMAC(GLOBAL_SERVER_SECRET; nonce, expiration_timestamp, client_ip_addr)) + HTTPResponse::new_401_unauthorized_digest(REALM_NAME, nonce, opaque, false, false).send_to_tcp_stream(&mut stream)?; + return Err(Error::new(ErrorKind::Other, format!("requested {} with an incorrect or without a digest authentication", get_path))); + }, + Err(err) => { // Incomplete or syntactically incorrect Authentication: + HTTPResponse::new_400_bad_request(&mut "Incomplete or syntactically incorrect digest authentication provided".as_bytes().to_vec()).send_to_tcp_stream(&mut stream)?; + return Err(Error::new(ErrorKind::Other, format!("requested {} with an incomplete or with a syntactically incorrect digest authentication: {}", get_path, err))); + } } - None => { // No authorization was provided: - HTTPResponse::new_401_unauthorized("").send_to_tcp_stream(&mut stream)?; - return Err(Error::new(ErrorKind::Other, format!("requested {} without giving credentials!", get_path))); + } else { // use HTTP Basic Authentication (insecure! pw transmitted in plain text! pw check vulnerable to timing attacks!): + match http_request.get_authorization() { + Some((provided_uname, provided_pw)) + if provided_uname == username && provided_pw == password => {}, // Uname & PW ok, do nothing and continue... + Some((provided_uname, provided_pw)) => { // An invalid authorization was provided: + HTTPResponse::new_401_unauthorized(REALM_NAME).send_to_tcp_stream(&mut stream)?; + return Err(Error::new(ErrorKind::Other, format!("requested {} with incorrect credentials: {}:{}", get_path, provided_uname, provided_pw))); + } + None => { // No authorization was provided: + HTTPResponse::new_401_unauthorized(REALM_NAME).send_to_tcp_stream(&mut stream)?; + return Err(Error::new(ErrorKind::Other, format!("requested {} without giving credentials!", get_path))); + } } } }