Skip to main content

xml_sec/xmlenc/
hkdf_agreement.rs

1//! RFC 9231 HKDF XML adapter, distinct from libxmlsec1's HKDFParams layout.
2
3use super::parse::{bounded_simple_text_with_limit, require_element, validate_metadata_len};
4use super::types::{XMLDSIG_NS, XMLENC_NS};
5use super::{KeyDerivationMethod, KeyEstablishmentBudget, XmlEncError, map_document_error};
6use crate::document::{
7    DocumentParseSettings, XmlParseWorkBudget, parse_borrowed_with_settings_and_budget,
8};
9use crate::xml::dom::Node;
10
11const MORE: &str = "http://www.w3.org/2021/04/xmldsig-more#";
12const HKDF: &str = "http://www.w3.org/2021/04/xmldsig-more#hkdf";
13
14/// Normalized RFC 9231 HKDF invocation, with optional XML-supplied IKM.
15///
16/// RFC 9231 section 2.8.1 permits IKM to be absent when known out of band.
17/// Salt and KA-Nonce are hexadecimal in this profile, not base64. Parsing
18/// neither authorizes execution nor selects a security policy.
19pub struct HkdfAgreement {
20    method: KeyDerivationMethod,
21    ikm: Option<zeroize::Zeroizing<Vec<u8>>>,
22}
23
24impl core::fmt::Debug for HkdfAgreement {
25    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
26        formatter
27            .debug_struct("HkdfAgreement")
28            .field("method", &self.method)
29            .field("has_embedded_ikm", &self.ikm.is_some())
30            .finish()
31    }
32}
33
34impl HkdfAgreement {
35    /// Borrow normalized parameters without exposing embedded initial key material.
36    pub fn key_derivation_method(&self) -> &KeyDerivationMethod {
37        &self.method
38    }
39
40    /// Derive with XML-supplied IKM or an explicit out-of-band request value.
41    /// Supplying both is an ambiguous request and is rejected, never overridden.
42    pub fn derive_key(
43        &self,
44        output_len: usize,
45        external_ikm: Option<&[u8]>,
46        provider: &dyn crate::provider::CryptoProvider,
47        budget: &mut KeyEstablishmentBudget<'_>,
48    ) -> Result<zeroize::Zeroizing<Vec<u8>>, XmlEncError> {
49        let ikm = match (&self.ikm, external_ikm) {
50            (Some(ikm), None) => ikm.as_slice(),
51            (None, Some(ikm)) => ikm,
52            (None, None) => return Err(XmlEncError::MissingRequired("HKDF initial key material")),
53            (Some(_), Some(_)) => return Err(invalid("HKDF initial key material supplied twice")),
54        };
55        self.method.derive_key(output_len, provider, ikm, budget)
56    }
57}
58
59/// Parse RFC 9231 section 2.8.1 using the operation-selected backend and policy.
60/// This is a parameter adapter, not an asymmetric AgreementMethod resolver.
61pub fn parse_hkdf_agreement_method(
62    xml: &str,
63    policy: &crate::policy::DecryptionPolicy,
64    backend: crate::XmlBackend,
65) -> Result<HkdfAgreement, XmlEncError> {
66    policy.validate()?;
67    policy.resources.validate_xml_document_len(xml.len())?;
68    let settings =
69        DocumentParseSettings::from_policy(&policy.xml, &policy.resources).with_backend(backend);
70    let budget = XmlParseWorkBudget::from_resources(&policy.resources);
71    let document = parse_borrowed_with_settings_and_budget(xml, settings, Some(&budget))
72        .map_err(|error| map_document_error(error, settings))?;
73    parse_node(
74        document.root_element(),
75        policy.resources.max_encryption_metadata_bytes,
76    )
77}
78
79pub(super) fn parse_node(node: Node<'_, '_>, maximum: usize) -> Result<HkdfAgreement, XmlEncError> {
80    require_element(node, XMLENC_NS, "AgreementMethod")?;
81    // RFC 9231's illustrative example lowercases "algorithm", but the XML
82    // Security wire attribute is case-sensitive Algorithm. No casing fallback.
83    // https://www.rfc-editor.org/rfc/rfc9231.html#section-2.8.1
84    // https://www.w3.org/TR/2013/REC-xmlenc-core1-20130411/#sec-AgreementMethod
85    let algorithm = node
86        .attribute("Algorithm")
87        .ok_or(XmlEncError::MissingRequired("AgreementMethod Algorithm"))?;
88    validate_metadata_len(algorithm.len(), maximum)?;
89    if algorithm != HKDF {
90        return Err(XmlEncError::UnsupportedAlgorithm(algorithm.into()));
91    }
92    let mut digest = None;
93    let mut salt = None;
94    let mut info = None;
95    let mut ikm = None;
96    let mut size = None;
97    for child in node.children() {
98        if child.is_text()
99            && child
100                .text()
101                .is_some_and(|text| !text.bytes().all(xml_space))
102        {
103            return Err(invalid("HKDF AgreementMethod contains character data"));
104        }
105        if !child.is_element() {
106            continue;
107        }
108        match (child.tag_name().namespace(), child.tag_name().name()) {
109            (Some(XMLDSIG_NS), "DigestMethod") if digest.is_none() => {
110                if child.children().any(|node| {
111                    node.is_element()
112                        || (node.is_text()
113                            && node.text().is_some_and(|text| !text.bytes().all(xml_space)))
114                }) {
115                    return Err(invalid("HKDF DigestMethod parameters are unsupported"));
116                }
117                let value = child
118                    .attribute("Algorithm")
119                    .ok_or(XmlEncError::MissingRequired("DigestMethod Algorithm"))?;
120                validate_metadata_len(value.len(), maximum)?;
121                // The normative field selects the hash function; the RFC's
122                // example names HMAC-SHA256 instead. Both identify the same
123                // underlying HKDF hash, unlike an arbitrary signature URI.
124                // RFC 9231 section 2.8.1: hash function and worked example.
125                use crate::xmldsig::{DigestAlgorithm as D, SignatureAlgorithm as S};
126                let prf = match D::from_uri(value) {
127                    Some(D::Sha1) => S::HmacSha1,
128                    Some(D::Sha224) => S::HmacSha224,
129                    Some(D::Sha256) => S::HmacSha256,
130                    Some(D::Sha384) => S::HmacSha384,
131                    Some(D::Sha512) => S::HmacSha512,
132                    _ => match S::from_uri(value) {
133                        Some(
134                            prf @ (S::HmacSha1
135                            | S::HmacSha224
136                            | S::HmacSha256
137                            | S::HmacSha384
138                            | S::HmacSha512),
139                        ) => prf,
140                        _ => return Err(XmlEncError::UnsupportedAlgorithm(value.into())),
141                    },
142                };
143                digest = Some(prf.uri().to_owned());
144            }
145            (Some(MORE), "Salt") if salt.is_none() => salt = Some(hex_text(child, maximum)?),
146            (Some(XMLENC_NS), "KA-Nonce") if info.is_none() => {
147                info = Some(hex_text(child, maximum)?)
148            }
149            (Some(XMLENC_NS), "OriginatorKeyInfo") if ikm.is_none() => {
150                ikm = Some(zeroize::Zeroizing::new(hex_text(child, maximum)?))
151            }
152            (Some(XMLENC_NS), "KeySize") if size.is_none() => {
153                // RFC 9231 section 2.8.1's RFC 5869 A.1 example sets KeySize=42
154                // for L=42 OCTETS. This profile's field is not EncryptionMethod's
155                // KeySize in bits. Preserve the profile rather than dividing by 8.
156                let text = bounded_simple_text_with_limit(child, "HKDF KeySize", maximum)?;
157                let text = trim_xml_space(&text);
158                if text.is_empty() || !text.bytes().all(|byte| byte.is_ascii_digit()) {
159                    return Err(invalid(
160                        "HKDF KeySize must be a positive decimal octet count",
161                    ));
162                }
163                size = Some(
164                    text.parse::<usize>()
165                        .ok()
166                        .filter(|value| *value != 0)
167                        .ok_or_else(|| invalid("HKDF KeySize exceeds platform width or is zero"))?,
168                );
169            }
170            _ => {
171                return Err(invalid(
172                    "unexpected or duplicate HKDF AgreementMethod parameter",
173                ));
174            }
175        }
176    }
177    // RFC 9231 section 2.8.1 makes salt and info optional. RFC 5869
178    // sections 2.2-2.3 define absent salt as HashLen zeros and absent info as
179    // empty. The HMAC primitive's empty key has the same zero-padded key block.
180    let method = KeyDerivationMethod::hkdf(
181        digest.ok_or(XmlEncError::MissingRequired("HKDF DigestMethod"))?,
182        salt.unwrap_or_default(),
183        info.unwrap_or_default(),
184        size.ok_or(XmlEncError::MissingRequired("HKDF KeySize"))?,
185    );
186    Ok(HkdfAgreement { method, ikm })
187}
188
189fn xml_space(byte: u8) -> bool {
190    matches!(byte, b' ' | b'\t' | b'\r' | b'\n')
191}
192fn trim_xml_space(text: &str) -> &str {
193    text.trim_matches(|value| matches!(value, ' ' | '\t' | '\r' | '\n'))
194}
195fn invalid(message: &str) -> XmlEncError {
196    XmlEncError::InvalidStructure(message.into())
197}
198
199fn hex_text(node: Node<'_, '_>, maximum: usize) -> Result<Vec<u8>, XmlEncError> {
200    // Validate all text fragments before reserving the one decoded buffer.
201    // Entity/CDATA/comment boundaries do not require an intermediate secret
202    // String, and whitespace is allowed only around the hexBinary value.
203    let mut text_bytes = 0usize;
204    let mut digits = 0usize;
205    let mut trailing_space = false;
206    for child in node.children() {
207        if child.is_element() {
208            return Err(invalid("HKDF hex parameter contains an element"));
209        }
210        if !child.is_text() {
211            continue;
212        }
213        let text = child.text().unwrap_or_default();
214        text_bytes = text_bytes
215            .checked_add(text.len())
216            .ok_or_else(|| invalid("HKDF hex parameter length overflow"))?;
217        validate_metadata_len(text_bytes, maximum)?;
218        for byte in text.bytes() {
219            if xml_space(byte) {
220                if digits != 0 {
221                    trailing_space = true;
222                }
223            } else if byte.is_ascii_hexdigit() && !trailing_space {
224                digits += 1;
225            } else {
226                return Err(invalid("HKDF parameter must be hexadecimal octets"));
227            }
228        }
229    }
230    if !digits.is_multiple_of(2) {
231        return Err(invalid("HKDF parameter must be hexadecimal octets"));
232    }
233    let mut bytes = Vec::with_capacity(digits / 2);
234    let digit = |value: u8| {
235        if value <= b'9' {
236            value - b'0'
237        } else {
238            (value | 32) - b'a' + 10
239        }
240    };
241    let mut high = None;
242    for child in node.children().filter(Node::is_text) {
243        for byte in child.text().unwrap_or_default().bytes() {
244            if xml_space(byte) {
245                continue;
246            }
247            match high.take() {
248                Some(value) => bytes.push(value | digit(byte)),
249                None => high = Some(digit(byte) << 4),
250            }
251        }
252    }
253    debug_assert!(high.is_none());
254    Ok(bytes)
255}