use base64::Engine;
use md5::{Digest as _, Md5};
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,
nonce: String,
},
Bearer {
token: String,
},
Forwarded {
user_header: String,
forwarded_for_header: Option<String>,
},
SignedUrl {
keys: SignedUrlKeySet,
},
}
pub struct Verifier {
scheme: VerifierScheme,
}
impl Verifier {
pub fn new(credentials: Credentials, realm: impl Into<String>) -> Self {
let 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,
nonce: generate_nonce(),
},
Credentials::Bearer { token } => VerifierScheme::Bearer { token },
};
Verifier { scheme }
}
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,
},
}
}
pub fn signed_url(keys: SignedUrlKeySet) -> Self {
Verifier {
scheme: VerifierScheme::SignedUrl { keys },
}
}
pub fn challenge(&self) -> String {
match &self.scheme {
VerifierScheme::Basic { realm, .. } => format!("Basic realm=\"{realm}\""),
VerifierScheme::Digest { realm, nonce, .. } => {
format!("Digest realm=\"{realm}\", nonce=\"{nonce}\", qop=\"auth\", algorithm=MD5")
}
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,
nonce,
} => ctx.header("authorization").is_some_and(|header| {
verify_digest(
header, username, password, realm, nonce, ctx.method, ctx.uri,
)
}),
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 verify_basic(header: &str, username: &str, password: &str) -> bool {
let Some(encoded) = header.strip_prefix("Basic ") else {
return false;
};
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(encoded.trim()) else {
return false;
};
let expected = format!("{username}:{password}");
constant_time_eq(&decoded, expected.as_bytes())
}
fn verify_bearer(header: &str, token: &str) -> bool {
let Some(sent) = header.strip_prefix("Bearer ") else {
return false;
};
constant_time_eq(sent.trim().as_bytes(), token.as_bytes())
}
const MAX_DIGEST_FIELDS: usize = 64;
fn verify_digest(
header: &str,
username: &str,
password: &str,
realm: &str,
nonce: &str,
method: &str,
request_uri: &str,
) -> bool {
let Some(rest) = header.strip_prefix("Digest ") else {
return false;
};
if rest.split(',').count() > MAX_DIGEST_FIELDS {
return false;
}
let mut fields = std::collections::HashMap::new();
for part in rest.split(',') {
let part = part.trim();
let Some((key, value)) = part.split_once('=') else {
continue;
};
fields.insert(key.trim(), value.trim().trim_matches('"'));
}
let get = |k: &str| fields.get(k).copied().unwrap_or_default();
if get("username") != username || get("realm") != realm || get("nonce") != nonce {
return false;
}
let client_uri = get("uri");
if !digest_uri_matches(client_uri, request_uri) {
return false;
}
let nc = get("nc");
let cnonce = get("cnonce");
let qop = get("qop");
let client_response = get("response");
if nc.is_empty() || cnonce.is_empty() || client_response.is_empty() {
return false;
}
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}:{cnonce}:{qop}:{ha2}"));
constant_time_eq(expected_response.as_bytes(), client_response.as_bytes())
}
fn digest_uri_matches(client_uri: &str, request_uri: &str) -> bool {
if client_uri == request_uri {
return true;
}
if let Some((_scheme, after_scheme)) = client_uri.split_once("://") {
if let Some(slash) = after_scheme.find('/') {
return &after_scheme[slash..] == request_uri;
}
}
false
}
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());
let digest = hasher.finalize();
digest.iter().map(|b| format!("{b:02x}")).collect()
}
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 generate_nonce() -> String {
let bytes: [u8; 16] = rand::random();
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Credentials, RequestContext, respond};
const REALM: &str = "cameras";
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_nonce_is_stable_across_repeated_challenge_calls() {
let v = Verifier::new(
Credentials::Digest {
username: "admin".into(),
password: "12345".into(),
},
REALM,
);
assert_eq!(
v.challenge(),
v.challenge(),
"nonce must not rotate per-call"
);
}
#[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 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"
);
}
#[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}");
}
}