use std::fmt::Write as _;
use md5::Md5;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Algorithm {
#[default]
Md5,
Md5Sess,
Sha256,
Sha256Sess,
Sha512_256,
Sha512_256Sess,
}
impl Algorithm {
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_uppercase().as_str() {
"MD5" => Some(Self::Md5),
"MD5-SESS" => Some(Self::Md5Sess),
"SHA-256" => Some(Self::Sha256),
"SHA-256-SESS" => Some(Self::Sha256Sess),
"SHA-512-256" => Some(Self::Sha512_256),
"SHA-512-256-SESS" => Some(Self::Sha512_256Sess),
_ => None,
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Md5 => "MD5",
Self::Md5Sess => "MD5-sess",
Self::Sha256 => "SHA-256",
Self::Sha256Sess => "SHA-256-sess",
Self::Sha512_256 => "SHA-512-256",
Self::Sha512_256Sess => "SHA-512-256-sess",
}
}
#[must_use]
pub fn is_session(self) -> bool {
matches!(
self,
Self::Md5Sess | Self::Sha256Sess | Self::Sha512_256Sess
)
}
#[must_use]
pub fn strength(self) -> u8 {
match self {
Self::Md5 => 1,
Self::Md5Sess => 2,
Self::Sha256 => 3,
Self::Sha256Sess => 4,
Self::Sha512_256 => 5,
Self::Sha512_256Sess => 6,
}
}
fn hash(self, input: &str) -> String {
match self {
Self::Md5 | Self::Md5Sess => hex(&Md5::digest(input.as_bytes())),
Self::Sha256 | Self::Sha256Sess => hex(&Sha256::digest(input.as_bytes())),
Self::Sha512_256 | Self::Sha512_256Sess => {
hex(&sha2::Sha512_256::digest(input.as_bytes()))
}
}
}
}
fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Challenge {
pub realm: String,
pub nonce: String,
pub opaque: Option<String>,
pub algorithm: Algorithm,
pub qop_auth: bool,
pub stale: bool,
pub from_proxy: bool,
}
impl Challenge {
#[must_use]
pub fn parse(value: &[u8], from_proxy: bool) -> Option<Self> {
let text = std::str::from_utf8(value).ok()?;
let rest = text.trim().strip_prefix_ignore_ascii_case("Digest")?;
let mut realm = None;
let mut nonce = None;
let mut opaque = None;
let mut algorithm = Algorithm::Md5;
let mut qop_auth = false;
let mut stale = false;
for (name, value) in params(rest) {
match name.to_ascii_lowercase().as_str() {
"realm" => realm = Some(value),
"nonce" => nonce = Some(value),
"opaque" => opaque = Some(value),
"algorithm" => algorithm = Algorithm::parse(&value)?,
"qop" => {
qop_auth = value
.split(',')
.any(|option| option.trim().eq_ignore_ascii_case("auth"));
if !qop_auth {
return None;
}
}
"stale" => stale = value.trim().eq_ignore_ascii_case("true"),
_ => {}
}
}
Some(Self {
realm: realm?,
nonce: nonce?,
opaque,
algorithm,
qop_auth,
stale,
from_proxy,
})
}
#[must_use]
pub fn response_header(&self) -> crate::HeaderName {
if self.from_proxy {
crate::HeaderName::ProxyAuthorization
} else {
crate::HeaderName::Authorization
}
}
}
trait StripPrefixIgnoreCase {
fn strip_prefix_ignore_ascii_case(&self, prefix: &str) -> Option<&str>;
}
impl StripPrefixIgnoreCase for str {
fn strip_prefix_ignore_ascii_case(&self, prefix: &str) -> Option<&str> {
let head = self.get(..prefix.len())?;
head.eq_ignore_ascii_case(prefix)
.then(|| self.get(prefix.len()..))
.flatten()
}
}
fn params(input: &str) -> Vec<(String, String)> {
let bytes = input.as_bytes();
let mut out = Vec::new();
let mut i = 0usize;
while i < bytes.len() {
while matches!(bytes.get(i), Some(b' ' | b'\t' | b',')) {
i += 1;
}
let name_start = i;
while bytes.get(i).is_some_and(|&b| b != b'=' && b != b',') {
i += 1;
}
let name = input.get(name_start..i).unwrap_or("").trim().to_owned();
if bytes.get(i) != Some(&b'=') {
if !name.is_empty() {
out.push((name, String::new()));
}
continue;
}
i += 1;
while matches!(bytes.get(i), Some(b' ' | b'\t')) {
i += 1;
}
let value = if bytes.get(i) == Some(&b'"') {
i += 1;
let start = i;
let mut unescaped = String::new();
while let Some(&byte) = bytes.get(i) {
match byte {
b'\\' => {
if let Some(&next) = bytes.get(i + 1) {
unescaped.push(char::from(next));
i += 2;
} else {
i += 1;
}
}
b'"' => break,
_ => {
unescaped.push(char::from(byte));
i += 1;
}
}
}
let _ = start;
i += 1;
unescaped
} else {
let start = i;
while bytes.get(i).is_some_and(|&b| b != b',') {
i += 1;
}
input.get(start..i).unwrap_or("").trim().to_owned()
};
out.push((name, value));
}
out
}
#[derive(Clone)]
pub struct Credentials {
pub username: String,
pub password: String,
}
impl std::fmt::Debug for Credentials {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Credentials")
.field("username", &self.username)
.field("password", &"[REDACTED]")
.finish()
}
}
impl Credentials {
#[must_use]
pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
Self {
username: username.into(),
password: password.into(),
}
}
}
#[must_use]
pub fn respond(
challenge: &Challenge,
credentials: &Credentials,
method: &str,
uri: &str,
nonce_count: u32,
cnonce: &str,
) -> String {
let algorithm = challenge.algorithm;
let mut ha1 = algorithm.hash(&format!(
"{}:{}:{}",
credentials.username, challenge.realm, credentials.password
));
if algorithm.is_session() {
ha1 = algorithm.hash(&format!("{ha1}:{}:{cnonce}", challenge.nonce));
}
let ha2 = algorithm.hash(&format!("{method}:{uri}"));
let nc = format!("{nonce_count:08x}");
let response = if challenge.qop_auth {
algorithm.hash(&format!(
"{ha1}:{}:{nc}:{cnonce}:auth:{ha2}",
challenge.nonce
))
} else {
algorithm.hash(&format!("{ha1}:{}:{ha2}", challenge.nonce))
};
let mut header = format!(
r#"Digest username="{}", realm="{}", nonce="{}", uri="{}", response="{response}""#,
escape(&credentials.username),
escape(&challenge.realm),
escape(&challenge.nonce),
escape(uri),
);
if challenge.qop_auth {
let _ = write!(
header,
r#", qop=auth, nc={nc}, cnonce="{}""#,
escape(cnonce)
);
}
let _ = write!(header, ", algorithm={}", algorithm.as_str());
if let Some(opaque) = &challenge.opaque {
let _ = write!(header, r#", opaque="{}""#, escape(opaque));
}
header
}
fn escape(value: &str) -> String {
value.replace('\\', r"\\").replace('"', "\\\"")
}
#[must_use]
pub fn strongest(challenges: Vec<Challenge>) -> Option<Challenge> {
challenges.into_iter().reduce(|best, next| {
if next.algorithm.strength() > best.algorithm.strength() {
next
} else {
best
}
})
}
#[must_use]
pub fn topmost_supported(challenges: Vec<Challenge>) -> Option<Challenge> {
challenges.into_iter().next()
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
#[test]
fn rfc2617_worked_example_matches_the_published_digest() {
let challenge = Challenge {
realm: "testrealm@host.com".to_owned(),
nonce: "dcd98b7102dd2f0e8b11d0f600bfb0c093".to_owned(),
opaque: Some("5ccc069c403ebaf9f0171e9517f40e41".to_owned()),
algorithm: Algorithm::Md5,
qop_auth: true,
stale: false,
from_proxy: false,
};
let credentials = Credentials::new("Mufasa", "Circle Of Life");
let header = respond(
&challenge,
&credentials,
"GET",
"/dir/index.html",
1,
"0a4f113b",
);
assert!(
header.contains(r#"response="6629fae49393a05397450978507c4ef1""#),
"must match the digest RFC 2617 publishes: {header}"
);
assert!(header.contains("nc=00000001"));
assert!(header.contains("qop=auth"));
assert!(header.contains(r#"opaque="5ccc069c403ebaf9f0171e9517f40e41""#));
}
#[test]
fn rfc7616_sha256_example_matches_the_published_digest() {
let challenge = Challenge {
realm: "http-auth@example.org".to_owned(),
nonce: "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v".to_owned(),
opaque: Some("FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS".to_owned()),
algorithm: Algorithm::Sha256,
qop_auth: true,
stale: false,
from_proxy: false,
};
let header = respond(
&challenge,
&Credentials::new("Mufasa", "Circle of Life"),
"GET",
"/dir/index.html",
1,
"f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ",
);
assert!(
header.contains("753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1"),
"must match the digest RFC 7616 §3.9.1 publishes: {header}"
);
}
#[test]
fn rfc7616_sha512_256_example_matches_the_corrected_digest() {
let challenge = Challenge {
realm: "api@example.org".to_owned(),
nonce: "5TsQWLVdgBdmrQ0XsxbDODV+57QdFR34I9HAbC/RVvkK".to_owned(),
opaque: Some("HRPCssKJSGjCrkzDg8OhwpzCiGPChXYjwrI2QmXDnsOS".to_owned()),
algorithm: Algorithm::Sha512_256,
qop_auth: true,
stale: false,
from_proxy: false,
};
let header = respond(
&challenge,
&Credentials::new("J\u{e4}s\u{f8}n Doe", "Secret, or not?"),
"GET",
"/doe.json",
1,
"NTg6RKcb9boFIAS3KrFK9BGeh+iDa/sm6jUMp2wds69v",
);
assert!(
header.contains("3798d4131c277846293534c3edc11bd8a5e4cdcbff78b05db9d95eeb1cec68a5"),
"must match the digest errata 4897 publishes: {header}"
);
}
#[test]
fn sha512_256_is_the_fips_function_not_a_truncated_sha512() {
let hashed = Algorithm::Sha512_256.hash("J\u{e4}s\u{f8}n Doe:api@example.org");
assert_eq!(
hashed,
"793263caabb707a56211940d90411ea4a575adeccb7e360aeb624ed06ece9b0b"
);
let truncated_sha512 = {
use sha2::{Digest as _, Sha512};
hex(&Sha512::digest("J\u{e4}s\u{f8}n Doe:api@example.org".as_bytes())[..32])
};
assert_ne!(
hashed, truncated_sha512,
"SHA-512/256 must not be SHA-512 cut in half"
);
}
#[test]
fn the_strongest_offered_algorithm_is_chosen() {
let offer = |algorithm| Challenge {
realm: "example.com".to_owned(),
nonce: "n".to_owned(),
opaque: None,
algorithm,
qop_auth: true,
stale: false,
from_proxy: false,
};
let chosen = strongest(vec![
offer(Algorithm::Md5),
offer(Algorithm::Sha256),
offer(Algorithm::Sha512_256),
])
.expect("one is chosen");
assert_eq!(chosen.algorithm, Algorithm::Sha512_256);
let topmost = topmost_supported(vec![offer(Algorithm::Md5), offer(Algorithm::Sha512_256)])
.expect("one is chosen");
assert_eq!(topmost.algorithm, Algorithm::Md5);
}
#[test]
fn an_equal_ranking_tie_goes_to_the_server_order() {
let offer = |realm: &str| Challenge {
realm: realm.to_owned(),
nonce: "n".to_owned(),
opaque: None,
algorithm: Algorithm::Sha256,
qop_auth: true,
stale: false,
from_proxy: false,
};
let chosen = strongest(vec![offer("first"), offer("second")]).expect("one is chosen");
assert_eq!(chosen.realm, "first");
}
#[test]
fn the_modern_algorithms_round_trip_their_names() {
for algorithm in [
Algorithm::Sha512_256,
Algorithm::Sha512_256Sess,
Algorithm::Sha256,
Algorithm::Md5,
] {
assert_eq!(
Algorithm::parse(algorithm.as_str()),
Some(algorithm),
"{} did not survive a round trip",
algorithm.as_str()
);
}
assert_eq!(Algorithm::parse("sha-512-256"), Some(Algorithm::Sha512_256));
assert_eq!(
Algorithm::parse("SHA-512-256-SESS"),
Some(Algorithm::Sha512_256Sess)
);
assert_eq!(Algorithm::parse("SHA-3-512"), None);
}
#[test]
fn qop_changes_the_response() {
let credentials = Credentials::new("alice", "secret");
let base = Challenge {
realm: "example.com".to_owned(),
nonce: "abc123".to_owned(),
opaque: None,
algorithm: Algorithm::Md5,
qop_auth: true,
stale: false,
from_proxy: false,
};
let with_qop = respond(&base, &credentials, "REGISTER", "sip:example.com", 1, "c");
let without = respond(
&Challenge {
qop_auth: false,
..base
},
&credentials,
"REGISTER",
"sip:example.com",
1,
"c",
);
assert_ne!(
digest_of(&with_qop),
digest_of(&without),
"the two formulas must not coincide"
);
assert!(!without.contains("qop"), "no qop offered, none sent");
assert!(!without.contains("nc="), "and no nonce count either");
}
#[test]
fn the_session_variant_differs_from_the_plain_one() {
let credentials = Credentials::new("alice", "secret");
let plain = Challenge {
realm: "example.com".to_owned(),
nonce: "abc123".to_owned(),
opaque: None,
algorithm: Algorithm::Md5,
qop_auth: true,
stale: false,
from_proxy: false,
};
let session = Challenge {
algorithm: Algorithm::Md5Sess,
..plain.clone()
};
assert_ne!(
digest_of(&respond(&plain, &credentials, "REGISTER", "sip:x", 1, "cn")),
digest_of(&respond(
&session,
&credentials,
"REGISTER",
"sip:x",
1,
"cn"
)),
);
}
#[test]
fn the_nonce_count_is_eight_hex_digits_and_advances() {
let challenge = Challenge {
realm: "r".to_owned(),
nonce: "n".to_owned(),
opaque: None,
algorithm: Algorithm::Md5,
qop_auth: true,
stale: false,
from_proxy: false,
};
let credentials = Credentials::new("u", "p");
let first = respond(&challenge, &credentials, "REGISTER", "sip:x", 1, "cn");
let second = respond(&challenge, &credentials, "REGISTER", "sip:x", 2, "cn");
assert!(first.contains("nc=00000001"), "{first}");
assert!(second.contains("nc=00000002"), "{second}");
assert_ne!(
digest_of(&first),
digest_of(&second),
"the count is part of the digest, so it must change the response"
);
let large = respond(
&challenge,
&credentials,
"REGISTER",
"sip:x",
0x00ab_cdef,
"cn",
);
assert!(large.contains("nc=00abcdef"), "lowercase hex: {large}");
}
#[test]
fn a_challenge_parses_with_its_parameters_in_any_order() {
let challenge = Challenge::parse(
br#"Digest realm="example.com", qop="auth,auth-int", nonce="xyz", opaque="op", algorithm=SHA-256, stale=TRUE"#,
false,
)
.expect("parses");
assert_eq!(challenge.realm, "example.com");
assert_eq!(challenge.nonce, "xyz");
assert_eq!(challenge.opaque.as_deref(), Some("op"));
assert_eq!(challenge.algorithm, Algorithm::Sha256);
assert!(challenge.qop_auth, "auth is in the list");
assert!(challenge.stale, "stale is case-insensitive");
}
#[test]
fn an_auth_int_only_challenge_is_unsupported() {
assert!(
Challenge::parse(
br#"Digest realm="example.com", nonce="xyz", qop="auth-int""#,
false,
)
.is_none(),
"sipx does not implement request-body integrity and must not answer with the legacy formula"
);
}
#[test]
fn a_comma_inside_a_quoted_value_does_not_split_the_parameters() {
let challenge = Challenge::parse(
br#"Digest realm="a,b", nonce="n,m", qop="auth,auth-int", opaque="last""#,
false,
)
.expect("parses");
assert_eq!(challenge.realm, "a,b");
assert_eq!(challenge.nonce, "n,m");
assert_eq!(
challenge.opaque.as_deref(),
Some("last"),
"the parameter after the quoted list must survive"
);
}
#[test]
fn an_absent_algorithm_means_md5() {
let challenge = Challenge::parse(br#"Digest realm="r", nonce="n""#, false).expect("parses");
assert_eq!(challenge.algorithm, Algorithm::Md5);
assert!(!challenge.qop_auth, "no qop offered");
}
#[test]
fn a_non_digest_scheme_is_refused() {
assert!(Challenge::parse(b"Basic realm=\"example.com\"", false).is_none());
}
#[test]
fn an_unknown_algorithm_is_refused_rather_than_guessed() {
assert!(
Challenge::parse(br#"Digest realm="r", nonce="n", algorithm=MD9"#, false).is_none()
);
}
#[test]
fn a_proxy_challenge_is_answered_in_the_proxy_header() {
let direct = Challenge::parse(br#"Digest realm="r", nonce="n""#, false).expect("parses");
let proxy = Challenge::parse(br#"Digest realm="r", nonce="n""#, true).expect("parses");
assert_eq!(direct.response_header(), crate::HeaderName::Authorization);
assert_eq!(
proxy.response_header(),
crate::HeaderName::ProxyAuthorization
);
}
#[test]
fn the_strongest_offered_challenge_is_chosen() {
let weak = Challenge::parse(br#"Digest realm="r", nonce="n", algorithm=MD5"#, false)
.expect("parses");
let strong = Challenge::parse(br#"Digest realm="r", nonce="n", algorithm=SHA-256"#, false)
.expect("parses");
assert_eq!(
strongest(vec![weak.clone(), strong.clone()])
.expect("one of them")
.algorithm,
Algorithm::Sha256
);
assert_eq!(
strongest(vec![strong, weak])
.expect("one of them")
.algorithm,
Algorithm::Sha256,
"order of offer must not matter"
);
}
#[test]
fn quotes_in_a_value_are_escaped() {
let challenge = Challenge {
realm: r#"ex"ample"#.to_owned(),
nonce: "n".to_owned(),
opaque: None,
algorithm: Algorithm::Md5,
qop_auth: false,
stale: false,
from_proxy: false,
};
let header = respond(
&challenge,
&Credentials::new(r#"al"ice"#, "p"),
"REGISTER",
"sip:x",
1,
"cn",
);
assert!(header.contains(r#"username="al\"ice""#), "{header}");
assert!(header.contains(r#"realm="ex\"ample""#), "{header}");
}
#[test]
fn a_credentials_debug_report_never_contains_the_password() {
let rendered = format!("{:?}", Credentials::new("alice", "Circle Of Life"));
assert!(rendered.contains("alice"), "{rendered}");
assert!(rendered.contains("[REDACTED]"), "{rendered}");
assert!(!rendered.contains("Circle Of Life"), "{rendered}");
}
fn digest_of(header: &str) -> String {
header
.split("response=\"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.unwrap_or_default()
.to_owned()
}
}