use std::{
cmp::Ordering,
string::ToString,
sync::{
atomic::{self, AtomicU32},
Arc, Mutex, PoisonError,
},
};
use hyper::{
header::{HeaderValue, WWW_AUTHENTICATE},
http::uri::PathAndQuery,
Method, Response, Uri,
};
use log::trace;
use md5::{Digest, Md5};
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha12Rng;
use strum::Display;
use thiserror::Error;
#[derive(Debug, Clone)]
pub(crate) struct AuthInfo {
username: String,
password: String,
pub counter: Arc<AtomicU32>,
pub last_auth_params: Arc<Mutex<Option<AuthParams>>>,
rng: ChaCha12Rng,
}
impl AuthInfo {
pub fn new(username: String, password: String, seed: Option<u64>) -> AuthInfo {
let mut rng = ChaCha12Rng::from_entropy();
if let Some(s) = seed {
rng = ChaCha12Rng::seed_from_u64(s);
}
AuthInfo {
username,
password,
counter: Arc::new(AtomicU32::new(1)),
last_auth_params: Arc::new(Mutex::new(None)),
rng,
}
}
#[allow(clippy::similar_names)]
pub fn authenticate(
&mut self,
uri: &Uri,
method: &Method,
) -> Result<Option<HeaderValue>, AuthError> {
let maybe_auth_params = &*self
.last_auth_params
.lock()
.unwrap_or_else(PoisonError::into_inner);
let auth_params = match maybe_auth_params {
Some(params) => params,
None => return Ok(None),
};
let mut cnonce_bytes: [u8; 16] = [0; 16]; self.rng.fill(&mut cnonce_bytes[..]);
let path_and_query = uri
.path_and_query()
.map_or(uri.path(), PathAndQuery::as_str);
let nc = format!("{:08x}", self.counter.load(atomic::Ordering::Relaxed));
let qop = auth_params.qop.iter().max().ok_or(AuthError::Unsupported)?;
let nonce = &auth_params.nonce;
let realm = &auth_params.realm;
let opaque = &auth_params.opaque;
let cnonce = hex::encode(cnonce_bytes);
let algorithm = &auth_params.algorithm;
trace!(
"Performing digest authentication with qop: {}, algorithm: {}, nc: {}.",
qop,
algorithm,
nc
);
let ha1_input = format!("{}:{}:{}", &self.username, realm, &self.password);
let mut ha1 = md5_str(ha1_input);
if algorithm.is_sess() {
ha1 = md5_str(format!("{}:{}:{}", ha1, nonce, cnonce));
}
let ha2_input = format!("{}:{}", method, path_and_query);
let ha2 = md5_str(ha2_input);
let response_input = format!("{}:{}:{}:{}:{}:{}", ha1, nonce, nc, cnonce, qop, ha2);
let response = md5_str(response_input);
let mut auth_header = format!(
"Digest username=\"{}\", realm=\"{}\", nonce=\"{}\", uri=\"{}\", qop={}, nc={}, cnonce=\"{}\", response=\"{}\", algorithm={}",
self.username,
realm,
nonce,
path_and_query,
qop,
nc,
cnonce,
response,
algorithm,
);
if let Some(opaque_val) = opaque {
let opaque_str = format!(", opaque={}", opaque_val);
auth_header.push_str(&opaque_str);
}
self.counter.fetch_add(1, atomic::Ordering::Relaxed);
Ok(Some(HeaderValue::from_str(&auth_header)?))
}
pub fn authenticate_with_resp<T>(
&mut self,
response: &Response<T>,
uri: &Uri,
method: &Method,
) -> Result<HeaderValue, AuthError> {
let headers = response.headers();
let authenticate_headers = headers
.get_all(WWW_AUTHENTICATE)
.into_iter()
.map(|h| {
h.to_str().map_err(|_| {
AuthError::InvalidHeader("header could not be parsed to String".to_string())
})
})
.collect::<Result<Vec<&str>, AuthError>>()?;
let digest_headers = authenticate_headers
.into_iter()
.filter_map(|h| h.strip_prefix("Digest "));
let mut auth_choices = digest_headers
.map(parse_header)
.collect::<Result<Vec<AuthParams>, AuthError>>()?;
auth_choices.sort_unstable(); let auth_params = auth_choices.last().ok_or(AuthError::Unsupported)?;
*self
.last_auth_params
.lock()
.unwrap_or_else(PoisonError::into_inner) = Some(auth_params.clone());
self.counter.store(1, atomic::Ordering::Relaxed);
self.authenticate(uri, method)
.transpose()
.ok_or(AuthError::Unsupported)?
}
}
fn parse_header(header: &str) -> Result<AuthParams, AuthError> {
let str_params = split_header(header);
let realm = find_string_value(&str_params, "realm").unwrap_or_default();
let qop = find_string_value(&str_params, "qop")
.unwrap_or_default()
.split(',')
.map(|s| match s.trim() {
"" | "auth" => Ok(Qop::Auth),
q => Err(AuthError::InvalidHeader(format!(
"unknown QoP directive: {}",
q
))),
})
.collect::<Result<Vec<Qop>, AuthError>>()?;
let algorithm = match find_string_value(&str_params, "algorithm")
.unwrap_or_default()
.trim()
{
"" | "MD5" => Algorithm::Md5,
"MD5-sess" => Algorithm::Md5Sess,
a => {
return Err(AuthError::InvalidHeader(format!(
"unknown algorithm: {}",
a
)))
}
};
let nonce = find_string_value(&str_params, "nonce")
.ok_or_else(|| AuthError::InvalidHeader("no nonce provided".to_string()))?;
let opaque = find_string_value(&str_params, "opaque");
Ok(AuthParams {
realm,
qop,
algorithm,
nonce,
opaque,
})
}
fn split_header(header_str: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut last_split = 0;
let mut char_iterator = header_str.char_indices().peekable();
while let Some((i, c)) = char_iterator.next() {
match c {
'\'' => in_single_quote = !in_single_quote,
'\"' => in_double_quote = !in_double_quote,
',' => {
if !in_single_quote && !in_double_quote {
parts.push(header_str[last_split..i].trim_start_matches(',').trim());
last_split = i;
}
}
_ => {}
}
if char_iterator.peek() == None {
parts.push(header_str[last_split..].trim_start_matches(',').trim());
}
}
parts
}
fn find_string_value(parts: &Vec<&str>, field: &'static str) -> Option<String> {
for &p in parts {
if p.starts_with(field) {
let formatted = format!("{}=", field);
return Some(
p.replace(&formatted, "")
.trim_start_matches('\"')
.trim_end_matches('\"')
.to_string(),
);
}
}
None
}
fn md5_str(input: String) -> String {
let mut digest = Md5::new();
let input_bytes = input.into_bytes();
digest.update(&input_bytes);
hex::encode(digest.finalize())
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub(crate) struct AuthParams {
realm: String,
qop: Vec<Qop>,
algorithm: Algorithm,
nonce: String,
opaque: Option<String>,
}
impl Ord for AuthParams {
fn cmp(&self, other: &Self) -> Ordering {
match self.qop.iter().max().cmp(&other.qop.iter().max()) {
Ordering::Equal => self.algorithm.cmp(&other.algorithm),
ord => ord,
}
}
}
impl PartialOrd for AuthParams {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Display, Debug)]
#[strum(serialize_all = "kebab-case")]
enum Qop {
Auth,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Display, Debug)]
enum Algorithm {
#[strum(serialize = "MD5")]
Md5,
#[strum(serialize = "MD5-sess")]
Md5Sess,
}
impl Algorithm {
fn is_sess(self) -> bool {
matches!(self, Algorithm::Md5Sess)
}
}
#[allow(clippy::module_name_repetitions)]
#[derive(Error, Debug)]
pub enum AuthError {
#[error("unauthorized")]
Unauthorized,
#[error("invalid WWW-AUTHENTICATE header: {0}")]
InvalidHeader(String),
#[error("failed to constuct AUTHORIZATION header")]
HeaderConstruction(#[from] hyper::header::InvalidHeaderValue),
#[error("no supported authentication method")]
Unsupported,
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use hyper::{
header::{HeaderValue, WWW_AUTHENTICATE},
Method, Response, Uri,
};
use super::{parse_header, Algorithm, AuthInfo, AuthParams, Qop};
#[test]
fn test_parse_header() {
let header = "Digest qop=\"auth\",algorithm=MD5-sess,realm=\"monero-rpc\", nonce=\"kVmRYw+lSQ80tTK3zj6/aA==\", stale=false";
let auth_params = parse_header(header).expect("failed to parse header");
let expected_auth_params = AuthParams {
realm: "monero-rpc".to_string(),
qop: vec![Qop::Auth],
algorithm: Algorithm::Md5Sess,
nonce: "kVmRYw+lSQ80tTK3zj6/aA==".to_string(),
opaque: None,
};
assert_eq!(auth_params, expected_auth_params);
}
#[test]
fn md5_auth() {
let mut auth_info = AuthInfo::new(
"test user".to_string(),
"test password".to_string(),
Some(1),
);
let response = Response::builder()
.header(WWW_AUTHENTICATE, "Digest qop=\"auth\",algorithm=MD5,realm=\"monero-rpc\",nonce=\"JmNFnqfRJdOr/vFZ2CpDQg==\",stale=false")
.body(()).expect("wailed to build WWW_AUTHENTICATE response");
let authorization_header = auth_info
.authenticate_with_resp(
&response,
&Uri::from_static("https://busyboredom.com:18089/json_rpc"),
&Method::POST,
)
.expect("failed to create AUTHORIZATION header");
assert_eq!(authorization_header, HeaderValue::from_static("Digest username=\"test user\", realm=\"monero-rpc\", nonce=\"JmNFnqfRJdOr/vFZ2CpDQg==\", uri=\"/json_rpc\", qop=auth, nc=00000001, cnonce=\"611830d3641a68f94a690dcc25d1f4b0\", response=\"af7810760defeed31054108ed35d400d\", algorithm=MD5"));
}
#[test]
fn md5_sess_auth() {
let mut auth_info = AuthInfo::new(
"test user".to_string(),
"test password".to_string(),
Some(1),
);
let response = Response::builder()
.header(WWW_AUTHENTICATE, "Digest qop=\"auth\",algorithm=MD5,realm=\"monero-rpc\",nonce=\"JmNFnqfRJdOr/vFZ2CpDQg==\",stale=false")
.header(WWW_AUTHENTICATE, "Digest qop=\"auth\",algorithm=MD5-sess,realm=\"monero-rpc\",nonce=\"JmNFnqfRJdOr/vFZ2CpDQg==\",stale=false")
.body(()).expect("wailed to build WWW_AUTHENTICATE response");
let authorization_header = auth_info
.authenticate_with_resp(
&response,
&Uri::from_static("https://busyboredom.com:18089/json_rpc"),
&Method::POST,
)
.expect("failed to create AUTHORIZATION header");
assert_eq!(authorization_header, HeaderValue::from_static("Digest username=\"test user\", realm=\"monero-rpc\", nonce=\"JmNFnqfRJdOr/vFZ2CpDQg==\", uri=\"/json_rpc\", qop=auth, nc=00000001, cnonce=\"611830d3641a68f94a690dcc25d1f4b0\", response=\"b0a351e720384a0160042ff2898ab24a\", algorithm=MD5-sess"));
}
#[test]
fn auth_with_opaque() {
let mut auth_info = AuthInfo::new(
"test user".to_string(),
"test password".to_string(),
Some(1),
);
let response = Response::builder()
.header(WWW_AUTHENTICATE, "Digest qop=\"auth\",algorithm=MD5,realm=\"monero-rpc\",nonce=\"JmNFnqfRJdOr/vFZ2CpDQg==\",stale=false")
.header(WWW_AUTHENTICATE, "Digest qop=\"auth\",algorithm=MD5-sess,realm=\"monero-rpc\",nonce=\"JmNFnqfRJdOr/vFZ2CpDQg==\",stale=false,opaque=5PCCDS2k5PCCDS2k")
.body(()).expect("wailed to build WWW_AUTHENTICATE response");
let authorization_header = auth_info
.authenticate_with_resp(
&response,
&Uri::from_static("https://busyboredom.com:18089/json_rpc"),
&Method::POST,
)
.expect("failed to create AUTHORIZATION header");
assert_eq!(authorization_header, HeaderValue::from_static("Digest username=\"test user\", realm=\"monero-rpc\", nonce=\"JmNFnqfRJdOr/vFZ2CpDQg==\", uri=\"/json_rpc\", qop=auth, nc=00000001, cnonce=\"611830d3641a68f94a690dcc25d1f4b0\", response=\"b0a351e720384a0160042ff2898ab24a\", algorithm=MD5-sess, opaque=5PCCDS2k5PCCDS2k"));
}
}