Skip to main content

zerodds_soap/
security.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! WS-Security 1.1 — OASIS WSS-1.1.
5//!
6//! Spec: `http://docs.oasis-open.org/wss-m/wss/v1.1/`.
7//!
8//! Wir liefern die zentralen Token-Typen:
9//!
10//! * UsernameToken (UsernameToken Profile 1.1, §3.1).
11//! * X.509-Token (Token Profile 1.1, §3.2).
12//! * Timestamp (WS-Security Core 1.1 §10.2).
13//!
14//! Cipher-Material + Signature-Computation ist Caller-Layer.
15
16use alloc::format;
17use alloc::string::String;
18use alloc::vec::Vec;
19
20/// WS-Security 1.1 SecurityHeader-Namespace.
21pub const WSSE_NS: &str =
22    "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd";
23/// WS-Security 1.1 Utility-Namespace (`wsu:`).
24pub const WSU_NS: &str =
25    "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd";
26
27/// UsernameToken — WSS UsernameToken Profile 1.1.
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub struct UsernameToken {
30    /// Username.
31    pub username: String,
32    /// Password (Cleartext oder Digest — Caller entscheidet).
33    pub password: String,
34    /// `Type`-Attribut auf dem `<wsse:Password>`-Element.
35    pub password_type: PasswordType,
36    /// Optional Nonce (Base64-codiert).
37    pub nonce: Option<String>,
38    /// Optional Created-Timestamp (XSD-DateTime).
39    pub created: Option<String>,
40}
41
42/// Password-Type. WSS UsernameToken §3.1.1.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum PasswordType {
45    /// `#PasswordText` — Cleartext.
46    #[default]
47    Text,
48    /// `#PasswordDigest` — Caller liefert SHA1(nonce + created +
49    /// password) base64-codiert.
50    Digest,
51}
52
53impl PasswordType {
54    /// Spec-URI.
55    #[must_use]
56    pub fn type_uri(self) -> &'static str {
57        match self {
58            Self::Text => {
59                "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText"
60            }
61            Self::Digest => {
62                "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest"
63            }
64        }
65    }
66}
67
68/// X.509-Token — WSS X.509 Token Profile 1.1.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct X509Token {
71    /// Cert (Base64- oder Hex-codiert).
72    pub cert_b64: String,
73    /// `EncodingType`-Attribut auf `<wsse:BinarySecurityToken>`.
74    pub encoding_type: String,
75    /// `ValueType`-Attribut.
76    pub value_type: String,
77}
78
79impl Default for X509Token {
80    fn default() -> Self {
81        Self {
82            cert_b64: String::new(),
83            encoding_type: "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary".into(),
84            value_type: "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3".into(),
85        }
86    }
87}
88
89/// `<wsu:Timestamp>` — WS-Security Core §10.2.
90#[derive(Debug, Clone, PartialEq, Eq, Default)]
91pub struct Timestamp {
92    /// `<wsu:Created>` — XSD-DateTime.
93    pub created: String,
94    /// Optional `<wsu:Expires>`.
95    pub expires: Option<String>,
96}
97
98/// SecurityHeader — kombiniert Tokens + Timestamp.
99#[derive(Debug, Clone, PartialEq, Eq, Default)]
100pub struct SecurityHeader {
101    /// Username-Tokens.
102    pub usernames: Vec<UsernameToken>,
103    /// X.509-Tokens.
104    pub x509: Vec<X509Token>,
105    /// Optional Timestamp.
106    pub timestamp: Option<Timestamp>,
107    /// `mustUnderstand`-Flag (default false).
108    pub must_understand: bool,
109}
110
111impl SecurityHeader {
112    /// Render zu XML — als Inhalt eines `<soap:Header>`. Spec
113    /// `<wsse:Security>` Skeleton.
114    #[must_use]
115    pub fn to_xml(&self) -> String {
116        let mu = if self.must_understand {
117            " soap:mustUnderstand=\"1\""
118        } else {
119            ""
120        };
121        let mut out =
122            format!("<wsse:Security xmlns:wsse=\"{WSSE_NS}\" xmlns:wsu=\"{WSU_NS}\"{mu}>");
123        if let Some(ts) = &self.timestamp {
124            out.push_str("<wsu:Timestamp>");
125            out.push_str(&format!("<wsu:Created>{}</wsu:Created>", ts.created));
126            if let Some(exp) = &ts.expires {
127                out.push_str(&format!("<wsu:Expires>{exp}</wsu:Expires>"));
128            }
129            out.push_str("</wsu:Timestamp>");
130        }
131        for u in &self.usernames {
132            out.push_str("<wsse:UsernameToken>");
133            out.push_str(&format!("<wsse:Username>{}</wsse:Username>", u.username));
134            out.push_str(&format!(
135                "<wsse:Password Type=\"{}\">{}</wsse:Password>",
136                u.password_type.type_uri(),
137                xml_escape(&u.password)
138            ));
139            if let Some(n) = &u.nonce {
140                out.push_str(&format!("<wsse:Nonce>{n}</wsse:Nonce>"));
141            }
142            if let Some(c) = &u.created {
143                out.push_str(&format!("<wsu:Created>{c}</wsu:Created>"));
144            }
145            out.push_str("</wsse:UsernameToken>");
146        }
147        for x in &self.x509 {
148            out.push_str(&format!(
149                "<wsse:BinarySecurityToken EncodingType=\"{}\" ValueType=\"{}\">{}</wsse:BinarySecurityToken>",
150                x.encoding_type, x.value_type, x.cert_b64
151            ));
152        }
153        out.push_str("</wsse:Security>");
154        out
155    }
156}
157
158fn xml_escape(s: &str) -> String {
159    s.replace('&', "&amp;")
160        .replace('<', "&lt;")
161        .replace('>', "&gt;")
162}
163
164#[cfg(test)]
165#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn empty_header_still_emits_security_element() {
171        let h = SecurityHeader::default();
172        let xml = h.to_xml();
173        assert!(xml.starts_with("<wsse:Security"));
174        assert!(xml.ends_with("</wsse:Security>"));
175    }
176
177    #[test]
178    fn username_token_text_password_emits_correct_type() {
179        let mut h = SecurityHeader::default();
180        h.usernames.push(UsernameToken {
181            username: "alice".into(),
182            password: "secret".into(),
183            password_type: PasswordType::Text,
184            ..UsernameToken::default()
185        });
186        let xml = h.to_xml();
187        assert!(xml.contains("<wsse:Username>alice</wsse:Username>"));
188        assert!(xml.contains("PasswordText"));
189        assert!(xml.contains(">secret<"));
190    }
191
192    #[test]
193    fn username_token_digest_uses_digest_uri() {
194        let mut h = SecurityHeader::default();
195        h.usernames.push(UsernameToken {
196            username: "alice".into(),
197            password: "abc=".into(),
198            password_type: PasswordType::Digest,
199            nonce: Some("nonce==".into()),
200            created: Some("2026-04-01T00:00:00Z".into()),
201        });
202        let xml = h.to_xml();
203        assert!(xml.contains("PasswordDigest"));
204        assert!(xml.contains("<wsse:Nonce>nonce=="));
205        assert!(xml.contains("<wsu:Created>2026-04-01T00:00:00Z</wsu:Created>"));
206    }
207
208    #[test]
209    fn timestamp_emits_created_and_expires() {
210        let h = SecurityHeader {
211            timestamp: Some(Timestamp {
212                created: "2026-04-01T00:00:00Z".into(),
213                expires: Some("2026-04-01T00:05:00Z".into()),
214            }),
215            ..SecurityHeader::default()
216        };
217        let xml = h.to_xml();
218        assert!(xml.contains("<wsu:Created>2026-04-01T00:00:00Z</wsu:Created>"));
219        assert!(xml.contains("<wsu:Expires>2026-04-01T00:05:00Z</wsu:Expires>"));
220    }
221
222    #[test]
223    fn x509_token_default_uses_v3_value_type() {
224        let mut h = SecurityHeader::default();
225        h.x509.push(X509Token {
226            cert_b64: "MIIB...".into(),
227            ..X509Token::default()
228        });
229        let xml = h.to_xml();
230        assert!(xml.contains("BinarySecurityToken"));
231        assert!(xml.contains("X509v3"));
232        assert!(xml.contains("MIIB..."));
233    }
234
235    #[test]
236    fn must_understand_flag_emitted() {
237        let h = SecurityHeader {
238            must_understand: true,
239            ..SecurityHeader::default()
240        };
241        let xml = h.to_xml();
242        assert!(xml.contains("soap:mustUnderstand=\"1\""));
243    }
244
245    #[test]
246    fn password_xml_escaped() {
247        let mut h = SecurityHeader::default();
248        h.usernames.push(UsernameToken {
249            username: "u".into(),
250            password: "<bad>&".into(),
251            password_type: PasswordType::Text,
252            ..UsernameToken::default()
253        });
254        let xml = h.to_xml();
255        assert!(xml.contains("&lt;bad&gt;&amp;"));
256        assert!(!xml.contains("<bad>"));
257    }
258
259    #[test]
260    fn password_type_uris_match_spec() {
261        assert!(PasswordType::Text.type_uri().contains("PasswordText"));
262        assert!(PasswordType::Digest.type_uri().contains("PasswordDigest"));
263    }
264}