Skip to main content

soap_server/wssec/
username_token.rs

1// WS-Security UsernameToken parsing and verification
2use 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
14// WS-Security namespaces
15const 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/// The type of password in a UsernameToken.
23#[derive(Debug, Clone, PartialEq)]
24pub enum PasswordType {
25    Digest,
26    Text,
27}
28
29/// A parsed WS-Security UsernameToken.
30#[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/// Compute PasswordDigest per OASIS WS-Security UsernameToken Profile 1.1 spec:
40/// `digest = Base64( SHA-1( Base64Decode(Nonce) ++ Created_UTF8 ++ Password_UTF8 ) )`
41///
42/// # Arguments
43/// - `nonce_b64`: the **base64-encoded** nonce string (as it appears in the XML `<wsse:Nonce>` element).
44///   This function decodes it internally. Passing raw nonce bytes (not base64-encoded) will produce
45///   an incorrect digest.
46/// - `created`: the ISO 8601 Created timestamp string (e.g. `"2024-01-01T00:00:00.000Z"`).
47/// - `password`: the plaintext password.
48///
49/// Returns `Err(SoapFault)` if `nonce_b64` is not valid base64.
50///
51/// # Note
52/// This function is part of soap-server's internal WS-Security implementation.
53/// It is exported for testing convenience but is not intended as a stable public API.
54/// Prefer using [`validate_username_token`] for full token validation.
55#[doc(hidden)]
56pub fn compute_digest(nonce_b64: &str, created: &str, password: &str) -> Result<String, SoapFault> {
57    // Add padding if needed for base64 decoding
58    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
69/// Add base64 padding `=` chars if missing.
70fn 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
80/// Parse a WS-Security UsernameToken from XML bytes (the wsse:Security header content).
81pub 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    // Track namespace prefix -> URI mappings (global across the document)
92    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                // Update ns_map from xmlns attributes on this element
106                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                        // Read the Type attribute
123                        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
222/// Validate a WS-Security UsernameToken from a raw `<wsse:Security>` XML element.
223///
224/// Returns the authenticated username on success, or a [`SoapFault`] on failure.
225///
226/// # Arguments
227/// - `security_bytes`: raw XML bytes of the `<wsse:Security>` header child element.
228/// - `get_password`: closure mapping a username to its stored plaintext password,
229///   or `None` if the user is unknown. Called with the username from the token.
230/// - `nonce_cache`: replay-detection cache. Takes `&mut self` — the caller must hold
231///   an exclusive lock (e.g. via `tokio::sync::Mutex`) when calling this in an async
232///   context. See [`RotatingNonceCache`] for thread-safety guidance.
233/// - `tolerance_secs`: maximum age in seconds for the `<wsu:Created>` timestamp.
234///   Requests older than this are rejected as expired.
235/// - `now`: current UTC time, used for timestamp freshness checks. Pass `Utc::now()`
236///   in production; inject a fixed value in tests.
237///
238/// # Validation steps
239/// 1. Parse the `<wsse:UsernameToken>` from `security_bytes`.
240/// 2. Look up the stored password via `get_password`.
241/// 3. Verify the password (PasswordDigest or PasswordText).
242/// 4. Check that `<wsu:Created>` is within `tolerance_secs` of `now`.
243/// 5. Check the nonce for replay via `nonce_cache`.
244pub 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    // Normalize unknown-user to the same generic fault as a wrong password so
254    // that callers cannot enumerate valid usernames from the fault reason (Finding #12).
255    let stored_password =
256        get_password(&token.username).ok_or_else(|| SoapFault::sender("Authentication failed"))?;
257
258    // Verify password
259    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            // Use constant-time comparison to avoid timing side-channels (Finding #9).
271            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            // Require freshness for Digest tokens (Created was already required above).
280            let created_dt = parse_created(created)?;
281            check_freshness(now, created_dt, tolerance_secs)?;
282        }
283        PasswordType::Text => {
284            // Require Created timestamp for PasswordText (Finding #9).
285            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            // Plain string equality is fine for text passwords; constant-time
293            // comparison would also be acceptable but offers negligible benefit
294            // given that text passwords are inherently weak without TLS.
295            if token.password != stored_password {
296                return Err(SoapFault::sender("Authentication failed"));
297            }
298        }
299    }
300
301    // Check nonce for replay
302    if let Some(nonce) = &token.nonce {
303        nonce_cache.check_and_insert(nonce)?;
304    }
305
306    Ok(token.username)
307}
308
309/// Collect namespace prefix -> URI bindings from element attributes.
310fn 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
325/// Split a qualified element name into (prefix, local_name).
326fn 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
334/// Resolve a namespace prefix to its URI using the ns_map.
335fn 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    // Known test vector — self-consistent, independently verified with Python hashlib/base64.
349    // Nonce raw bytes: [0x00, 0x01, ..., 0x0f] (16 bytes), base64-encoded.
350    // Verified: base64.b64encode(sha1(base64.b64decode(NONCE) + CREATED + PASSWORD)) == DIGEST
351    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        // 1 second after the created timestamp — within tolerance
358        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        // Includes a Created timestamp so freshness checks pass.
378        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    // ---- compute_digest tests ----
410
411    #[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    // ---- parse_username_token tests ----
427
428    #[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    // ---- validate_username_token tests ----
470
471    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        // Compute digest with wrong password
490        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        // Unknown user is normalized to the same generic message (Finding #12).
521        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        // Use a 'now' that is 400 seconds after the created timestamp
531        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        // First call succeeds
545        validate_username_token(&xml, &get_password, &mut cache, 300, test_now()).unwrap();
546        // Second call with same nonce should fail (replay)
547        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    // ── Finding #9: PasswordText without Created is rejected ─────────────────
590
591    #[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    // ── Finding #9: Digest compare still validates a correct token ────────────
609    // (constant-time path)
610
611    #[test]
612    fn validate_correct_digest_constant_time_path_returns_username() {
613        // Re-run the known-vector test to confirm the constant-time path is correct.
614        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    // ── Finding #12: Unknown user and bad digest produce identical fault reason ─
625
626    #[test]
627    fn unknown_user_and_bad_digest_produce_same_fault_reason() {
628        // Auth failure due to unknown user.
629        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        // Auth failure due to wrong digest (correct user).
640        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}