diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 7601a70..9c11eb4 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -18,4 +18,7 @@ jobs: run: rustup component add clippy - name: Run checks run: make check - + - name: Run explicit openssl checks + run: make check_openssl + - name: Run explicit md5 checks + run: make check_md5 diff --git a/Makefile b/Makefile index f8f5e1f..2eb548a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ check: test lint +check_openssl: lint_with_openssl build_with_openssl test_with_openssl +check_md5: lint_with_md5 build_with_md5 test_with_md5 test: cargo test @@ -17,3 +19,20 @@ fix: cargo fix --allow-dirty --allow-staged cargo fmt +build_with_openssl: + cd radius && cargo build --verbose --no-default-features --features openssl + +test_with_openssl: + cd radius && cargo test --verbose --no-default-features --features openssl + +lint_with_openssl: + cd radius && cargo clippy --verbose --no-default-features --features openssl + +build_with_md5: + cd radius && cargo build --verbose --no-default-features --features md5 + +test_with_md5: + cd radius && cargo test --verbose --no-default-features --features md5 + +lint_with_md5: + cd radius && cargo clippy --verbose --no-default-features --features md5 diff --git a/radius/Cargo.toml b/radius/Cargo.toml index 8734594..448cc55 100644 --- a/radius/Cargo.toml +++ b/radius/Cargo.toml @@ -13,7 +13,8 @@ categories = ["network-programming"] keywords = ["radius", "async"] [dependencies] -md5 = "0.7.0" +md5 = { version = "0.7.0", optional = true} +openssl = { version = "0.10", optional = true } chrono = "0.4" rand = "0.8.3" num_enum = "0.5.1" @@ -21,3 +22,8 @@ thiserror = "1.0" log = "0.4.14" tokio = { version = "1.6.1", features = ["full"] } async-trait = "0.1.50" + +[features] +default = ["md5"] +openssl = ["dep:openssl"] +md5 = ["dep:md5"] diff --git a/radius/src/core/avp.rs b/radius/src/core/avp.rs index f2407d8..a5787e2 100644 --- a/radius/src/core/avp.rs +++ b/radius/src/core/avp.rs @@ -7,6 +7,12 @@ use thiserror::Error; use crate::core::tag::{Tag, UNUSED_TAG_VALUE}; +#[cfg(feature = "md5")] +use md5::compute; + +#[cfg(feature = "openssl")] +use openssl::hash::{hash, MessageDigest}; + #[derive(Error, PartialEq, Debug)] pub enum AVPError { /// This error is raised on the length of given plain text for user-password exceeds the maximum limit. @@ -44,6 +50,10 @@ pub enum AVPError { /// This error is raised when a tag is invalid for the tagged-integer value. #[error("invalid tag for integer value. this must be less than or equal 0x1f")] InvalidTagForIntegerValueError(), + + /// This error is raised when computation of hash fails using openssl hash + #[error("computation of hash failed: {0}")] + HashComputationFailed(String), } pub type AVPType = u8; @@ -167,6 +177,7 @@ impl AVP { }) } + #[cfg(feature = "md5")] /// (This method is for dictionary developers) make an AVP from a user-password value. /// see also: https://tools.ietf.org/html/rfc2865#section-5.2 pub fn from_user_password( @@ -207,7 +218,7 @@ impl AVP { let mut buff = request_authenticator.to_vec(); if plain_text.is_empty() { - let enc = md5::compute([secret, &buff[..]].concat()).to_vec(); + let enc = compute([secret, &buff[..]].concat()).to_vec(); return Ok(AVP { typ, value: enc.iter().zip(vec![0; 16]).map(|(d, p)| d ^ p).collect(), @@ -222,7 +233,85 @@ impl AVP { chunk_vec.extend(vec![0; 16 - l]); // zero padding } - let enc_block = md5::compute([secret, &buff[..]].concat()).to_vec(); + let enc_block = compute([secret, &buff[..]].concat()).to_vec(); + buff = enc_block + .iter() + .zip(chunk_vec) + .map(|(d, p)| d ^ p) + .collect(); + enc.extend(&buff); + } + + Ok(AVP { typ, value: enc }) + } + + #[cfg(feature = "openssl")] + /// (This method is for dictionary developers) make an AVP from a user-password value. + /// see also: https://tools.ietf.org/html/rfc2865#section-5.2 + pub fn from_user_password( + typ: AVPType, + plain_text: &[u8], + secret: &[u8], + request_authenticator: &[u8], + ) -> Result { + // Call the shared secret S and the pseudo-random 128-bit Request + // Authenticator RA. Break the password into 16-octet chunks p1, p2, + // etc. with the last one padded at the end with nulls to a 16-octet + // boundary. Call the ciphertext blocks c(1), c(2), etc. We'll need + // intermediate values b1, b2, etc. + // + // b1 = MD5(S + RA) c(1) = p1 xor b1 + // b2 = MD5(S + c(1)) c(2) = p2 xor b2 + // . . + // . . + // . . + // bi = MD5(S + c(i-1)) c(i) = pi xor bi + // + // ref: https://tools.ietf.org/html/rfc2865#section-5.2 + + if plain_text.len() > 128 { + return Err(AVPError::UserPasswordPlainTextMaximumLengthExceededError( + plain_text.len(), + )); + } + + if secret.is_empty() { + return Err(AVPError::PasswordSecretMissingError()); + } + + if request_authenticator.len() != 16 { + return Err(AVPError::InvalidRequestAuthenticatorLength()); + } + + let mut buff = request_authenticator.to_vec(); + + if plain_text.is_empty() { + let hash_val = hash(MessageDigest::md5(), &[secret, &buff[..]].concat()); + let enc_block = if let Err(_err) = hash_val { + return Err(AVPError::HashComputationFailed(_err.to_string())) + } else { + hash_val.unwrap() + }; + return Ok(AVP { + typ, + value: enc_block.iter().zip(vec![0; 16]).map(|(d, p)| d ^ p).collect(), + }); + } + + let mut enc: Vec = Vec::new(); + for chunk in plain_text.chunks(16) { + let mut chunk_vec = chunk.to_vec(); + let l = chunk.len(); + if l < 16 { + chunk_vec.extend(vec![0; 16 - l]); // zero padding + } + + let hash_val = hash(MessageDigest::md5(), &[secret, &buff[..]].concat()); + let enc_block = if let Err(_err) = hash_val { + return Err(AVPError::HashComputationFailed(_err.to_string())); + } else { + hash_val.unwrap() + }; buff = enc_block .iter() .zip(chunk_vec) @@ -242,6 +331,7 @@ impl AVP { } } + #[cfg(feature = "md5")] /// (This method is for dictionary developers) make an AVP from a tunne-password value. /// see also: https://tools.ietf.org/html/rfc2868#section-3.5 pub fn from_tunnel_password( @@ -305,7 +395,7 @@ impl AVP { typ, value: [ enc, - md5::compute([secret, &buff[..]].concat()) + compute([secret, &buff[..]].concat()) .iter() .zip(vec![0; 16]) .map(|(d, p)| d ^ p) @@ -322,7 +412,112 @@ impl AVP { chunk_vec.extend(vec![0; 16 - l]); // zero padding } - let enc_block = md5::compute([secret, &buff[..]].concat()).to_vec(); + let enc_block = compute([secret, &buff[..]].concat()).to_vec(); + buff = enc_block + .iter() + .zip(chunk_vec) + .map(|(d, p)| d ^ p) + .collect(); + enc.extend(&buff); + } + + Ok(AVP { typ, value: enc }) + } + + #[cfg(feature = "openssl")] + /// (This method is for dictionary developers) make an AVP from a tunne-password value. + /// see also: https://tools.ietf.org/html/rfc2868#section-3.5 + pub fn from_tunnel_password( + typ: AVPType, + tag: Option<&Tag>, + plain_text: &[u8], + secret: &[u8], + request_authenticator: &[u8], + ) -> Result { + /* + * 0 1 2 3 + * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | Type | Length | Tag | Salt + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * Salt (cont) | String ... + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * + * b(1) = MD5(S + R + A) c(1) = p(1) xor b(1) C = c(1) + * b(2) = MD5(S + c(1)) c(2) = p(2) xor b(2) C = C + c(2) + * . . + * . . + * . . + * b(i) = MD5(S + c(i-1)) c(i) = p(i) xor b(i) C = C + c(i) + * + * The resulting encrypted String field will contain + * c(1)+c(2)+...+c(i). + * + * https://tools.ietf.org/html/rfc2868#section-3.5 + */ + + if request_authenticator.len() > 240 { + return Err(AVPError::InvalidAttributeLengthError( + "240 bytes".to_owned(), + request_authenticator.len(), + )); + } + + let mut rng = rand::thread_rng(); + let salt: [u8; 2] = [rng.gen::() | 0x80, rng.gen::()]; + + if secret.is_empty() { + return Err(AVPError::PasswordSecretMissingError()); + } + + if request_authenticator.len() != 16 { + return Err(AVPError::InvalidRequestAuthenticatorLength()); + } + + // NOTE: prepend one byte as a tag and two bytes as a salt + // TODO: should it separate them to private struct fields? + let mut enc: Vec = [ + vec![tag.map_or(UNUSED_TAG_VALUE, |v| v.value)], + salt.to_vec(), + ] + .concat(); + + let mut buff = [request_authenticator, &salt].concat(); + let hash_val = hash(MessageDigest::md5(), &[secret, &buff[..]].concat()); + let enc_block = if let Err(_err) = hash_val { + return Err(AVPError::HashComputationFailed(_err.to_string())); + } else { + hash_val.unwrap() + }; + + if plain_text.is_empty() { + return Ok(AVP { + typ, + value: [ + enc, + enc_block + .iter() + .zip(vec![0; 16]) + .map(|(d, p)| d ^ p) + .collect::>(), + ] + .concat(), + }); + } + + for chunk in plain_text.chunks(16) { + let mut chunk_vec = chunk.to_vec(); + let l = chunk.len(); + if l < 16 { + chunk_vec.extend(vec![0; 16 - l]); // zero padding + } + + let hash_val = hash(MessageDigest::md5(), &[secret, &buff[..]].concat()); + let enc_block = if let Err(_err) = hash_val { + return Err(AVPError::HashComputationFailed(_err.to_string())); + } else { + hash_val.unwrap() + }; buff = enc_block .iter() .zip(chunk_vec) @@ -504,6 +699,7 @@ impl AVP { } } + #[cfg(feature = "md5")] /// (This method is for dictionary developers) encode an AVP into user-password value as bytes. pub fn encode_user_password( &self, @@ -533,7 +729,60 @@ impl AVP { // And this must be aligned by each 16 bytes length. for chunk in self.value.chunks(16) { let chunk_vec = chunk.to_vec(); - let dec_block = md5::compute([secret, &buff[..]].concat()).to_vec(); + let dec_block = compute([secret, &buff[..]].concat()).to_vec(); + dec.extend( + dec_block + .iter() + .zip(&chunk_vec) + .map(|(d, p)| d ^ p) + .collect::>(), + ); + buff = chunk_vec.clone(); + } + + // remove trailing zero bytes + match dec.split(|b| *b == 0).next() { + Some(dec) => Ok(dec.to_vec()), + None => Ok(vec![]), + } + } + + #[cfg(feature = "openssl")] + // (This method is for dictionary developers) encode an AVP into user-password value as bytes. + pub fn encode_user_password( + &self, + secret: &[u8], + request_authenticator: &[u8], + ) -> Result, AVPError> { + if self.value.len() < 16 || self.value.len() > 128 { + return Err(AVPError::InvalidAttributeLengthError( + "16 >= bytes && 128 <= bytes".to_owned(), + self.value.len(), + )); + } + + if secret.is_empty() { + return Err(AVPError::PasswordSecretMissingError()); + } + + if request_authenticator.len() != 16 { + return Err(AVPError::InvalidRequestAuthenticatorLength()); + } + + let mut dec: Vec = Vec::new(); + let mut buff: Vec = request_authenticator.to_vec(); + + // NOTE: + // It ensures attribute value has 16 bytes length at least because the value is encoded by md5. + // And this must be aligned by each 16 bytes length. + for chunk in self.value.chunks(16) { + let chunk_vec = chunk.to_vec(); + let hash_val = hash(MessageDigest::md5(), &[secret, &buff[..]].concat()); + let dec_block = if let Err(_err) = hash_val { + return Err(AVPError::HashComputationFailed(_err.to_string())) + } else { + hash_val.unwrap() + }; dec.extend( dec_block .iter() @@ -571,6 +820,7 @@ impl AVP { } } + #[cfg(feature = "md5")] /// (This method is for dictionary developers) encode an AVP into a tunnel-password value as bytes. pub fn encode_tunnel_password( &self, @@ -606,7 +856,66 @@ impl AVP { for chunk in self.value[3..].chunks(16) { let chunk_vec = chunk.to_vec(); - let dec_block = md5::compute([secret, &buff[..]].concat()).to_vec(); + let dec_block = compute([secret, &buff[..]].concat()).to_vec(); + dec.extend( + dec_block + .iter() + .zip(&chunk_vec) + .map(|(d, p)| d ^ p) + .collect::>(), + ); + buff = chunk_vec.clone(); + } + + // remove trailing zero bytes + match dec.split(|b| *b == 0).next() { + Some(dec) => Ok((dec.to_vec(), tag)), + None => Ok((vec![], tag)), + } + } + + #[cfg(feature = "openssl")] + /// (This method is for dictionary developers) encode an AVP into a tunnel-password value as bytes. + pub fn encode_tunnel_password( + &self, + secret: &[u8], + request_authenticator: &[u8], + ) -> Result<(Vec, Tag), AVPError> { + if self.value.len() < 19 || self.value.len() > 243 || (self.value.len() - 3) % 16 != 0 { + return Err(AVPError::InvalidAttributeLengthError( + "19 <= bytes && bytes <= 242 && (bytes - 3) % 16 == 0".to_owned(), + self.value.len(), + )); + } + + if self.value[1] & 0x80 != 0x80 { + // salt + return Err(AVPError::InvalidSaltMSBError(self.value[1])); + } + + if secret.is_empty() { + return Err(AVPError::PasswordSecretMissingError()); + } + + if request_authenticator.len() != 16 { + return Err(AVPError::InvalidRequestAuthenticatorLength()); + } + + let tag = Tag { + value: self.value[0], + }; + let mut dec: Vec = Vec::new(); + let mut buff: Vec = + [request_authenticator.to_vec(), self.value[1..3].to_vec()].concat(); + + for chunk in self.value[3..].chunks(16) { + let chunk_vec = chunk.to_vec(); + let hash_val = hash(MessageDigest::md5(), &[secret, &buff[..]].concat()); + let dec_block = if let Err(_err) = hash_val { + return Err(AVPError::HashComputationFailed(_err.to_string())) + } else { + hash_val.unwrap() + }; dec.extend( dec_block .iter() diff --git a/radius/src/core/packet.rs b/radius/src/core/packet.rs index 89b9e96..2dd30e9 100644 --- a/radius/src/core/packet.rs +++ b/radius/src/core/packet.rs @@ -8,6 +8,12 @@ use crate::core::attributes::Attributes; use crate::core::avp::{AVPType, AVP}; use crate::core::code::Code; +#[cfg(feature = "md5")] +use md5::compute; + +#[cfg(feature = "openssl")] +use openssl::hash::{hash, MessageDigest}; + const MAX_PACKET_LENGTH: usize = 4096; const RADIUS_PACKET_HEADER_LENGTH: usize = 20; // i.e. minimum packet length @@ -36,6 +42,10 @@ pub enum PacketError { /// An error that is raised when it received unknown packet type code of RADIUS. #[error("Unknown RADIUS packet type code: {0}")] UnknownCodeError(String), + + /// This error is raised when computation of hash fails using openssl hash + #[error("computation of hash failed: {0}")] + HashComputationFailed(String), } /// This struct represents a packet of RADIUS for request and response. @@ -159,6 +169,7 @@ impl Packet { } } + #[cfg(feature = "md5")] /// This method encodes the Packet into bytes. pub fn encode(&self) -> Result, PacketError> { let mut bs = match self.marshal_binary() { @@ -196,7 +207,59 @@ impl Packet { } buf.extend(bs[RADIUS_PACKET_HEADER_LENGTH..].to_vec()); buf.extend(&self.secret); - bs.splice(4..20, md5::compute(&buf).to_vec()); + bs.splice(4..20, compute(&buf).to_vec()); + + Ok(bs) + } + _ => Err(PacketError::UnknownCodeError(format!("{:?}", self.code))), + } + } + + #[cfg(feature = "openssl")] + /// This method encodes the Packet into bytes. + pub fn encode(&self) -> Result, PacketError> { + let mut bs = match self.marshal_binary() { + Ok(bs) => bs, + Err(e) => return Err(PacketError::EncodingError(e)), + }; + + match self.code { + Code::AccessRequest | Code::StatusServer => Ok(bs), + Code::AccessAccept + | Code::AccessReject + | Code::AccountingRequest + | Code::AccountingResponse + | Code::AccessChallenge + | Code::DisconnectRequest + | Code::DisconnectACK + | Code::DisconnectNAK + | Code::CoARequest + | Code::CoAACK + | Code::CoANAK => { + let mut buf: Vec = bs[..4].to_vec(); + match self.code { + Code::AccountingRequest // see "Request Authenticator" in https://tools.ietf.org/html/rfc2866#section-3 + | Code::DisconnectRequest // same as "RFC2866"; https://tools.ietf.org/html/rfc5176#section-2.3 + | Code::CoARequest // same as "RFC2866"; https://tools.ietf.org/html/rfc5176#section-2.3 + => { + buf.extend(vec![ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + ]); + } + _ => { + buf.extend(self.authenticator.clone()); // TODO take from `bs`? + } + } + buf.extend(bs[RADIUS_PACKET_HEADER_LENGTH..].to_vec()); + buf.extend(&self.secret); + let hash_val = hash(MessageDigest::md5(), &buf); + let enc = if let Err(_err) = hash_val { + return Err(PacketError::HashComputationFailed(_err.to_string())); + } else { + hash_val.unwrap() + }; + bs.splice(4..20, enc.to_vec()); Ok(bs) } @@ -242,6 +305,7 @@ impl Packet { Ok(bs) } + #[cfg(feature = "md5")] /// Returns whether the Packet is authentic response or not. pub fn is_authentic_response(response: &[u8], request: &[u8], secret: &[u8]) -> bool { if response.len() < RADIUS_PACKET_HEADER_LENGTH @@ -251,7 +315,7 @@ impl Packet { return false; } - md5::compute( + compute( [ &response[..4], &request[4..RADIUS_PACKET_HEADER_LENGTH], @@ -264,6 +328,32 @@ impl Packet { .eq(&response[4..RADIUS_PACKET_HEADER_LENGTH].to_vec()) } + #[cfg(feature = "openssl")] + /// Returns whether the Packet is authentic response or not. + pub fn is_authentic_response(response: &[u8], request: &[u8], secret: &[u8]) -> bool { + if response.len() < RADIUS_PACKET_HEADER_LENGTH + || request.len() < RADIUS_PACKET_HEADER_LENGTH + || secret.is_empty() + { + return false; + } + + let hash_val = hash( + MessageDigest::md5(), + &[ + &response[..4], + &request[4..RADIUS_PACKET_HEADER_LENGTH], + &response[RADIUS_PACKET_HEADER_LENGTH..], + secret, + ] + .concat(), + ); + let enc = hash_val.expect("Hash computation failed using openssl md5 digest algorithm"); + enc.to_vec() + .eq(&response[4..RADIUS_PACKET_HEADER_LENGTH].to_vec()) + } + + #[cfg(feature = "md5")] /// Returns whether the Packet is authentic request or not. pub fn is_authentic_request(request: &[u8], secret: &[u8]) -> bool { if request.len() < RADIUS_PACKET_HEADER_LENGTH || secret.is_empty() { @@ -272,7 +362,7 @@ impl Packet { match Code::from(request[0]) { Code::AccessRequest | Code::StatusServer => true, - Code::AccountingRequest | Code::DisconnectRequest | Code::CoARequest => md5::compute( + Code::AccountingRequest | Code::DisconnectRequest | Code::CoARequest => compute( [ &request[..4], &[ @@ -290,6 +380,38 @@ impl Packet { } } + #[cfg(feature = "openssl")] + /// Returns whether the Packet is authentic request or not. + pub fn is_authentic_request(request: &[u8], secret: &[u8]) -> bool { + if request.len() < RADIUS_PACKET_HEADER_LENGTH || secret.is_empty() { + return false; + } + + match Code::from(request[0]) { + Code::AccessRequest | Code::StatusServer => true, + Code::AccountingRequest | Code::DisconnectRequest | Code::CoARequest => { + let hash_val = hash( + MessageDigest::md5(), + &[ + &request[..4], + &[ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + ], + &request[RADIUS_PACKET_HEADER_LENGTH..], + secret, + ] + .concat(), + ); + let enc = hash_val.expect("Hash computation failed using openssl md5 digest algorithm"); + + enc.to_vec() + .eq(&request[4..RADIUS_PACKET_HEADER_LENGTH].to_vec()) + } + _ => false, + } + } + /// Add an AVP to the list of AVPs. pub fn add(&mut self, avp: AVP) { self.attributes.add(avp); diff --git a/radius/src/lib.rs b/radius/src/lib.rs index 9b2f1e4..8a05afd 100644 --- a/radius/src/lib.rs +++ b/radius/src/lib.rs @@ -4,3 +4,6 @@ extern crate log; pub mod client; pub mod core; pub mod server; + +#[cfg(all(feature = "md5", feature = "openssl"))] +compile_error!("feature \"md5\" and feature \"openssl\" cannot be enabled at the same time");