use base64::{Engine as _, engine::general_purpose::STANDARD};
use sha1::{Digest, Sha1};
use crate::state::SharedState;
use crate::xml_parse::extract_tag;
const AUTH_EXEMPT: &[&str] = &["http://www.onvif.org/ver10/device/wsdl/GetSystemDateAndTime"];
pub fn requires_auth(action: &str) -> bool {
!AUTH_EXEMPT.contains(&action)
}
pub fn validate_ws_security(body: &str, state: &SharedState) -> Result<(), String> {
let username = extract_tag(body, "Username").ok_or_else(|| "Missing Username".to_string())?;
let digest_b64 =
extract_tag(body, "Password").ok_or_else(|| "Missing Password digest".to_string())?;
let nonce_b64 = extract_tag(body, "Nonce").ok_or_else(|| "Missing Nonce".to_string())?;
let created =
extract_tag(body, "Created").ok_or_else(|| "Missing Created timestamp".to_string())?;
let nonce_raw = STANDARD
.decode(&nonce_b64)
.map_err(|e| format!("Invalid nonce base64: {e}"))?;
let password = {
let s = state.read();
s.users
.iter()
.find(|u| u.username == username)
.map(|u| u.password.clone())
.ok_or_else(|| format!("Unknown user: {username}"))?
};
let mut h = Sha1::new();
h.update(&nonce_raw);
h.update(created.as_bytes());
h.update(password.as_bytes());
let expected = STANDARD.encode(h.finalize());
if digest_b64 == expected {
Ok(())
} else {
Err(format!("Password digest mismatch for user {username}"))
}
}
pub fn auth_fault(reason: &str) -> String {
crate::helpers::soap(
"",
&format!(
r#"<s:Fault>
<s:Code><s:Value>s:Sender</s:Value>
<s:Subcode><s:Value>wsse:FailedAuthentication</s:Value></s:Subcode>
</s:Code>
<s:Reason><s:Text xml:lang="en">{reason}</s:Text></s:Reason>
</s:Fault>"#
),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::PersistentState;
fn new_state() -> PersistentState {
PersistentState::for_tests()
}
fn build_digest_body(
username: &str,
password: &str,
created: &str,
nonce_raw: &[u8],
) -> String {
let nonce_b64 = STANDARD.encode(nonce_raw);
let mut h = Sha1::new();
h.update(nonce_raw);
h.update(created.as_bytes());
h.update(password.as_bytes());
let digest_b64 = STANDARD.encode(h.finalize());
format!(
r#"<wsse:Security>
<wsse:UsernameToken>
<wsse:Username>{username}</wsse:Username>
<wsse:Password>{digest_b64}</wsse:Password>
<wsse:Nonce>{nonce_b64}</wsse:Nonce>
<wsu:Created>{created}</wsu:Created>
</wsse:UsernameToken>
</wsse:Security>"#
)
}
#[test]
fn exempt_action_does_not_require_auth() {
assert!(!requires_auth(
"http://www.onvif.org/ver10/device/wsdl/GetSystemDateAndTime"
));
}
#[test]
fn normal_action_requires_auth() {
assert!(requires_auth(
"http://www.onvif.org/ver10/device/wsdl/GetDeviceInformation"
));
}
#[test]
fn valid_digest_for_admin_passes() {
let s = new_state();
let body = build_digest_body(
"admin",
"admin",
"2026-04-15T00:00:00Z",
b"nonce_admin_20bytes!",
);
assert!(validate_ws_security(&body, &s).is_ok());
}
#[test]
fn valid_digest_for_operator_passes() {
let s = new_state();
let body = build_digest_body(
"operator",
"operator",
"2026-04-15T00:00:00Z",
b"nonce_op_20bytes_x!!",
);
assert!(validate_ws_security(&body, &s).is_ok());
}
#[test]
fn operator_cannot_use_admin_password() {
let s = new_state();
let body = build_digest_body(
"operator",
"admin",
"2026-04-15T00:00:00Z",
b"nonce_cross_20byts!!",
);
assert!(validate_ws_security(&body, &s).is_err());
}
#[test]
fn wrong_password_fails() {
let s = new_state();
let body = build_digest_body(
"admin",
"wrong",
"2026-04-15T00:00:00Z",
b"test_nonce_20_bytes!",
);
assert!(validate_ws_security(&body, &s).is_err());
}
#[test]
fn unknown_user_fails() {
let s = new_state();
let body = r#"<wsse:Username>hacker</wsse:Username>
<wsse:Password>x</wsse:Password>
<wsse:Nonce>eA==</wsse:Nonce>
<wsu:Created>x</wsu:Created>"#;
let err = validate_ws_security(body, &s).unwrap_err();
assert!(err.contains("Unknown user"), "got: {err}");
}
#[test]
fn missing_credentials_fails() {
let s = new_state();
let body = "<s:Body>no auth here</s:Body>";
assert!(validate_ws_security(body, &s).is_err());
}
#[test]
fn created_user_can_authenticate() {
let s = new_state();
let create_body = r#"<tds:CreateUsers><tds:User>
<tt:Username>viewer</tt:Username>
<tt:Password>viewerpw</tt:Password>
<tt:UserLevel>User</tt:UserLevel>
</tds:User></tds:CreateUsers>"#;
crate::services::device::handle_create_users(&s, create_body);
let body = build_digest_body(
"viewer",
"viewerpw",
"2026-04-15T00:00:00Z",
b"viewer_nonce_20bytes",
);
assert!(validate_ws_security(&body, &s).is_ok());
}
}