use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use headers::authorization::{Basic, Bearer, Credentials as HeaderCredentials};
use headers::{Authorization, Header, HeaderValue};
use hmac::digest::Key;
use hmac::{Hmac, KeyInit, Mac};
use http_auth::{ChallengeParser, ChallengeRef};
use lru::LruCache;
use md5::{Digest as _, Md5};
use percent_encoding::percent_decode_str;
use sha2::Sha256;
use url::{Position, Url};
use crate::credentials::Credentials;
use crate::request::RequestContext;
use crate::signed_url::{self, SignedUrlKeySet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AuthResult {
Ok,
Unauthorized,
}
enum VerifierScheme {
Basic {
username: String,
password: String,
realm: String,
},
Digest {
username: String,
password: String,
realm: String,
nonces: DigestNonces,
},
Bearer {
token: String,
},
Forwarded {
user_header: String,
forwarded_for_header: Option<String>,
},
SignedUrl {
keys: SignedUrlKeySet,
},
}
pub struct Verifier {
scheme: VerifierScheme,
clock: Clock,
}
type Clock = Box<dyn Fn() -> SystemTime + Send + Sync>;
pub const DIGEST_NONCE_LIFETIME: Duration = Duration::from_secs(3600);
pub const DIGEST_NC_TRACK_CAP: usize = 65_536;
pub const NC_WINDOW: u32 = u64::BITS;
const NONCE_SECRET_LEN: usize = 32;
const _: () = assert!(NONCE_SECRET_LEN <= 64);
const NONCE_TIME_LEN: usize = 8;
const NONCE_SEQ_LEN: usize = 8;
const NONCE_MAC_LEN: usize = 32;
const NONCE_LEN: usize = NONCE_TIME_LEN + NONCE_SEQ_LEN + NONCE_MAC_LEN;
const NC_HEX_LEN: usize = 8;
struct DigestNonces {
secret: [u8; NONCE_SECRET_LEN],
seen: Mutex<NcTable>,
}
type PairKey = [u8; 32];
#[derive(Clone, Copy)]
struct NonceStamp {
time: u64,
seq: u64,
}
struct NcEntry {
stamp: NonceStamp,
highest: u32,
window: u64,
}
impl NcEntry {
fn accept(&mut self, nc: u32) -> bool {
if nc > self.highest {
let shift = nc - self.highest;
let old_highest = 1u64.checked_shl(shift - 1).unwrap_or(0);
self.window = self.window.checked_shl(shift).unwrap_or(0) | old_highest;
self.highest = nc;
return true;
}
let below = self.highest - nc;
if below == 0 || below > NC_WINDOW {
return false;
}
let bit = 1u64 << (below - 1);
if self.window & bit != 0 {
return false;
}
self.window |= bit;
true
}
}
struct NcTable {
entries: LruCache<PairKey, NcEntry>,
capacity: usize,
floor_seq: u64,
next_seq: u64,
latest_issue_time: u64,
}
enum DigestCheck {
Accept {
key: PairKey,
stamp: NonceStamp,
nc: u32,
},
Stale,
Reject,
}
fn pair_key(nonce: &str, cnonce: &str) -> PairKey {
let mut hash = Sha256::new();
hash.update(nonce.as_bytes());
hash.update(cnonce.as_bytes());
hash.finalize().into()
}
impl DigestNonces {
fn new() -> Self {
DigestNonces {
secret: rand::random(),
seen: Mutex::new(NcTable {
entries: LruCache::unbounded(),
capacity: DIGEST_NC_TRACK_CAP,
floor_seq: 0,
next_seq: 0,
latest_issue_time: 0,
}),
}
}
fn table(&self) -> std::sync::MutexGuard<'_, NcTable> {
self.seen.lock().unwrap_or_else(|e| e.into_inner())
}
fn set_capacity(&self, capacity: usize) {
self.table().capacity = capacity.max(1);
}
fn now(&self, clock_secs: u64) -> u64 {
clock_secs.max(self.table().latest_issue_time)
}
fn mac(&self, signed: &[u8]) -> Hmac<Sha256> {
let mut block = Key::<Hmac<Sha256>>::default();
block[..NONCE_SECRET_LEN].copy_from_slice(&self.secret);
let mut mac = <Hmac<Sha256> as KeyInit>::new(&block);
mac.update(signed);
mac
}
fn issue(&self, clock_secs: u64) -> String {
let (time, seq) = {
let mut table = self.table();
let time = clock_secs.max(table.latest_issue_time);
table.latest_issue_time = time;
let seq = table.next_seq;
table.next_seq = seq.wrapping_add(1);
(time, seq)
};
let mut raw = [0u8; NONCE_LEN];
raw[..NONCE_TIME_LEN].copy_from_slice(&time.to_be_bytes());
raw[NONCE_TIME_LEN..NONCE_TIME_LEN + NONCE_SEQ_LEN].copy_from_slice(&seq.to_be_bytes());
let tag = self
.mac(&raw[..NONCE_TIME_LEN + NONCE_SEQ_LEN])
.finalize()
.into_bytes();
raw[NONCE_TIME_LEN + NONCE_SEQ_LEN..].copy_from_slice(&tag);
hex::encode(raw)
}
fn issued_at(&self, nonce: &str) -> Option<NonceStamp> {
let raw = unhex::<NONCE_LEN>(nonce)?;
let (signed, tag) = raw.split_at(NONCE_TIME_LEN + NONCE_SEQ_LEN);
self.mac(signed).verify_slice(tag).ok()?;
let mut time = [0u8; NONCE_TIME_LEN];
time.copy_from_slice(&raw[..NONCE_TIME_LEN]);
let mut seq = [0u8; NONCE_SEQ_LEN];
seq.copy_from_slice(&raw[NONCE_TIME_LEN..NONCE_TIME_LEN + NONCE_SEQ_LEN]);
Some(NonceStamp {
time: u64::from_be_bytes(time),
seq: u64::from_be_bytes(seq),
})
}
fn is_expired(issued: u64, now_secs: u64) -> bool {
now_secs.saturating_sub(issued) >= DIGEST_NONCE_LIFETIME.as_secs()
}
fn record(&self, key: PairKey, stamp: NonceStamp, nc: u32, now_secs: u64) -> bool {
let mut guard = self.table();
let table = &mut *guard;
if let Some(entry) = table.entries.peek_mut(&key) {
if !entry.accept(nc) {
return false;
}
table.entries.promote(&key);
return true;
}
while table.entries.len() >= table.capacity {
let Some((_, dropped)) = table.entries.pop_lru() else {
break;
};
if !Self::is_expired(dropped.stamp.time, now_secs) {
table.floor_seq = table.floor_seq.max(dropped.stamp.seq.saturating_add(1));
}
}
if stamp.seq < table.floor_seq {
return false;
}
table.entries.put(
key,
NcEntry {
stamp,
highest: nc,
window: 0,
},
);
true
}
fn below_floor(&self, key: &PairKey, stamp: NonceStamp) -> bool {
let table = self.table();
stamp.seq < table.floor_seq && !table.entries.contains(key)
}
}
fn unix_secs(t: SystemTime) -> u64 {
t.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
fn default_clock() -> Clock {
Box::new(SystemTime::now)
}
impl Verifier {
pub fn new(credentials: Credentials, realm: impl Into<String>) -> Self {
let realm = sanitize_realm(&realm.into());
let scheme = match credentials {
Credentials::Basic { username, password } => VerifierScheme::Basic {
username,
password,
realm,
},
Credentials::Digest { username, password } => VerifierScheme::Digest {
username,
password,
realm,
nonces: DigestNonces::new(),
},
Credentials::Bearer { token } => VerifierScheme::Bearer { token },
};
Verifier {
scheme,
clock: default_clock(),
}
}
pub fn with_clock(mut self, clock: impl Fn() -> SystemTime + Send + Sync + 'static) -> Self {
self.clock = Box::new(clock);
self
}
fn now_secs(&self) -> u64 {
unix_secs((self.clock)())
}
pub fn with_digest_nc_capacity(self, capacity: usize) -> Self {
if let VerifierScheme::Digest { nonces, .. } = &self.scheme {
nonces.set_capacity(capacity);
}
self
}
pub fn forwarded(user_header: impl Into<String>, forwarded_for_header: Option<String>) -> Self {
Verifier {
scheme: VerifierScheme::Forwarded {
user_header: user_header.into(),
forwarded_for_header,
},
clock: default_clock(),
}
}
pub fn signed_url(keys: SignedUrlKeySet) -> Self {
Verifier {
scheme: VerifierScheme::SignedUrl { keys },
clock: default_clock(),
}
}
pub fn challenge(&self) -> String {
self.render_challenge(false)
}
pub fn challenge_for(&self, ctx: &RequestContext<'_>) -> String {
let stale = match &self.scheme {
VerifierScheme::Digest {
username,
password,
realm,
nonces,
} => ctx.header("authorization").is_some_and(|header| {
matches!(
check_digest(
header,
username,
password,
realm,
nonces,
ctx.method,
ctx.uri,
nonces.now(self.now_secs()),
),
DigestCheck::Stale
)
}),
_ => false,
};
self.render_challenge(stale)
}
fn render_challenge(&self, stale: bool) -> String {
match &self.scheme {
VerifierScheme::Basic { realm, .. } => format!("Basic realm={}", quoted(realm)),
VerifierScheme::Digest { realm, nonces, .. } => {
let nonce = nonces.issue(self.now_secs());
let stale = if stale { ", stale=true" } else { "" };
format!(
"Digest realm={}, nonce=\"{nonce}\", qop=\"auth\", algorithm=MD5{stale}",
quoted(realm)
)
}
VerifierScheme::Bearer { .. } => "Bearer".to_string(),
VerifierScheme::Forwarded { .. } => "Forwarded".to_string(),
VerifierScheme::SignedUrl { .. } => "SignedUrl".to_string(),
}
}
pub fn verify(&self, ctx: &RequestContext<'_>) -> AuthResult {
let ok = match &self.scheme {
VerifierScheme::Basic {
username, password, ..
} => ctx
.header("authorization")
.is_some_and(|header| verify_basic(header, username, password)),
VerifierScheme::Bearer { token } => ctx
.header("authorization")
.is_some_and(|header| verify_bearer(header, token)),
VerifierScheme::Digest {
username,
password,
realm,
nonces,
} => ctx.header("authorization").is_some_and(|header| {
let now = nonces.now(self.now_secs());
match check_digest(
header, username, password, realm, nonces, ctx.method, ctx.uri, now,
) {
DigestCheck::Accept { key, stamp, nc } => nonces.record(key, stamp, nc, now),
DigestCheck::Stale | DigestCheck::Reject => false,
}
}),
VerifierScheme::Forwarded { user_header, .. } => verify_forwarded(ctx, user_header),
VerifierScheme::SignedUrl { keys } => signed_url::verify(ctx, keys),
};
if ok {
AuthResult::Ok
} else {
AuthResult::Unauthorized
}
}
pub fn forwarded_for<'a>(&self, ctx: &RequestContext<'a>) -> Option<&'a str> {
match &self.scheme {
VerifierScheme::Forwarded {
forwarded_for_header: Some(header_name),
..
} => ctx.header(header_name),
_ => None,
}
}
}
impl core::fmt::Debug for Verifier {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let scheme = match &self.scheme {
VerifierScheme::Basic { .. } => "Basic",
VerifierScheme::Digest { .. } => "Digest",
VerifierScheme::Bearer { .. } => "Bearer",
VerifierScheme::Forwarded { .. } => "Forwarded",
VerifierScheme::SignedUrl { .. } => "SignedUrl",
};
f.debug_struct("Verifier")
.field("scheme", &scheme)
.finish_non_exhaustive()
}
}
fn sanitize_realm(realm: &str) -> String {
realm
.chars()
.filter(|c| !c.is_ascii_control() || *c == '\t')
.collect()
}
fn quoted(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for c in value
.chars()
.filter(|c| !c.is_ascii_control() || *c == '\t')
{
if matches!(c, '"' | '\\') {
out.push('\\');
}
out.push(c);
}
out.push('"');
out
}
fn is_ext_value_syntax(value: &str) -> bool {
let b = value.as_bytes();
let mut i = 0;
while i < b.len() {
match b[i] {
b'%' => {
if !(i + 2 < b.len()
&& b[i + 1].is_ascii_hexdigit()
&& b[i + 2].is_ascii_hexdigit())
{
return false;
}
i += 3;
}
c if c.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&c) => i += 1,
_ => return false,
}
}
true
}
fn decode_ext_value(v: &str) -> Option<String> {
let (charset, rest) = v.split_once('\'')?;
let (_language, value) = rest.split_once('\'')?;
if !charset.eq_ignore_ascii_case("UTF-8") || !is_ext_value_syntax(value) {
return None;
}
percent_decode_str(value)
.decode_utf8()
.ok()
.map(std::borrow::Cow::into_owned)
}
fn parse_digest_fields(header: &str) -> Option<HashMap<String, String>> {
let mut parser = ChallengeParser::new(header);
let challenge: ChallengeRef<'_> = parser.next()?.ok()?;
if parser.next().is_some()
|| !challenge.scheme.eq_ignore_ascii_case("Digest")
|| challenge.params.len() > MAX_DIGEST_FIELDS
{
return None;
}
let mut fields = HashMap::with_capacity(challenge.params.len());
for (name, value) in &challenge.params {
if fields
.insert(name.to_ascii_lowercase(), value.to_unescaped())
.is_some()
{
return None;
}
}
Some(fields)
}
fn decode<C: HeaderCredentials>(header: &str) -> Option<Authorization<C>> {
let value = HeaderValue::from_str(header).ok()?;
Authorization::<C>::decode(&mut std::iter::once(&value)).ok()
}
fn verify_basic(header: &str, username: &str, password: &str) -> bool {
let Some(auth) = decode::<Basic>(header) else {
return false;
};
let user_ok = constant_time_eq(auth.username().as_bytes(), username.as_bytes());
let pass_ok = constant_time_eq(auth.password().as_bytes(), password.as_bytes());
user_ok & pass_ok
}
fn verify_bearer(header: &str, token: &str) -> bool {
let Some(auth) = decode::<Bearer>(header) else {
return false;
};
constant_time_eq(auth.token().trim().as_bytes(), token.trim().as_bytes())
}
const MAX_DIGEST_FIELDS: usize = 64;
#[allow(clippy::too_many_arguments)]
fn check_digest(
header: &str,
username: &str,
password: &str,
realm: &str,
nonces: &DigestNonces,
method: &str,
request_uri: &str,
now_secs: u64,
) -> DigestCheck {
let Some(fields) = parse_digest_fields(header) else {
return DigestCheck::Reject;
};
let get = |k: &str| fields.get(k).map(String::as_str).unwrap_or_default();
if get("userhash").eq_ignore_ascii_case("true") {
return DigestCheck::Reject;
}
let client_user = match (fields.get("username"), fields.get("username*")) {
(Some(_), Some(_)) => return DigestCheck::Reject, (Some(u), None) => u.clone(),
(None, Some(ext)) => match decode_ext_value(ext) {
Some(u) => u,
None => return DigestCheck::Reject,
},
(None, None) => return DigestCheck::Reject,
};
if client_user != username || get("realm") != realm {
return DigestCheck::Reject;
}
let nonce = get("nonce");
let Some(stamp) = nonces.issued_at(nonce) else {
return DigestCheck::Reject;
};
let client_uri = get("uri");
if !digest_uri_matches(client_uri, request_uri) {
return DigestCheck::Reject;
}
let nc_text = get("nc");
let cnonce = get("cnonce");
let qop = get("qop");
let client_response = get("response");
if cnonce.is_empty() || client_response.is_empty() {
return DigestCheck::Reject;
}
if qop != "auth" {
return DigestCheck::Reject;
}
let algorithm = get("algorithm");
if !algorithm.is_empty() && !algorithm.eq_ignore_ascii_case("MD5") {
return DigestCheck::Reject;
}
let Some(nc) = parse_nc(nc_text) else {
return DigestCheck::Reject;
};
let ha1 = md5_hex(format!("{username}:{realm}:{password}"));
let ha2 = md5_hex(format!("{method}:{client_uri}"));
let expected_response = md5_hex(format!("{ha1}:{nonce}:{nc_text}:{cnonce}:{qop}:{ha2}"));
if !constant_time_eq(expected_response.as_bytes(), client_response.as_bytes()) {
return DigestCheck::Reject;
}
let key = pair_key(nonce, cnonce);
if DigestNonces::is_expired(stamp.time, now_secs) || nonces.below_floor(&key, stamp) {
return DigestCheck::Stale;
}
DigestCheck::Accept { key, stamp, nc }
}
fn parse_nc(nc: &str) -> Option<u32> {
if nc.len() != NC_HEX_LEN || !nc.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
u32::from_str_radix(nc, 16).ok()
}
fn digest_uri_matches(client_uri: &str, request_uri: &str) -> bool {
if client_uri == request_uri {
return true;
}
let Ok(parsed) = Url::parse(client_uri) else {
return false;
};
parsed.as_str() == client_uri && &parsed[Position::BeforePath..] == request_uri
}
fn verify_forwarded(ctx: &RequestContext<'_>, user_header: &str) -> bool {
ctx.header(user_header)
.is_some_and(|v| !v.trim().is_empty())
}
fn md5_hex(input: String) -> String {
let mut hasher = Md5::new();
hasher.update(input.as_bytes());
hex::encode(hasher.finalize())
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter()
.zip(b.iter())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
fn unhex<const N: usize>(text: &str) -> Option<[u8; N]> {
let mut out = [0u8; N];
hex::decode_to_slice(text, &mut out).ok()?; Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Credentials, RequestContext, respond};
const REALM: &str = "cameras";
#[test]
fn recently_used_pair_survives_eviction() {
let nonces = DigestNonces::new();
nonces.set_capacity(2);
let stamp = |seq| NonceStamp { time: 100, seq };
let (a, b, c) = (
pair_key("n1", "c"),
pair_key("n2", "c"),
pair_key("n3", "c"),
);
assert!(nonces.record(a, stamp(1), 1, 100));
assert!(nonces.record(b, stamp(2), 1, 100));
assert!(nonces.record(a, stamp(1), 2, 100));
assert!(nonces.record(c, stamp(3), 1, 100));
assert!(
!nonces.record(a, stamp(1), 2, 100),
"a still tracked: nc 2 is a replay"
);
assert_eq!(nonces.table().entries.len(), 2);
assert!(!nonces.record(b, stamp(2), 1, 100));
}
#[test]
fn nonce_is_96_lowercase_hex_and_round_trips() {
let nonces = DigestNonces::new();
let nonce = nonces.issue(1_000);
assert_eq!(nonce.len(), NONCE_LEN * 2);
assert!(
nonce
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
);
let stamp = nonces.issued_at(&nonce).expect("our own nonce verifies");
assert_eq!(stamp.time, 1_000);
assert!(
nonces.issued_at(&nonce.to_uppercase()).is_some(),
"hex is case-insensitive on read"
);
assert!(nonces.issued_at(&nonce[..nonce.len() - 2]).is_none());
assert!(
nonces
.issued_at(&format!("{}zz", &nonce[..nonce.len() - 2]))
.is_none()
);
}
#[test]
fn nonce_mac_matches_variable_length_keying() {
let nonces = DigestNonces::new();
let signed = b"issue-time||issue-seq";
let reference = {
let mut m = <Hmac<Sha256> as KeyInit>::new_from_slice(&nonces.secret).unwrap();
m.update(signed);
m.finalize().into_bytes()
};
assert_eq!(nonces.mac(signed).finalize().into_bytes(), reference);
}
fn verify_auth(
v: &Verifier,
authorization: Option<&str>,
method: &str,
uri: &str,
) -> AuthResult {
let auth_header = authorization.map(|h| [("authorization", h)]);
let headers: &[(&str, &str)] = match &auth_header {
Some(arr) => arr,
None => &[],
};
let ctx = RequestContext::new(method, uri).with_headers(headers);
v.verify(&ctx)
}
#[test]
fn basic_challenge_names_the_realm() {
let v = Verifier::new(
Credentials::Basic {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
assert_eq!(v.challenge(), "Basic realm=\"cameras\"");
}
#[test]
fn digest_challenge_carries_realm_nonce_qop_algorithm() {
let v = Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
let challenge = v.challenge();
assert!(challenge.starts_with("Digest "), "got: {challenge}");
for needle in [
"realm=\"cameras\"",
"nonce=",
"qop=\"auth\"",
"algorithm=MD5",
] {
assert!(
challenge.contains(needle),
"missing {needle} in {challenge}"
);
}
}
#[test]
fn bearer_challenge_is_bare_scheme_name() {
let v = Verifier::new(Credentials::bearer("tok"), REALM);
assert_eq!(v.challenge(), "Bearer");
}
#[test]
fn digest_challenges_issue_distinct_nonces() {
let v = Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
assert_ne!(v.challenge(), v.challenge());
}
#[test]
fn basic_respond_to_challenge_verifies_ok() {
let v = Verifier::new(
Credentials::Basic {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
let header = respond(
&v.challenge(),
&RequestContext::new("GET", "/stream"),
Credentials::new("admin", "12345"),
)
.unwrap();
assert_eq!(
verify_auth(&v, Some(&header), "GET", "/stream"),
AuthResult::Ok
);
}
#[test]
fn digest_respond_to_challenge_verifies_ok() {
let v = Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
let ctx = RequestContext::new("DESCRIBE", "rtsp://cam/live");
let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "12345")).unwrap();
assert_eq!(
verify_auth(&v, Some(&header), "DESCRIBE", "rtsp://cam/live"),
AuthResult::Ok
);
}
#[test]
fn bearer_respond_to_challenge_verifies_ok() {
let v = Verifier::new(Credentials::bearer("mytoken123"), REALM);
let header = respond(
&v.challenge(),
&RequestContext::new("GET", "/stream"),
Credentials::bearer("mytoken123"),
)
.unwrap();
assert_eq!(
verify_auth(&v, Some(&header), "GET", "/stream"),
AuthResult::Ok
);
}
#[test]
fn basic_wrong_password_is_unauthorized() {
let v = Verifier::new(
Credentials::Basic {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
let header = respond(
&v.challenge(),
&RequestContext::new("GET", "/stream"),
Credentials::new("admin", "WRONG"),
)
.unwrap();
assert_eq!(
verify_auth(&v, Some(&header), "GET", "/stream"),
AuthResult::Unauthorized
);
}
#[test]
fn digest_wrong_password_is_unauthorized() {
let v = Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
let ctx = RequestContext::new("DESCRIBE", "rtsp://cam/live");
let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "WRONG")).unwrap();
assert_eq!(
verify_auth(&v, Some(&header), "DESCRIBE", "rtsp://cam/live"),
AuthResult::Unauthorized
);
}
#[test]
fn digest_mismatched_request_uri_is_unauthorized() {
let v = Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
let ctx = RequestContext::new("DESCRIBE", "rtsp://cam/live");
let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "12345")).unwrap();
assert_eq!(
verify_auth(&v, Some(&header), "DESCRIBE", "rtsp://cam/OTHER"),
AuthResult::Unauthorized
);
}
#[test]
fn digest_accepts_absolute_form_client_uri_matching_request_path() {
let v = Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
let client_ctx = RequestContext::new("GET", "http://cam.local/stream/media.m3u8");
let header = respond(
&v.challenge(),
&client_ctx,
Credentials::new("admin", "12345"),
)
.unwrap();
assert!(
header.contains("uri=\"http://cam.local/stream/media.m3u8\""),
"expected the client to hash the absolute-form uri, got: {header}"
);
assert_eq!(
verify_auth(&v, Some(&header), "GET", "/stream/media.m3u8"),
AuthResult::Ok
);
}
#[test]
fn digest_rejects_absolute_form_uri_with_wrong_path() {
let v = Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
let client_ctx = RequestContext::new("GET", "http://cam.local/other/path");
let header = respond(
&v.challenge(),
&client_ctx,
Credentials::new("admin", "12345"),
)
.unwrap();
assert_eq!(
verify_auth(&v, Some(&header), "GET", "/stream/media.m3u8"),
AuthResult::Unauthorized
);
}
#[test]
fn digest_rejects_origin_form_uri_with_wrong_path() {
let v = Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
let client_ctx = RequestContext::new("GET", "/other/path");
let header = respond(
&v.challenge(),
&client_ctx,
Credentials::new("admin", "12345"),
)
.unwrap();
assert_eq!(
verify_auth(&v, Some(&header), "GET", "/stream/media.m3u8"),
AuthResult::Unauthorized
);
}
#[test]
fn digest_uri_matches_unit_cases() {
assert!(digest_uri_matches("/a/b", "/a/b"));
assert!(digest_uri_matches("http://host/a/b", "/a/b"));
assert!(digest_uri_matches("https://host:8080/a/b?q=1", "/a/b?q=1"));
assert!(!digest_uri_matches("/a/c", "/a/b"));
assert!(!digest_uri_matches("http://host/a/c", "/a/b"));
assert!(!digest_uri_matches("http://host/x/a/b", "/a/b"));
assert!(!digest_uri_matches("/a/b/extra", "/a/b"));
assert!(!digest_uri_matches("http://host", "/a/b"));
}
#[test]
fn digest_uri_match_is_not_loosened_by_url_normalisation() {
assert!(!digest_uri_matches("http://h/a/../b", "/b"));
assert!(!digest_uri_matches("http://h/%2e%2e/b", "/b"));
assert!(!digest_uri_matches("http://h/./b", "/b"));
assert!(!digest_uri_matches("HTTP://H/b", "/b"));
assert!(!digest_uri_matches("http://h:80/b", "/b"));
assert!(!digest_uri_matches("http://h", "/"));
assert!(!digest_uri_matches("http://h/b#frag", "/b"));
assert!(!digest_uri_matches("http://[::1/b", "/b"));
assert!(digest_uri_matches("http://h/b?x=1&y=2", "/b?x=1&y=2"));
assert!(digest_uri_matches("http://[2001:db8::1]:8080/b", "/b"));
assert!(digest_uri_matches("rtsp://cam:554/live/ch1", "/live/ch1"));
assert!(digest_uri_matches(
"rtsp://cam/live/ch1",
"rtsp://cam/live/ch1"
));
}
#[test]
fn bearer_wrong_token_is_unauthorized() {
let v = Verifier::new(Credentials::bearer("right-token"), REALM);
let header = respond(
&v.challenge(),
&RequestContext::new("GET", "/stream"),
Credentials::bearer("wrong-token"),
)
.unwrap();
assert_eq!(
verify_auth(&v, Some(&header), "GET", "/stream"),
AuthResult::Unauthorized
);
}
#[test]
fn missing_authorization_header_is_unauthorized() {
let v = Verifier::new(Credentials::bearer("tok"), REALM);
assert_eq!(
verify_auth(&v, None, "GET", "/stream"),
AuthResult::Unauthorized
);
}
#[test]
fn wrong_scheme_header_is_unauthorized() {
let v = Verifier::new(
Credentials::Basic {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
assert_eq!(
verify_auth(&v, Some("Bearer sometoken"), "GET", "/stream"),
AuthResult::Unauthorized
);
}
#[test]
fn forwarded_challenge_is_bare_scheme_name() {
let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
assert_eq!(v.challenge(), "Forwarded");
}
#[test]
fn forwarded_with_user_header_present_is_ok() {
let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
let headers: &[(&str, &str)] = &[("X-Forwarded-User", "alice")];
let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
assert_eq!(v.verify(&ctx), AuthResult::Ok);
}
#[test]
fn forwarded_without_user_header_is_unauthorized() {
let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
let ctx = RequestContext::new("GET", "/stream");
assert_eq!(v.verify(&ctx), AuthResult::Unauthorized);
}
#[test]
fn forwarded_with_empty_user_header_is_unauthorized() {
let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
let headers: &[(&str, &str)] = &[("X-Forwarded-User", "")];
let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
assert_eq!(v.verify(&ctx), AuthResult::Unauthorized);
}
#[test]
fn forwarded_user_header_lookup_is_case_insensitive() {
let v = Verifier::forwarded("X-Forwarded-User", None);
let headers: &[(&str, &str)] = &[("x-forwarded-user", "alice")];
let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
assert_eq!(v.verify(&ctx), AuthResult::Ok);
}
#[test]
fn forwarded_for_reads_configured_header() {
let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
let headers: &[(&str, &str)] = &[
("X-Forwarded-User", "alice"),
("X-Forwarded-For", "203.0.113.7"),
];
let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
assert_eq!(v.forwarded_for(&ctx), Some("203.0.113.7"));
}
#[test]
fn forwarded_for_is_none_when_not_configured() {
let v = Verifier::forwarded("X-Forwarded-User", None);
let headers: &[(&str, &str)] = &[("X-Forwarded-For", "203.0.113.7")];
let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
assert_eq!(v.forwarded_for(&ctx), None);
}
#[test]
fn forwarded_for_is_none_for_non_forwarded_verifier() {
let v = Verifier::new(Credentials::bearer("tok"), REALM);
let headers: &[(&str, &str)] = &[("X-Forwarded-For", "203.0.113.7")];
let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
assert_eq!(v.forwarded_for(&ctx), None);
}
#[test]
fn forwarded_debug_names_scheme() {
let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
let debug = format!("{v:?}");
assert!(debug.contains("Forwarded"), "debug: {debug}");
}
#[test]
fn signed_url_challenge_is_bare_scheme_name() {
let keys = SignedUrlKeySet::new([("k".to_string(), vec![0u8; 32])]).unwrap();
let v = Verifier::signed_url(keys);
assert_eq!(v.challenge(), "SignedUrl");
}
#[test]
fn signed_url_debug_names_scheme_and_never_leaks_secret() {
let secret = b"super-secret-32-byte-hmac-key!!!".to_vec();
let keys = SignedUrlKeySet::new([("k".to_string(), secret.clone())]).unwrap();
let v = Verifier::signed_url(keys);
let debug = format!("{v:?}");
assert!(debug.contains("SignedUrl"), "debug: {debug}");
assert!(
!debug.contains(std::str::from_utf8(&secret).unwrap()),
"debug: {debug}"
);
}
#[test]
fn constant_time_eq_matches_naive_equality() {
assert!(constant_time_eq(b"same", b"same"));
assert!(!constant_time_eq(b"same", b"diff"));
assert!(!constant_time_eq(b"short", b"longer-string"));
assert!(constant_time_eq(b"", b""));
}
#[test]
fn oversized_digest_header_is_rejected_not_parsed() {
let v = Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
let mut huge = String::from("Digest ");
for i in 0..(MAX_DIGEST_FIELDS + 1) {
if i > 0 {
huge.push(',');
}
huge.push_str(&format!("k{i}=\"v{i}\""));
}
assert_eq!(
verify_auth(&v, Some(&huge), "DESCRIBE", "rtsp://cam/live"),
AuthResult::Unauthorized,
"oversized Digest header must not be accepted"
);
}
fn digest_verifier_at(now: std::sync::Arc<std::sync::atomic::AtomicU64>) -> Verifier {
Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "12345".into(),
},
REALM,
)
.with_clock(move || {
UNIX_EPOCH + Duration::from_secs(now.load(std::sync::atomic::Ordering::SeqCst))
})
}
fn digest_header(challenge: &str, uri: &str) -> String {
respond(
challenge,
&RequestContext::new("GET", uri),
Credentials::new("admin", "12345"),
)
.unwrap()
}
fn with_nc(header: &str, nc: u32, uri: &str) -> String {
let field = |name: &str| {
let start = header.find(&format!("{name}=")).unwrap() + name.len() + 1;
let rest = &header[start..];
let rest = rest.trim_start_matches('"');
let end = rest.find(['"', ',']).unwrap_or(rest.len());
rest[..end].to_string()
};
let (nonce, cnonce, old_nc, old_resp) = (
field("nonce"),
field("cnonce"),
field("nc"),
field("response"),
);
let nc = format!("{nc:08x}");
let ha1 = md5_hex(format!("admin:{REALM}:12345"));
let ha2 = md5_hex(format!("GET:{uri}"));
let resp = md5_hex(format!("{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}"));
header
.replace(&format!("nc={old_nc}"), &format!("nc={nc}"))
.replace(&old_resp, &resp)
}
#[test]
fn basic_scheme_is_matched_case_insensitively() {
let v = Verifier::new(
Credentials::Basic {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
let header = respond(
&v.challenge(),
&RequestContext::new("GET", "/stream"),
Credentials::new("admin", "12345"),
)
.unwrap();
assert!(header.starts_with("Basic "));
let lower = header.replacen("Basic ", "basic ", 1);
assert_eq!(
verify_auth(&v, Some(&lower), "GET", "/stream"),
AuthResult::Ok,
"lower-case scheme token must verify identically to the canonical case"
);
}
#[test]
fn bearer_scheme_is_matched_case_insensitively() {
let v = Verifier::new(Credentials::bearer("mytoken123"), REALM);
let header = respond(
&v.challenge(),
&RequestContext::new("GET", "/stream"),
Credentials::bearer("mytoken123"),
)
.unwrap();
assert!(header.starts_with("Bearer "));
let mixed = header.replacen("Bearer ", "BEARER ", 1);
assert_eq!(
verify_auth(&v, Some(&mixed), "GET", "/stream"),
AuthResult::Ok,
"mixed-case scheme token must verify identically to the canonical case"
);
}
#[test]
fn digest_scheme_is_matched_case_insensitively() {
let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
let v = digest_verifier_at(now);
let uri = "/stream/index.m3u8";
let header = digest_header(&v.challenge(), uri);
assert!(header.starts_with("Digest "));
let lower = header.replacen("Digest ", "digest ", 1);
assert_eq!(
verify_auth(&v, Some(&lower), "GET", uri),
AuthResult::Ok,
"lower-case scheme token must verify identically to the canonical case"
);
}
#[test]
fn digest_field_parser_respects_quoted_commas() {
let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
let v = digest_verifier_at(now);
let uri = "/stream/a,b/index.m3u8";
let header = digest_header(&v.challenge(), uri);
assert!(header.contains(&format!("uri=\"{uri}\"")));
assert_eq!(
verify_auth(&v, Some(&header), "GET", uri),
AuthResult::Ok,
"a comma inside the quoted uri field must not fragment the field list"
);
}
#[test]
fn digest_rejects_unsupported_algorithm_label() {
let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
let v = digest_verifier_at(now);
let uri = "/stream/index.m3u8";
let header = digest_header(&v.challenge(), uri);
assert!(header.contains("algorithm=MD5"));
let relabeled = header.replacen("algorithm=MD5", "algorithm=SHA-256", 1);
assert_eq!(
verify_auth(&v, Some(&relabeled), "GET", uri),
AuthResult::Unauthorized,
"a header claiming an unimplemented algorithm must not verify"
);
}
#[test]
fn digest_rejects_non_auth_qop() {
let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
let v = digest_verifier_at(now);
let uri = "/stream/index.m3u8";
let header = digest_header(&v.challenge(), uri);
let field = |name: &str| {
let start = header.find(&format!("{name}=")).unwrap() + name.len() + 1;
let rest = &header[start..];
let rest = rest.trim_start_matches('"');
let end = rest.find(['"', ',']).unwrap_or(rest.len());
rest[..end].to_string()
};
let (nonce, cnonce, nc, response) = (
field("nonce"),
field("cnonce"),
field("nc"),
field("response"),
);
let ha1 = md5_hex(format!("admin:{REALM}:12345"));
let ha2 = md5_hex(format!("GET:{uri}"));
let recomputed = md5_hex(format!("{ha1}:{nonce}:{nc}:{cnonce}:auth-int:{ha2}"));
let relabeled = header
.replacen("qop=auth", "qop=auth-int", 1)
.replace(&response, &recomputed);
assert_eq!(
verify_auth(&v, Some(&relabeled), "GET", uri),
AuthResult::Unauthorized,
"a header claiming qop=auth-int must not verify even when response \
was (mis)computed with the qop=auth formula"
);
}
#[test]
fn digest_increasing_nc_is_accepted_and_repeated_nc_rejected() {
let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
let v = digest_verifier_at(now);
let uri = "/stream/index.m3u8";
let first = digest_header(&v.challenge(), uri);
assert_eq!(verify_auth(&v, Some(&first), "GET", uri), AuthResult::Ok);
let second = with_nc(&first, 2, uri);
assert_eq!(verify_auth(&v, Some(&second), "GET", uri), AuthResult::Ok);
let third = with_nc(&first, 3, uri);
assert_eq!(verify_auth(&v, Some(&third), "GET", uri), AuthResult::Ok);
assert_eq!(
verify_auth(&v, Some(&second), "GET", uri),
AuthResult::Unauthorized,
"an nc already accepted must be rejected"
);
}
#[test]
fn digest_expired_nonce_is_rejected_with_stale_challenge() {
let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
let v = digest_verifier_at(now.clone());
let uri = "/stream/index.m3u8";
let header = digest_header(&v.challenge(), uri);
now.fetch_add(
DIGEST_NONCE_LIFETIME.as_secs(),
std::sync::atomic::Ordering::SeqCst,
);
assert_eq!(
verify_auth(&v, Some(&header), "GET", uri),
AuthResult::Unauthorized
);
let headers = [("authorization", header.as_str())];
let ctx = RequestContext::new("GET", uri).with_headers(&headers);
let challenge = v.challenge_for(&ctx);
assert!(challenge.ends_with(", stale=true"), "got: {challenge}");
let retry = digest_header(&challenge, uri);
assert_eq!(verify_auth(&v, Some(&retry), "GET", uri), AuthResult::Ok);
let wrong = respond(
&v.challenge(),
&RequestContext::new("GET", uri),
Credentials::new("admin", "WRONG"),
)
.unwrap();
now.fetch_add(
DIGEST_NONCE_LIFETIME.as_secs(),
std::sync::atomic::Ordering::SeqCst,
);
let headers = [("authorization", wrong.as_str())];
let ctx = RequestContext::new("GET", uri).with_headers(&headers);
assert!(!v.challenge_for(&ctx).contains("stale"));
}
#[test]
fn digest_nonce_with_wrong_mac_is_rejected() {
let v = Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
let uri = "/stream/index.m3u8";
let challenge = v.challenge();
let start = challenge.find("nonce=\"").unwrap() + "nonce=\"".len();
let nonce = &challenge[start..start + NONCE_LEN * 2];
let last = nonce.as_bytes()[nonce.len() - 1];
let flipped = if last == b'0' { '1' } else { '0' };
let forged_nonce = format!("{}{flipped}", &nonce[..nonce.len() - 1]);
let forged = challenge.replace(nonce, &forged_nonce);
let header = digest_header(&forged, uri);
assert!(header.contains(&forged_nonce));
assert_eq!(
verify_auth(&v, Some(&header), "GET", uri),
AuthResult::Unauthorized
);
let other = Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
let header = digest_header(&other.challenge(), uri);
assert_eq!(
verify_auth(&v, Some(&header), "GET", uri),
AuthResult::Unauthorized
);
}
#[test]
fn digest_out_of_order_nc_within_window_is_accepted_once() {
let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
let v = digest_verifier_at(now);
let uri = "/stream/part.m4s";
let first = digest_header(&v.challenge(), uri);
assert_eq!(verify_auth(&v, Some(&first), "GET", uri), AuthResult::Ok);
let nc = |n| with_nc(&first, n, uri);
assert_eq!(verify_auth(&v, Some(&nc(6)), "GET", uri), AuthResult::Ok);
assert_eq!(verify_auth(&v, Some(&nc(5)), "GET", uri), AuthResult::Ok);
assert_eq!(
verify_auth(&v, Some(&nc(5)), "GET", uri),
AuthResult::Unauthorized,
"nc 5 a second time is a replay"
);
assert_eq!(
verify_auth(&v, Some(&nc(6)), "GET", uri),
AuthResult::Unauthorized
);
let top = 200;
assert_eq!(verify_auth(&v, Some(&nc(top)), "GET", uri), AuthResult::Ok);
assert_eq!(
verify_auth(&v, Some(&nc(top - NC_WINDOW - 1)), "GET", uri),
AuthResult::Unauthorized,
"an nc 65 below the highest is outside the window"
);
assert_eq!(
verify_auth(&v, Some(&nc(top - NC_WINDOW)), "GET", uri),
AuthResult::Ok
);
}
#[test]
fn digest_nc_table_evicts_least_recently_used_pair() {
let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1_000_000));
let v = digest_verifier_at(now).with_digest_nc_capacity(4);
let uri = "/s";
let headers: Vec<String> = (0..4).map(|_| digest_header(&v.challenge(), uri)).collect();
for h in &headers {
assert_eq!(verify_auth(&v, Some(h), "GET", uri), AuthResult::Ok);
}
assert_eq!(
verify_auth(&v, Some(&with_nc(&headers[0], 2, uri)), "GET", uri),
AuthResult::Ok
);
let newcomer = digest_header(&v.challenge(), uri);
assert_eq!(verify_auth(&v, Some(&newcomer), "GET", uri), AuthResult::Ok);
let VerifierScheme::Digest { nonces, .. } = &v.scheme else {
unreachable!()
};
assert_eq!(nonces.table().entries.len(), 4);
assert_eq!(
verify_auth(&v, Some(&with_nc(&headers[0], 2, uri)), "GET", uri),
AuthResult::Unauthorized
);
assert_eq!(
verify_auth(&v, Some(&with_nc(&headers[0], 3, uri)), "GET", uri),
AuthResult::Ok
);
let replay = with_nc(&headers[1], 2, uri);
assert_eq!(
verify_auth(&v, Some(&replay), "GET", uri),
AuthResult::Unauthorized
);
let hdrs = [("authorization", replay.as_str())];
let ctx = RequestContext::new("GET", uri).with_headers(&hdrs);
let challenge = v.challenge_for(&ctx);
assert!(challenge.ends_with(", stale=true"), "got: {challenge}");
let retry = digest_header(&challenge, uri);
assert_eq!(verify_auth(&v, Some(&retry), "GET", uri), AuthResult::Ok);
}
#[test]
fn digest_clock_stepping_back_does_not_backdate_nonces() {
let start = 1_000_000;
let now = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(start));
let v = digest_verifier_at(now.clone());
let uri = "/s";
let before = digest_header(&v.challenge(), uri);
now.store(
start - 2 * DIGEST_NONCE_LIFETIME.as_secs(),
std::sync::atomic::Ordering::SeqCst,
);
let during = digest_header(&v.challenge(), uri);
assert_eq!(verify_auth(&v, Some(&during), "GET", uri), AuthResult::Ok);
assert_eq!(verify_auth(&v, Some(&before), "GET", uri), AuthResult::Ok);
now.store(
start + DIGEST_NONCE_LIFETIME.as_secs() - 1,
std::sync::atomic::Ordering::SeqCst,
);
let again = with_nc(&during, 2, uri);
assert_eq!(verify_auth(&v, Some(&again), "GET", uri), AuthResult::Ok);
}
#[test]
fn digest_replayed_request_is_rejected() {
let v = Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
let ctx = RequestContext::new("GET", "/stream/index.m3u8");
let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "12345")).unwrap();
assert_eq!(
verify_auth(&v, Some(&header), "GET", "/stream/index.m3u8"),
AuthResult::Ok
);
assert_eq!(
verify_auth(&v, Some(&header), "GET", "/stream/index.m3u8"),
AuthResult::Unauthorized,
"same nonce + nc + cnonce must not verify twice"
);
}
#[test]
fn debug_never_leaks_password_or_token() {
let v = Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "supersecret".into(),
},
REALM,
);
let debug = format!("{v:?}");
assert!(!debug.contains("supersecret"), "debug: {debug}");
let v = Verifier::new(Credentials::bearer("topsecrettoken"), REALM);
let debug = format!("{v:?}");
assert!(!debug.contains("topsecrettoken"), "debug: {debug}");
}
#[test]
fn digest_md5_matches_rfc2617_worked_example() {
use md5::{Digest as _, Md5};
let ha1 = hex_of(Md5::digest(b"Mufasa:testrealm@host.com:Circle Of Life"));
let ha2 = hex_of(Md5::digest(b"GET:/dir/index.html"));
let resp = hex_of(Md5::digest(
format!("{ha1}:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:0a4f113b:auth:{ha2}")
.as_bytes(),
));
assert_eq!(resp, "6629fae49393a05397450978507c4ef1");
}
fn hex_of(bytes: impl AsRef<[u8]>) -> String {
bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect()
}
#[test]
fn basic_credentials_base64_matches_rfc7617_example() {
use base64::Engine as _;
let enc = base64::engine::general_purpose::STANDARD.encode(b"Aladdin:open sesame");
assert_eq!(enc, "QWxhZGRpbjpvcGVuIHNlc2FtZQ==");
let dec = base64::engine::general_purpose::STANDARD
.decode("QWxhZGRpbjpvcGVuIHNlc2FtZQ==")
.unwrap();
assert_eq!(dec, b"Aladdin:open sesame");
}
#[test]
fn production_digest_chain_matches_rfc2617_and_rejects_altered() {
let ha1 = md5_hex("Mufasa:testrealm@host.com:Circle Of Life".to_string());
let ha2 = md5_hex("GET:/dir/index.html".to_string());
let expected = md5_hex(format!(
"{ha1}:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:0a4f113b:auth:{ha2}"
));
let rfc = "6629fae49393a05397450978507c4ef1";
assert!(constant_time_eq(expected.as_bytes(), rfc.as_bytes()));
let altered = "6629fae49393a05397450978507c4ef2";
assert!(!constant_time_eq(expected.as_bytes(), altered.as_bytes()));
}
#[test]
fn production_basic_verify_matches_rfc7617_and_rejects_altered() {
let ok = "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==";
assert!(verify_basic(ok, "Aladdin", "open sesame"));
let altered = "Basic RWxhZGRpbjpvcGVuIHNlc2FtZQ==";
assert!(!verify_basic(altered, "Aladdin", "open sesame"));
assert!(!verify_basic(ok, "Aladdin", "open sesamf"));
}
}