1use crate::fault::SoapFault;
3use crate::wssec::nonce_cache::RotatingNonceCache;
4use crate::wssec::timestamp::{check_freshness, parse_created};
5use base64::engine::general_purpose::STANDARD as BASE64;
6use base64::Engine;
7use chrono::{DateTime, Utc};
8use quick_xml::events::Event;
9use quick_xml::Reader;
10use sha1::{Digest, Sha1};
11use std::collections::HashMap;
12use subtle::ConstantTimeEq;
13
14const WSSE_NS: &str =
16 "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd";
17const WSU_NS: &str =
18 "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd";
19const PASSWORD_DIGEST_TYPE: &str = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest";
20const PASSWORD_TEXT_TYPE: &str = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText";
21
22#[derive(Debug, Clone, PartialEq)]
24pub enum PasswordType {
25 Digest,
26 Text,
27}
28
29#[derive(Debug, Clone)]
31pub struct UsernameToken {
32 pub username: String,
33 pub password: String,
34 pub password_type: PasswordType,
35 pub nonce: Option<String>,
36 pub created: Option<String>,
37}
38
39#[doc(hidden)]
56pub fn compute_digest(nonce_b64: &str, created: &str, password: &str) -> Result<String, SoapFault> {
57 let padded = add_base64_padding(nonce_b64);
59 let nonce_bytes = BASE64
60 .decode(padded.as_str())
61 .map_err(|e| SoapFault::sender(format!("Invalid nonce encoding: {e}")))?;
62 let mut hasher = Sha1::new();
63 hasher.update(&nonce_bytes);
64 hasher.update(created.as_bytes());
65 hasher.update(password.as_bytes());
66 Ok(BASE64.encode(hasher.finalize()))
67}
68
69fn add_base64_padding(s: &str) -> String {
71 let remainder = s.len() % 4;
72 if remainder == 0 {
73 s.to_string()
74 } else {
75 let padding = 4 - remainder;
76 format!("{}{}", s, "=".repeat(padding))
77 }
78}
79
80pub fn parse_username_token(xml_bytes: &[u8]) -> Result<UsernameToken, SoapFault> {
82 let mut reader = Reader::from_reader(xml_bytes);
83 reader.config_mut().trim_text(true);
84
85 let mut username: Option<String> = None;
86 let mut password: Option<String> = None;
87 let mut password_type = PasswordType::Digest;
88 let mut nonce: Option<String> = None;
89 let mut created: Option<String> = None;
90
91 let mut ns_map: HashMap<String, String> = HashMap::new();
93
94 let mut in_username_token = false;
95 let mut in_username_elem = false;
96 let mut in_password_elem = false;
97 let mut in_nonce_elem = false;
98 let mut in_created_elem = false;
99 let mut found_token = false;
100
101 let mut buf = Vec::new();
102 loop {
103 match reader.read_event_into(&mut buf) {
104 Ok(Event::Start(ref e)) => {
105 collect_ns_attrs(e.attributes(), &mut ns_map);
107
108 let name = e.name();
109 let (prefix, local) = split_name(name.as_ref());
110 let ns = resolve_ns(prefix, &ns_map);
111
112 match (local, ns.as_deref()) {
113 ("UsernameToken", Some(WSSE_NS)) => {
114 in_username_token = true;
115 found_token = true;
116 }
117 ("Username", Some(WSSE_NS)) if in_username_token => {
118 in_username_elem = true;
119 }
120 ("Password", Some(WSSE_NS)) if in_username_token => {
121 in_password_elem = true;
122 for attr in e.attributes().flatten() {
124 let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
125 if key == "Type" {
126 let val = String::from_utf8_lossy(&attr.value).to_string();
127 password_type = if val == PASSWORD_DIGEST_TYPE {
128 PasswordType::Digest
129 } else if val == PASSWORD_TEXT_TYPE {
130 PasswordType::Text
131 } else {
132 PasswordType::Digest
133 };
134 }
135 }
136 }
137 ("Nonce", Some(WSSE_NS)) if in_username_token => {
138 in_nonce_elem = true;
139 }
140 ("Created", Some(WSU_NS)) if in_username_token => {
141 in_created_elem = true;
142 }
143 _ => {}
144 }
145 }
146 Ok(Event::Empty(ref e)) => {
147 collect_ns_attrs(e.attributes(), &mut ns_map);
148 let name = e.name();
149 let (prefix, local) = split_name(name.as_ref());
150 let ns = resolve_ns(prefix, &ns_map);
151 if local == "UsernameToken" && ns.as_deref() == Some(WSSE_NS) {
152 found_token = true;
153 }
154 }
155 Ok(Event::Text(ref e)) => {
156 let text = String::from_utf8_lossy(e.as_ref()).to_string();
157 if in_username_elem {
158 username = Some(text);
159 in_username_elem = false;
160 } else if in_password_elem {
161 password = Some(text);
162 in_password_elem = false;
163 } else if in_nonce_elem {
164 nonce = Some(text);
165 in_nonce_elem = false;
166 } else if in_created_elem {
167 created = Some(text);
168 in_created_elem = false;
169 }
170 }
171 Ok(Event::End(ref e)) => {
172 let name = e.name();
173 let (_, local) = split_name(name.as_ref());
174 match local {
175 "UsernameToken" => {
176 in_username_token = false;
177 }
178 "Username" => {
179 in_username_elem = false;
180 }
181 "Password" => {
182 in_password_elem = false;
183 }
184 "Nonce" => {
185 in_nonce_elem = false;
186 }
187 "Created" => {
188 in_created_elem = false;
189 }
190 _ => {}
191 }
192 }
193 Ok(Event::Eof) => break,
194 Err(e) => {
195 return Err(SoapFault::sender(format!(
196 "WS-Security XML parse error: {e}"
197 )));
198 }
199 _ => {}
200 }
201 buf.clear();
202 }
203
204 if !found_token {
205 return Err(SoapFault::sender("Missing UsernameToken"));
206 }
207
208 let username =
209 username.ok_or_else(|| SoapFault::sender("Missing Username in UsernameToken"))?;
210 let password =
211 password.ok_or_else(|| SoapFault::sender("Missing Password in UsernameToken"))?;
212
213 Ok(UsernameToken {
214 username,
215 password,
216 password_type,
217 nonce,
218 created,
219 })
220}
221
222pub fn validate_username_token(
245 security_bytes: &[u8],
246 get_password: &dyn Fn(&str) -> Option<String>,
247 nonce_cache: &mut RotatingNonceCache,
248 tolerance_secs: i64,
249 now: DateTime<Utc>,
250) -> Result<String, SoapFault> {
251 let token = parse_username_token(security_bytes)?;
252
253 let stored_password =
256 get_password(&token.username).ok_or_else(|| SoapFault::sender("Authentication failed"))?;
257
258 match token.password_type {
260 PasswordType::Digest => {
261 let nonce = token
262 .nonce
263 .as_deref()
264 .ok_or_else(|| SoapFault::sender("Missing Nonce for PasswordDigest"))?;
265 let created = token
266 .created
267 .as_deref()
268 .ok_or_else(|| SoapFault::sender("Missing Created for PasswordDigest"))?;
269 let expected = compute_digest(nonce, created, &stored_password)?;
270 if expected
272 .as_bytes()
273 .ct_eq(token.password.as_bytes())
274 .unwrap_u8()
275 == 0
276 {
277 return Err(SoapFault::sender("Authentication failed"));
278 }
279 let created_dt = parse_created(created)?;
281 check_freshness(now, created_dt, tolerance_secs)?;
282 }
283 PasswordType::Text => {
284 let created_str = token
286 .created
287 .as_deref()
288 .ok_or_else(|| SoapFault::sender("Missing Created for PasswordText"))?;
289 let created_dt = parse_created(created_str)?;
290 check_freshness(now, created_dt, tolerance_secs)?;
291
292 if token.password != stored_password {
296 return Err(SoapFault::sender("Authentication failed"));
297 }
298 }
299 }
300
301 if let Some(nonce) = &token.nonce {
303 nonce_cache.check_and_insert(nonce)?;
304 }
305
306 Ok(token.username)
307}
308
309fn collect_ns_attrs(
311 attrs: quick_xml::events::attributes::Attributes<'_>,
312 ns_map: &mut HashMap<String, String>,
313) {
314 for attr in attrs.flatten() {
315 let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
316 let val = String::from_utf8_lossy(&attr.value).to_string();
317 if let Some(prefix) = key.strip_prefix("xmlns:") {
318 ns_map.insert(prefix.to_string(), val);
319 } else if key == "xmlns" {
320 ns_map.insert(String::new(), val);
321 }
322 }
323}
324
325fn split_name(name: &[u8]) -> (Option<&str>, &str) {
327 let s = std::str::from_utf8(name).unwrap_or("");
328 match s.find(':') {
329 Some(pos) => (Some(&s[..pos]), &s[pos + 1..]),
330 None => (None, s),
331 }
332}
333
334fn resolve_ns(prefix: Option<&str>, ns_map: &HashMap<String, String>) -> Option<String> {
336 match prefix {
337 Some(p) => ns_map.get(p).cloned(),
338 None => ns_map.get("").cloned(),
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use crate::wssec::nonce_cache::RotatingNonceCache;
346 use chrono::TimeZone;
347
348 const TEST_NONCE: &str = "AAECAwQFBgcICQoLDA0ODw==";
352 const TEST_CREATED: &str = "2010-09-09T14:18:30.000Z";
353 const TEST_PASSWORD: &str = "userpassword";
354 const TEST_EXPECTED_DIGEST: &str = "QPgtSBfcw764Vty2h0+LsasXgxo=";
355
356 fn test_now() -> DateTime<Utc> {
357 Utc.with_ymd_and_hms(2010, 9, 9, 14, 18, 31).unwrap()
359 }
360
361 fn make_nonce_cache() -> RotatingNonceCache {
362 RotatingNonceCache::new(150)
363 }
364
365 fn security_xml_digest(nonce: &str, created: &str, digest: &str) -> Vec<u8> {
366 format!(r#"<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
367 <wsse:UsernameToken>
368 <wsse:Username>admin</wsse:Username>
369 <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">{digest}</wsse:Password>
370 <wsse:Nonce>{nonce}</wsse:Nonce>
371 <wsu:Created>{created}</wsu:Created>
372 </wsse:UsernameToken>
373</wsse:Security>"#).into_bytes()
374 }
375
376 fn security_xml_text(password: &str) -> Vec<u8> {
377 format!(r#"<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
379 <wsse:UsernameToken>
380 <wsse:Username>admin</wsse:Username>
381 <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">{password}</wsse:Password>
382 <wsu:Created>{TEST_CREATED}</wsu:Created>
383 </wsse:UsernameToken>
384</wsse:Security>"#).into_bytes()
385 }
386
387 fn security_xml_text_no_created(password: &str) -> Vec<u8> {
388 format!(r#"<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
389 <wsse:UsernameToken>
390 <wsse:Username>admin</wsse:Username>
391 <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">{password}</wsse:Password>
392 </wsse:UsernameToken>
393</wsse:Security>"#).into_bytes()
394 }
395
396 fn security_xml_no_token() -> Vec<u8> {
397 br#"<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
398</wsse:Security>"#.to_vec()
399 }
400
401 fn security_xml_no_password() -> Vec<u8> {
402 br#"<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
403 <wsse:UsernameToken>
404 <wsse:Username>admin</wsse:Username>
405 </wsse:UsernameToken>
406</wsse:Security>"#.to_vec()
407 }
408
409 #[test]
412 fn known_vector_digest_matches_expected() {
413 let result = compute_digest(TEST_NONCE, TEST_CREATED, TEST_PASSWORD).unwrap();
414 assert_eq!(
415 result, TEST_EXPECTED_DIGEST,
416 "PasswordDigest known vector failed: got {result}, expected {TEST_EXPECTED_DIGEST}"
417 );
418 }
419
420 #[test]
421 fn compute_digest_invalid_base64_nonce_returns_err() {
422 let result = compute_digest("not!!!valid_base64!!!", TEST_CREATED, TEST_PASSWORD);
423 assert!(result.is_err());
424 }
425
426 #[test]
429 fn parse_digest_token_extracts_all_fields() {
430 let xml = security_xml_digest(TEST_NONCE, TEST_CREATED, TEST_EXPECTED_DIGEST);
431 let token = parse_username_token(&xml).unwrap();
432 assert_eq!(token.username, "admin");
433 assert_eq!(token.password, TEST_EXPECTED_DIGEST);
434 assert_eq!(token.password_type, PasswordType::Digest);
435 assert_eq!(token.nonce.as_deref(), Some(TEST_NONCE));
436 assert_eq!(token.created.as_deref(), Some(TEST_CREATED));
437 }
438
439 #[test]
440 fn parse_text_token_extracts_fields() {
441 let xml = security_xml_text("secret");
442 let token = parse_username_token(&xml).unwrap();
443 assert_eq!(token.username, "admin");
444 assert_eq!(token.password, "secret");
445 assert_eq!(token.password_type, PasswordType::Text);
446 assert!(token.nonce.is_none());
447 }
448
449 #[test]
450 fn parse_missing_username_token_returns_err() {
451 let xml = security_xml_no_token();
452 let result = parse_username_token(&xml);
453 assert!(result.is_err());
454 let fault = result.unwrap_err();
455 assert!(
456 fault.reason.contains("Missing UsernameToken"),
457 "got: {}",
458 fault.reason
459 );
460 }
461
462 #[test]
463 fn parse_missing_password_returns_err() {
464 let xml = security_xml_no_password();
465 let result = parse_username_token(&xml);
466 assert!(result.is_err());
467 }
468
469 fn get_password(username: &str) -> Option<String> {
472 if username == "admin" {
473 Some(TEST_PASSWORD.to_string())
474 } else {
475 None
476 }
477 }
478
479 #[test]
480 fn validate_correct_digest_returns_username() {
481 let xml = security_xml_digest(TEST_NONCE, TEST_CREATED, TEST_EXPECTED_DIGEST);
482 let mut cache = make_nonce_cache();
483 let result = validate_username_token(&xml, &get_password, &mut cache, 300, test_now());
484 assert_eq!(result.unwrap(), "admin");
485 }
486
487 #[test]
488 fn validate_wrong_password_returns_auth_failed() {
489 let bad_digest = compute_digest(TEST_NONCE, TEST_CREATED, "wrongpassword").unwrap();
491 let xml = security_xml_digest(TEST_NONCE, TEST_CREATED, &bad_digest);
492 let mut cache = make_nonce_cache();
493 let result = validate_username_token(&xml, &get_password, &mut cache, 300, test_now());
494 assert!(result.is_err());
495 let fault = result.unwrap_err();
496 assert!(
497 fault.reason.contains("Authentication failed"),
498 "got: {}",
499 fault.reason
500 );
501 }
502
503 #[test]
504 fn validate_unknown_user_returns_err() {
505 let xml = format!(
506 r#"<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
507 <wsse:UsernameToken>
508 <wsse:Username>unknownuser</wsse:Username>
509 <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">{TEST_EXPECTED_DIGEST}</wsse:Password>
510 <wsse:Nonce>{TEST_NONCE}</wsse:Nonce>
511 <wsu:Created>{TEST_CREATED}</wsu:Created>
512 </wsse:UsernameToken>
513</wsse:Security>"#
514 );
515 let mut cache = make_nonce_cache();
516 let result =
517 validate_username_token(xml.as_bytes(), &get_password, &mut cache, 300, test_now());
518 assert!(result.is_err());
519 let fault = result.unwrap_err();
520 assert!(
522 fault.reason.contains("Authentication failed"),
523 "got: {}",
524 fault.reason
525 );
526 }
527
528 #[test]
529 fn validate_expired_timestamp_returns_err() {
530 let expired_now = Utc.with_ymd_and_hms(2010, 9, 9, 14, 25, 30).unwrap();
532 let xml = security_xml_digest(TEST_NONCE, TEST_CREATED, TEST_EXPECTED_DIGEST);
533 let mut cache = make_nonce_cache();
534 let result = validate_username_token(&xml, &get_password, &mut cache, 300, expired_now);
535 assert!(result.is_err());
536 let fault = result.unwrap_err();
537 assert!(fault.reason.contains("expired"), "got: {}", fault.reason);
538 }
539
540 #[test]
541 fn validate_replayed_nonce_returns_err() {
542 let xml = security_xml_digest(TEST_NONCE, TEST_CREATED, TEST_EXPECTED_DIGEST);
543 let mut cache = make_nonce_cache();
544 validate_username_token(&xml, &get_password, &mut cache, 300, test_now()).unwrap();
546 let result = validate_username_token(&xml, &get_password, &mut cache, 300, test_now());
548 assert!(result.is_err());
549 let fault = result.unwrap_err();
550 assert!(fault.reason.contains("replay"), "got: {}", fault.reason);
551 }
552
553 #[test]
554 fn validate_text_password_correct() {
555 let xml = security_xml_text(TEST_PASSWORD);
556 let mut cache = make_nonce_cache();
557 let result = validate_username_token(&xml, &get_password, &mut cache, 300, test_now());
558 assert_eq!(result.unwrap(), "admin");
559 }
560
561 #[test]
562 fn validate_text_password_wrong_returns_err() {
563 let xml = security_xml_text("wrongpassword");
564 let mut cache = make_nonce_cache();
565 let result = validate_username_token(&xml, &get_password, &mut cache, 300, test_now());
566 assert!(result.is_err());
567 let fault = result.unwrap_err();
568 assert!(
569 fault.reason.contains("Authentication failed"),
570 "got: {}",
571 fault.reason
572 );
573 }
574
575 #[test]
576 fn validate_missing_username_token_returns_err() {
577 let xml = security_xml_no_token();
578 let mut cache = make_nonce_cache();
579 let result = validate_username_token(&xml, &get_password, &mut cache, 300, test_now());
580 assert!(result.is_err());
581 let fault = result.unwrap_err();
582 assert!(
583 fault.reason.contains("Missing UsernameToken"),
584 "got: {}",
585 fault.reason
586 );
587 }
588
589 #[test]
592 fn validate_text_password_without_created_is_rejected() {
593 let xml = security_xml_text_no_created(TEST_PASSWORD);
594 let mut cache = make_nonce_cache();
595 let result = validate_username_token(&xml, &get_password, &mut cache, 300, test_now());
596 assert!(
597 result.is_err(),
598 "PasswordText without Created must be rejected"
599 );
600 let fault = result.unwrap_err();
601 assert!(
602 fault.reason.contains("Missing Created"),
603 "Expected 'Missing Created' fault, got: {}",
604 fault.reason
605 );
606 }
607
608 #[test]
612 fn validate_correct_digest_constant_time_path_returns_username() {
613 let xml = security_xml_digest(TEST_NONCE, TEST_CREATED, TEST_EXPECTED_DIGEST);
615 let mut cache = make_nonce_cache();
616 let result = validate_username_token(&xml, &get_password, &mut cache, 300, test_now());
617 assert_eq!(
618 result.unwrap(),
619 "admin",
620 "Constant-time digest path must succeed"
621 );
622 }
623
624 #[test]
627 fn unknown_user_and_bad_digest_produce_same_fault_reason() {
628 let xml_unknown = format!(
630 r#"<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
631 <wsse:UsernameToken>
632 <wsse:Username>no_such_user</wsse:Username>
633 <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">{TEST_EXPECTED_DIGEST}</wsse:Password>
634 <wsse:Nonce>{TEST_NONCE}</wsse:Nonce>
635 <wsu:Created>{TEST_CREATED}</wsu:Created>
636 </wsse:UsernameToken>
637</wsse:Security>"#
638 );
639 let bad_digest = compute_digest(TEST_NONCE, TEST_CREATED, "wrongpassword").unwrap();
641 let xml_bad_digest = security_xml_digest(TEST_NONCE, TEST_CREATED, &bad_digest);
642
643 let mut cache1 = make_nonce_cache();
644 let fault_unknown = validate_username_token(
645 xml_unknown.as_bytes(),
646 &get_password,
647 &mut cache1,
648 300,
649 test_now(),
650 )
651 .unwrap_err();
652
653 let mut cache2 = make_nonce_cache();
654 let fault_bad =
655 validate_username_token(&xml_bad_digest, &get_password, &mut cache2, 300, test_now())
656 .unwrap_err();
657
658 assert_eq!(
659 fault_unknown.reason, fault_bad.reason,
660 "Unknown user and bad digest must produce the same public fault reason (Finding #12). \
661 Got unknown={:?} bad_digest={:?}",
662 fault_unknown.reason, fault_bad.reason
663 );
664 }
665}