auth_framework/
ws_security.rs

1//! WS-Security 1.1 Client Implementation
2//!
3//! This module provides client-side WS-Security 1.1 support for legacy enterprise systems.
4//! Includes UsernameToken, Timestamp, X.509 Certificate Signing, and SAML 2.0 token support.
5
6use crate::errors::{AuthError, Result};
7use crate::saml_assertions::SamlAssertion;
8use base64::{Engine as _, engine::general_purpose::STANDARD};
9use chrono::{DateTime, Duration, Utc};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// WS-Security Header builder
14#[derive(Debug, Clone, Default)]
15pub struct WsSecurityHeader {
16    /// Username token (if used)
17    pub username_token: Option<UsernameToken>,
18
19    /// Timestamp (if used)
20    pub timestamp: Option<Timestamp>,
21
22    /// Binary security token (X.509 certificate)
23    pub binary_security_token: Option<BinarySecurityToken>,
24
25    /// SAML assertions
26    pub saml_assertions: Vec<SamlAssertionRef>,
27
28    /// Signature elements
29    pub signature: Option<WsSecuritySignature>,
30
31    /// Additional custom elements
32    pub custom_elements: Vec<String>,
33}
34
35/// UsernameToken for basic authentication
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct UsernameToken {
38    /// Username
39    pub username: String,
40
41    /// Password (optional - can be omitted for cert-based auth)
42    pub password: Option<UsernamePassword>,
43
44    /// Nonce for replay protection
45    pub nonce: Option<String>,
46
47    /// Created timestamp
48    pub created: Option<DateTime<Utc>>,
49
50    /// WSU ID for referencing in signatures
51    pub wsu_id: Option<String>,
52}
53
54/// Password element with type
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct UsernamePassword {
57    /// Password value
58    pub value: String,
59
60    /// Password type (PasswordText or PasswordDigest)
61    pub password_type: PasswordType,
62}
63
64/// Password types for UsernameToken
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub enum PasswordType {
67    /// Plain text password (not recommended)
68    PasswordText,
69
70    /// SHA-1 digest of password, nonce, and created time
71    PasswordDigest,
72}
73
74/// Timestamp for message freshness
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct Timestamp {
77    /// When the message was created
78    pub created: DateTime<Utc>,
79
80    /// When the message expires
81    pub expires: DateTime<Utc>,
82
83    /// WSU ID for referencing in signatures
84    pub wsu_id: Option<String>,
85}
86
87/// Binary Security Token (typically X.509 certificate)
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct BinarySecurityToken {
90    /// Token value (base64 encoded certificate)
91    pub value: String,
92
93    /// Value type (X.509 certificate identifier)
94    pub value_type: String,
95
96    /// Encoding type (Base64Binary)
97    pub encoding_type: String,
98
99    /// WSU ID for referencing
100    pub wsu_id: Option<String>,
101}
102
103/// SAML Assertion for identity/attribute exchange
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct SamlAssertionRef {
106    /// Reference to the SAML assertion
107    pub assertion: SamlAssertion,
108
109    /// WSU ID for referencing in signatures
110    pub wsu_id: Option<String>,
111}
112
113/// WS-Security Signature
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct WsSecuritySignature {
116    /// Signature method algorithm
117    pub signature_method: String,
118
119    /// Canonicalization method
120    pub canonicalization_method: String,
121
122    /// Digest method
123    pub digest_method: String,
124
125    /// References to signed elements
126    pub references: Vec<SignatureReference>,
127
128    /// Key info (certificate reference)
129    pub key_info: Option<KeyInfo>,
130
131    /// Signature value
132    pub signature_value: Option<String>,
133}
134
135/// Reference to a signed element
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct SignatureReference {
138    /// URI reference to the element
139    pub uri: String,
140
141    /// Digest value
142    pub digest_value: String,
143
144    /// Transforms applied
145    pub transforms: Vec<String>,
146}
147
148/// Key information for signature verification
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct KeyInfo {
151    /// Reference to security token
152    pub security_token_reference: Option<String>,
153
154    /// Direct key value
155    pub key_value: Option<String>,
156
157    /// X.509 certificate data
158    pub x509_data: Option<String>,
159}
160
161/// WS-Security configuration
162#[derive(Debug, Clone)]
163pub struct WsSecurityConfig {
164    /// Whether to include timestamp
165    pub include_timestamp: bool,
166
167    /// Timestamp TTL
168    pub timestamp_ttl: Duration,
169
170    /// Whether to sign the message
171    pub sign_message: bool,
172
173    /// Elements to sign (by local name)
174    pub elements_to_sign: Vec<String>,
175
176    /// Certificate for signing (PEM format)
177    pub signing_certificate: Option<Vec<u8>>,
178
179    /// Private key for signing (PEM format)
180    pub signing_private_key: Option<Vec<u8>>,
181
182    /// Whether to include certificate in message
183    pub include_certificate: bool,
184
185    /// SAML token provider endpoint
186    pub saml_token_endpoint: Option<String>,
187
188    /// Actor value for delegation scenarios
189    pub actor: Option<String>,
190}
191
192/// WS-Security client for generating secure SOAP headers
193pub struct WsSecurityClient {
194    /// Configuration
195    config: WsSecurityConfig,
196
197    /// XML namespace prefixes
198    namespaces: HashMap<String, String>,
199}
200
201impl WsSecurityClient {
202    /// Create a new WS-Security client
203    pub fn new(config: WsSecurityConfig) -> Self {
204        let mut namespaces = HashMap::new();
205        namespaces.insert(
206            "wsse".to_string(),
207            "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"
208                .to_string(),
209        );
210        namespaces.insert(
211            "wsu".to_string(),
212            "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"
213                .to_string(),
214        );
215        namespaces.insert(
216            "ds".to_string(),
217            "http://www.w3.org/2000/09/xmldsig#".to_string(),
218        );
219        namespaces.insert(
220            "saml".to_string(),
221            "urn:oasis:names:tc:SAML:2.0:assertion".to_string(),
222        );
223
224        Self { config, namespaces }
225    }
226
227    /// Create WS-Security header with UsernameToken
228    pub fn create_username_token_header(
229        &self,
230        username: &str,
231        password: Option<&str>,
232        password_type: PasswordType,
233    ) -> Result<WsSecurityHeader> {
234        let mut header = WsSecurityHeader::default();
235
236        let (nonce, created) = if password_type == PasswordType::PasswordDigest {
237            (Some(self.generate_nonce()), Some(Utc::now()))
238        } else {
239            (None, None)
240        };
241
242        let password_element = if let Some(pwd) = password {
243            let pwd_value = match password_type {
244                PasswordType::PasswordText => pwd.to_string(),
245                PasswordType::PasswordDigest => {
246                    self.compute_password_digest(pwd, nonce.as_ref().unwrap(), &created.unwrap())?
247                }
248            };
249
250            Some(UsernamePassword {
251                value: pwd_value,
252                password_type,
253            })
254        } else {
255            None
256        };
257
258        header.username_token = Some(UsernameToken {
259            username: username.to_string(),
260            password: password_element,
261            nonce,
262            created,
263            wsu_id: Some(format!("UsernameToken-{}", uuid::Uuid::new_v4())),
264        });
265
266        if self.config.include_timestamp {
267            header.timestamp = Some(self.create_timestamp());
268        }
269
270        Ok(header)
271    }
272
273    /// Create WS-Security header with X.509 certificate
274    pub fn create_certificate_header(&self, certificate: &[u8]) -> Result<WsSecurityHeader> {
275        let mut header = WsSecurityHeader::default();
276
277        // Encode certificate as base64
278        let cert_b64 = STANDARD.encode(certificate);
279
280        header.binary_security_token = Some(BinarySecurityToken {
281            value: cert_b64,
282            value_type: "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3".to_string(),
283            encoding_type: "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary".to_string(),
284            wsu_id: Some(format!("X509Token-{}", uuid::Uuid::new_v4())),
285        });
286
287        if self.config.include_timestamp {
288            header.timestamp = Some(self.create_timestamp());
289        }
290
291        if self.config.sign_message {
292            header.signature = Some(self.create_signature_template()?);
293        }
294
295        Ok(header)
296    }
297
298    /// Create WS-Security header with SAML assertion
299    pub fn create_saml_header(&self, assertion: SamlAssertion) -> Result<WsSecurityHeader> {
300        let mut header = WsSecurityHeader::default();
301
302        let assertion_ref = SamlAssertionRef {
303            assertion,
304            wsu_id: Some(format!("SamlAssertion-{}", uuid::Uuid::new_v4())),
305        };
306
307        header.saml_assertions.push(assertion_ref);
308
309        if self.config.include_timestamp {
310            header.timestamp = Some(self.create_timestamp());
311        }
312
313        Ok(header)
314    }
315    /// Convert WS-Security header to XML
316    pub fn header_to_xml(&self, header: &WsSecurityHeader) -> Result<String> {
317        let mut xml = String::new();
318
319        // Start Security header
320        xml.push_str(&format!(
321            r#"<wsse:Security xmlns:wsse="{}" xmlns:wsu="{}">"#,
322            self.namespaces["wsse"], self.namespaces["wsu"]
323        ));
324
325        // Add timestamp
326        if let Some(ref timestamp) = header.timestamp {
327            xml.push_str(&self.timestamp_to_xml(timestamp));
328        }
329
330        // Add username token
331        if let Some(ref username_token) = header.username_token {
332            xml.push_str(&self.username_token_to_xml(username_token));
333        }
334
335        // Add binary security token
336        if let Some(ref bst) = header.binary_security_token {
337            xml.push_str(&self.binary_security_token_to_xml(bst));
338        }
339
340        // Add SAML assertions
341        for assertion_ref in &header.saml_assertions {
342            let assertion_xml = assertion_ref.assertion.to_xml()?;
343            xml.push_str(&assertion_xml);
344        }
345
346        // Add signature
347        if let Some(ref signature) = header.signature {
348            xml.push_str(&self.signature_to_xml(signature));
349        }
350
351        // End Security header
352        xml.push_str("</wsse:Security>");
353
354        Ok(xml)
355    }
356
357    /// Generate a random nonce
358    fn generate_nonce(&self) -> String {
359        use rand::RngCore;
360        let mut rng = rand::rng();
361        let mut nonce = [0u8; 16];
362        rng.fill_bytes(&mut nonce);
363        STANDARD.encode(nonce)
364    }
365
366    /// Compute password digest (SHA-1 of nonce + created + password)
367    fn compute_password_digest(
368        &self,
369        password: &str,
370        nonce: &str,
371        created: &DateTime<Utc>,
372    ) -> Result<String> {
373        use sha1::{Digest, Sha1};
374
375        let nonce_bytes = STANDARD
376            .decode(nonce)
377            .map_err(|_| AuthError::auth_method("ws_security", "Invalid nonce encoding"))?;
378        let created_str = created.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
379
380        let mut hasher = Sha1::new();
381        hasher.update(&nonce_bytes);
382        hasher.update(created_str.as_bytes());
383        hasher.update(password.as_bytes());
384
385        let digest = hasher.finalize();
386        Ok(STANDARD.encode(digest))
387    }
388
389    /// Create timestamp element
390    fn create_timestamp(&self) -> Timestamp {
391        let now = Utc::now();
392        let expires = now + self.config.timestamp_ttl;
393
394        Timestamp {
395            created: now,
396            expires,
397            wsu_id: Some(format!("Timestamp-{}", uuid::Uuid::new_v4())),
398        }
399    }
400
401    /// Create signature template
402    fn create_signature_template(&self) -> Result<WsSecuritySignature> {
403        Ok(WsSecuritySignature {
404            signature_method: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256".to_string(),
405            canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#".to_string(),
406            digest_method: "http://www.w3.org/2001/04/xmlenc#sha256".to_string(),
407            references: self
408                .config
409                .elements_to_sign
410                .iter()
411                .map(|element| {
412                    SignatureReference {
413                        uri: format!("#{}", element),
414                        digest_value: String::new(), // Will be computed during signing
415                        transforms: vec!["http://www.w3.org/2001/10/xml-exc-c14n#".to_string()],
416                    }
417                })
418                .collect(),
419            key_info: None,        // Will be set based on certificate
420            signature_value: None, // Will be computed during signing
421        })
422    }
423
424    /// Convert timestamp to XML
425    fn timestamp_to_xml(&self, timestamp: &Timestamp) -> String {
426        let mut xml = String::new();
427
428        if let Some(ref id) = timestamp.wsu_id {
429            xml.push_str(&format!(r#"<wsu:Timestamp wsu:Id="{}">"#, id));
430        } else {
431            xml.push_str("<wsu:Timestamp>");
432        }
433
434        xml.push_str(&format!(
435            "<wsu:Created>{}</wsu:Created>",
436            timestamp.created.format("%Y-%m-%dT%H:%M:%S%.3fZ")
437        ));
438
439        xml.push_str(&format!(
440            "<wsu:Expires>{}</wsu:Expires>",
441            timestamp.expires.format("%Y-%m-%dT%H:%M:%S%.3fZ")
442        ));
443
444        xml.push_str("</wsu:Timestamp>");
445        xml
446    }
447
448    /// Convert username token to XML
449    fn username_token_to_xml(&self, token: &UsernameToken) -> String {
450        let mut xml = String::new();
451
452        if let Some(ref id) = token.wsu_id {
453            xml.push_str(&format!(r#"<wsse:UsernameToken wsu:Id="{}">"#, id));
454        } else {
455            xml.push_str("<wsse:UsernameToken>");
456        }
457
458        xml.push_str(&format!(
459            "<wsse:Username>{}</wsse:Username>",
460            token.username
461        ));
462
463        if let Some(ref password) = token.password {
464            let type_attr = match password.password_type {
465                PasswordType::PasswordText => {
466                    "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText"
467                }
468                PasswordType::PasswordDigest => {
469                    "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest"
470                }
471            };
472
473            xml.push_str(&format!(
474                r#"<wsse:Password Type="{}">{}</wsse:Password>"#,
475                type_attr, password.value
476            ));
477        }
478
479        if let Some(ref nonce) = token.nonce {
480            xml.push_str(&format!(
481                r#"<wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">{}</wsse:Nonce>"#,
482                nonce
483            ));
484        }
485
486        if let Some(ref created) = token.created {
487            xml.push_str(&format!(
488                "<wsu:Created>{}</wsu:Created>",
489                created.format("%Y-%m-%dT%H:%M:%S%.3fZ")
490            ));
491        }
492
493        xml.push_str("</wsse:UsernameToken>");
494        xml
495    }
496
497    /// Convert binary security token to XML
498    fn binary_security_token_to_xml(&self, token: &BinarySecurityToken) -> String {
499        let mut xml = String::new();
500
501        xml.push_str(&format!(
502            r#"<wsse:BinarySecurityToken ValueType="{}" EncodingType="{}""#,
503            token.value_type, token.encoding_type
504        ));
505
506        if let Some(ref id) = token.wsu_id {
507            xml.push_str(&format!(r#" wsu:Id="{}""#, id));
508        }
509
510        xml.push('>');
511        xml.push_str(&token.value);
512        xml.push_str("</wsse:BinarySecurityToken>");
513
514        xml
515    }
516
517    /// Convert signature to XML (simplified template)
518    fn signature_to_xml(&self, signature: &WsSecuritySignature) -> String {
519        format!(
520            r#"<ds:Signature xmlns:ds="{}">
521                <ds:SignedInfo>
522                    <ds:CanonicalizationMethod Algorithm="{}"/>
523                    <ds:SignatureMethod Algorithm="{}"/>
524                    {}
525                </ds:SignedInfo>
526                <ds:SignatureValue></ds:SignatureValue>
527                <ds:KeyInfo></ds:KeyInfo>
528            </ds:Signature>"#,
529            self.namespaces["ds"],
530            signature.canonicalization_method,
531            signature.signature_method,
532            signature
533                .references
534                .iter()
535                .map(|r| format!(
536                    r#"<ds:Reference URI="{}">
537                        <ds:Transforms>
538                            {}
539                        </ds:Transforms>
540                        <ds:DigestMethod Algorithm="{}"/>
541                        <ds:DigestValue></ds:DigestValue>
542                    </ds:Reference>"#,
543                    r.uri,
544                    r.transforms
545                        .iter()
546                        .map(|t| format!(r#"<ds:Transform Algorithm="{}"/>"#, t))
547                        .collect::<Vec<_>>()
548                        .join(""),
549                    signature.digest_method
550                ))
551                .collect::<Vec<_>>()
552                .join("")
553        )
554    }
555}
556
557impl Default for WsSecurityConfig {
558    fn default() -> Self {
559        Self {
560            include_timestamp: true,
561            timestamp_ttl: Duration::minutes(5),
562            sign_message: false,
563            elements_to_sign: vec!["Body".to_string(), "Timestamp".to_string()],
564            signing_certificate: None,
565            signing_private_key: None,
566            include_certificate: true,
567            saml_token_endpoint: None,
568            actor: None,
569        }
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    #[test]
578    fn test_username_token_creation() {
579        let config = WsSecurityConfig::default();
580        let client = WsSecurityClient::new(config);
581
582        let header = client
583            .create_username_token_header("testuser", Some("testpass"), PasswordType::PasswordText)
584            .unwrap();
585
586        assert!(header.username_token.is_some());
587        let token = header.username_token.unwrap();
588        assert_eq!(token.username, "testuser");
589        assert!(token.password.is_some());
590    }
591
592    #[test]
593    fn test_password_digest() {
594        let config = WsSecurityConfig::default();
595        let client = WsSecurityClient::new(config);
596
597        let nonce = "MTIzNDU2Nzg5MDEyMzQ1Ng=="; // base64 of "1234567890123456"
598        let created = DateTime::parse_from_rfc3339("2023-01-01T12:00:00Z")
599            .unwrap()
600            .with_timezone(&Utc);
601        let password = "secret";
602
603        let digest = client
604            .compute_password_digest(password, nonce, &created)
605            .unwrap();
606        assert!(!digest.is_empty());
607    }
608
609    #[test]
610    fn test_timestamp_creation() {
611        let config = WsSecurityConfig::default();
612        let client = WsSecurityClient::new(config);
613
614        let timestamp = client.create_timestamp();
615        assert!(timestamp.expires > timestamp.created);
616        assert!(timestamp.wsu_id.is_some());
617    }
618
619    #[test]
620    fn test_xml_generation() {
621        let config = WsSecurityConfig::default();
622        let client = WsSecurityClient::new(config);
623
624        let header = client
625            .create_username_token_header("testuser", Some("testpass"), PasswordType::PasswordText)
626            .unwrap();
627
628        let xml = client.header_to_xml(&header).unwrap();
629        assert!(xml.contains("<wsse:Security"));
630        assert!(xml.contains("<wsse:UsernameToken"));
631        assert!(xml.contains("testuser"));
632        assert!(xml.contains("</wsse:Security>"));
633    }
634
635    #[test]
636    fn test_certificate_header() {
637        let config = WsSecurityConfig::default();
638        let client = WsSecurityClient::new(config);
639
640        let dummy_cert = b"dummy certificate data";
641        let header = client.create_certificate_header(dummy_cert).unwrap();
642
643        assert!(header.binary_security_token.is_some());
644        let bst = header.binary_security_token.unwrap();
645        assert_eq!(bst.value, STANDARD.encode(dummy_cert));
646    }
647}