hanko_xml 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +44 -0
  3. data/Cargo.lock +401 -0
  4. data/Cargo.toml +7 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +222 -0
  7. data/THIRD_PARTY_LICENSES.md +392 -0
  8. data/dependencies.yml +87 -0
  9. data/ext/hanko_xml/Cargo.toml +20 -0
  10. data/ext/hanko_xml/build/vendor.rs +817 -0
  11. data/ext/hanko_xml/build.rs +298 -0
  12. data/ext/hanko_xml/extconf.rb +6 -0
  13. data/ext/hanko_xml/src/dsig.rs +1851 -0
  14. data/ext/hanko_xml/src/errors.rs +151 -0
  15. data/ext/hanko_xml/src/lib.rs +490 -0
  16. data/ext/hanko_xml/src/sys.rs +43 -0
  17. data/lib/hanko_xml/algorithms.rb +228 -0
  18. data/lib/hanko_xml/dom.rb +137 -0
  19. data/lib/hanko_xml/errors.rb +132 -0
  20. data/lib/hanko_xml/exc-c14n.xsd +26 -0
  21. data/lib/hanko_xml/input.rb +50 -0
  22. data/lib/hanko_xml/key.rb +213 -0
  23. data/lib/hanko_xml/key_info.rb +67 -0
  24. data/lib/hanko_xml/native_version.rb +75 -0
  25. data/lib/hanko_xml/nokogiri_ext.rb +34 -0
  26. data/lib/hanko_xml/placement.rb +127 -0
  27. data/lib/hanko_xml/policy.rb +502 -0
  28. data/lib/hanko_xml/reference.rb +49 -0
  29. data/lib/hanko_xml/signature_info.rb +134 -0
  30. data/lib/hanko_xml/signed_document.rb +155 -0
  31. data/lib/hanko_xml/signer.rb +572 -0
  32. data/lib/hanko_xml/template.rb +150 -0
  33. data/lib/hanko_xml/transform.rb +87 -0
  34. data/lib/hanko_xml/verification.rb +377 -0
  35. data/lib/hanko_xml/verifier.rb +694 -0
  36. data/lib/hanko_xml/version.rb +5 -0
  37. data/lib/hanko_xml/xades/verifier.rb +319 -0
  38. data/lib/hanko_xml/xades.rb +287 -0
  39. data/lib/hanko_xml/xml.rb +93 -0
  40. data/lib/hanko_xml/xmldsig-core-schema.xsd +332 -0
  41. data/lib/hanko_xml.rb +122 -0
  42. data/sig/hanko_xml/key.rbs +48 -0
  43. data/sig/hanko_xml/native.rbs +12 -0
  44. data/sig/hanko_xml/native_version.rbs +13 -0
  45. data/sig/hanko_xml/nokogiri_ext.rbs +23 -0
  46. data/sig/hanko_xml/placement.rbs +27 -0
  47. data/sig/hanko_xml/policy.rbs +83 -0
  48. data/sig/hanko_xml/reference.rbs +36 -0
  49. data/sig/hanko_xml/signed_document.rbs +66 -0
  50. data/sig/hanko_xml/signer.rbs +50 -0
  51. data/sig/hanko_xml/template.rbs +41 -0
  52. data/sig/hanko_xml/verification.rbs +100 -0
  53. data/sig/hanko_xml/verifier.rbs +45 -0
  54. data/sig/hanko_xml/xades.rbs +70 -0
  55. data/sig/hanko_xml.rbs +62 -0
  56. metadata +105 -0
@@ -0,0 +1,1851 @@
1
+ //! Safe wrappers around the raw [`crate::sys`] bindings, implementing
2
+ //! the §7 native contract: `sign_document`, `verify_document`,
3
+ //! `inspect_document`, `versions`, and `check_version`.
4
+ //!
5
+ //! Every FFI call in this module lives in its own minimal `unsafe` block
6
+ //! with a `// SAFETY:` comment justifying it. The public functions
7
+ //! (`sign_document`, `verify_document`, `inspect_document`) contain no
8
+ //! `unsafe` — the RAII types below each own exactly one C resource and
9
+ //! free it on `Drop`.
10
+ //!
11
+ //! Phase 2 changes from the POC:
12
+ //! - `XmlDoc<'a>` and `XmlNode<'a>` are deleted — bytes-only I/O means
13
+ //! every document is `OwnedXmlDoc`, never borrowed.
14
+ //! - `XmlSecKey` added (with `into_raw()`) for signing.
15
+ //! - `errors` module captures xmlsec diagnostics (no more stderr).
16
+ //! - All functions take `id_attributes` + `signature_index`.
17
+
18
+ use crate::errors;
19
+ use crate::sys;
20
+ use std::cell::RefCell;
21
+ use std::collections::HashMap;
22
+ use std::ffi::CStr;
23
+ use std::os::raw::{c_char, c_int, c_void};
24
+ use std::ptr;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // OwnedXmlDoc — owned libxml2 document
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /// An owned libxml2 document parsed from an in-memory buffer. Frees itself
31
+ /// on drop.
32
+ pub struct OwnedXmlDoc(sys::xmlDocPtr);
33
+
34
+ /// Safely frees memory allocated by libxml2 functions (xmlGetProp, xmlNodeGetContent, xmlDocDumpMemory).
35
+ unsafe fn xml_free(ptr: *mut std::os::raw::c_void) {
36
+ if !ptr.is_null() {
37
+ extern "C" {
38
+ static xmlFree: Option<unsafe extern "C" fn(*mut std::os::raw::c_void)>;
39
+ }
40
+ if let Some(free_fn) = xmlFree {
41
+ free_fn(ptr);
42
+ } else {
43
+ extern "C" {
44
+ fn free(ptr: *mut std::os::raw::c_void);
45
+ }
46
+ free(ptr);
47
+ }
48
+ }
49
+ }
50
+
51
+ /// Which Ruby exception a native failure maps to (S-13).
52
+ ///
53
+ /// Decided at the call site that failed, never by looking at the message:
54
+ /// the xmlsec log can contain document text (algorithm hrefs, URIs, node
55
+ /// names), so matching words in it let a document pick its own exception
56
+ /// class.
57
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
58
+ pub enum ErrorKind {
59
+ Parse,
60
+ KeyLoad,
61
+ Signing,
62
+ Native,
63
+ }
64
+
65
+ /// A failure from one of the public entry points below. `message` is always
66
+ /// a fixed string from this file, so it never carries document content; the
67
+ /// xmlsec log for the call is drained separately by the caller.
68
+ #[derive(Debug)]
69
+ pub struct DsigError {
70
+ pub kind: ErrorKind,
71
+ pub message: &'static str,
72
+ }
73
+
74
+ impl DsigError {
75
+ fn parse(message: &'static str) -> Self {
76
+ Self { kind: ErrorKind::Parse, message }
77
+ }
78
+
79
+ fn key_load(message: &'static str) -> Self {
80
+ Self { kind: ErrorKind::KeyLoad, message }
81
+ }
82
+
83
+ fn signing(message: &'static str) -> Self {
84
+ Self { kind: ErrorKind::Signing, message }
85
+ }
86
+
87
+ fn native(message: &'static str) -> Self {
88
+ Self { kind: ErrorKind::Native, message }
89
+ }
90
+ }
91
+
92
+ /// Converts a buffer length to the integer type an FFI function takes (S-12).
93
+ ///
94
+ /// A plain `as` cast wraps silently: past `i32::MAX` an `int` length goes
95
+ /// negative, and past `u32::MAX` an unsigned one keeps only the low 32 bits,
96
+ /// so libxml2/xmlsec would read a different number of bytes than the caller
97
+ /// passed. The width of `xmlSecSize` also differs between xmlsec 1.2 and 1.3,
98
+ /// hence the generic target.
99
+ fn ffi_len<T: TryFrom<usize>>(len: usize) -> Option<T> {
100
+ T::try_from(len).ok()
101
+ }
102
+
103
+ impl OwnedXmlDoc {
104
+ /// Parses `xml` bytes into a libxml2 document.
105
+ ///
106
+ /// Documents longer than `c_int::MAX` bytes (about 2 GiB) are rejected
107
+ /// rather than truncated (S-12).
108
+ ///
109
+ /// libxml2's diagnostics go to the error buffer, never stderr. A
110
+ /// recoverable error (an undefined namespace prefix, an invalid
111
+ /// `xml:id`) still yields a document; see [`Self::parse_strict`].
112
+ pub fn parse(xml: &[u8]) -> Result<Self, &'static str> {
113
+ let len: std::os::raw::c_int = ffi_len(xml.len())
114
+ .ok_or("failed to parse XML document: larger than the 2 GiB libxml2 limit")?;
115
+ let _capture = Libxml2Capture::start();
116
+ // Never NOENT, DTDLOAD or HUGE; NONET so nothing is fetched (§6.3).
117
+ let options = sys::xmlParserOption_XML_PARSE_NONET as std::os::raw::c_int;
118
+ // SAFETY: xmlReadMemory reads exactly len (== xml.len()) bytes
119
+ // starting at xml.as_ptr() and copies them into buffers it manages
120
+ // itself, so xml doesn't need to outlive this call.
121
+ let ptr = unsafe {
122
+ sys::xmlReadMemory(xml.as_ptr().cast(), len, ptr::null(), ptr::null(), options)
123
+ };
124
+ if ptr.is_null() {
125
+ return Err("failed to parse XML document");
126
+ }
127
+ Ok(Self(ptr))
128
+ }
129
+
130
+ /// Like [`Self::parse`], but any error-level libxml2 diagnostic fails
131
+ /// the parse: the rule `HankoXml.parse` applies with Nokogiri (strict,
132
+ /// `doc.errors` empty), so input given as bytes is held to the same
133
+ /// standard without a DOM (#102).
134
+ pub fn parse_strict(xml: &[u8]) -> Result<Self, &'static str> {
135
+ let before = errors::libxml2_error_count();
136
+ let doc = Self::parse(xml)?;
137
+ if errors::libxml2_error_count() != before {
138
+ return Err("the document is not well-formed XML");
139
+ }
140
+ Ok(doc)
141
+ }
142
+
143
+ /// Returns the root element, or None if the document is empty.
144
+ pub fn root_element(&self) -> Option<sys::xmlNodePtr> {
145
+ // SAFETY: self.0 is valid (parse only wraps non-null).
146
+ let root = unsafe { sys::xmlDocGetRootElement(self.0) };
147
+ (!root.is_null()).then_some(root)
148
+ }
149
+
150
+ /// Registers ID-attribute names so `URI="#foo"` can resolve.
151
+ ///
152
+ /// `id_attrs` is a list of unqualified attribute name strings
153
+ /// (e.g. `["Id", "ID", "id"]`).
154
+ pub fn register_id_attributes(&self, root: sys::xmlNodePtr, id_attrs: &[String]) {
155
+ if id_attrs.is_empty() {
156
+ return;
157
+ }
158
+ // Build a NUL-terminated array of NUL-terminated C strings.
159
+ let c_strings: Vec<Vec<u8>> = id_attrs
160
+ .iter()
161
+ .map(|s| {
162
+ let mut v = s.as_bytes().to_vec();
163
+ v.push(0);
164
+ v
165
+ })
166
+ .collect();
167
+ let mut ptrs: Vec<*const sys::xmlChar> = c_strings
168
+ .iter()
169
+ .map(|v| v.as_ptr().cast())
170
+ .collect();
171
+ ptrs.push(ptr::null()); // NUL-terminated sentinel
172
+
173
+ // SAFETY: self.0 and root are valid; ptrs is a NUL-terminated
174
+ // array of NUL-terminated byte strings.
175
+ unsafe { sys::xmlSecAddIDs(self.0, root, ptrs.as_mut_ptr()) }
176
+ }
177
+
178
+ /// Finds the nth `<ds:Signature>` element in document order (0-based).
179
+ /// Returns the node pointer, or None if there are fewer than
180
+ /// `index + 1` signatures.
181
+ pub fn find_nth_signature(&self, root: sys::xmlNodePtr, index: usize) -> Option<sys::xmlNodePtr> {
182
+ let mut signatures = Vec::new();
183
+ collect_signature_nodes(root, &mut signatures);
184
+ signatures.get(index).copied()
185
+ }
186
+
187
+ /// Counts the total number of `<ds:Signature>` elements.
188
+ pub fn count_signatures(&self, root: sys::xmlNodePtr) -> usize {
189
+ let mut signatures = Vec::new();
190
+ collect_signature_nodes(root, &mut signatures);
191
+ signatures.len()
192
+ }
193
+
194
+ /// Serializes the document to an XML byte string.
195
+ pub fn serialize(&self) -> Result<Vec<u8>, &'static str> {
196
+ let mut buf: *mut sys::xmlChar = ptr::null_mut();
197
+ let mut size: i32 = 0;
198
+
199
+ // SAFETY: self.0 is valid; xmlDocDumpMemory writes into its own
200
+ // allocated buffer and returns the pointer + length.
201
+ unsafe { sys::xmlDocDumpMemory(self.0, &mut buf, &mut size) };
202
+
203
+ if buf.is_null() || size <= 0 {
204
+ return Err("failed to serialize XML document");
205
+ }
206
+
207
+ // SAFETY: buf is non-null, allocated by libxml2, and size is its
208
+ // length. We copy the bytes out, then free the libxml2 buffer.
209
+ let bytes = unsafe { std::slice::from_raw_parts(buf as *const u8, size as usize) }.to_vec();
210
+ unsafe { xml_free(buf as *mut _) };
211
+
212
+ Ok(bytes)
213
+ }
214
+ }
215
+
216
+ impl Drop for OwnedXmlDoc {
217
+ fn drop(&mut self) {
218
+ // SAFETY: we're the exclusive owner of this doc.
219
+ unsafe { sys::xmlFreeDoc(self.0) }
220
+ }
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Libxml2Capture — libxml2 diagnostics into the error buffer
225
+ // ---------------------------------------------------------------------------
226
+
227
+ /// Routes libxml2's structured errors on this thread to
228
+ /// [`errors::libxml2_error_handler`] until dropped, then removes the handler.
229
+ /// Nokogiri installs its own handler around each of its calls the same way,
230
+ /// so the two never see each other's errors even on one libxml2 (§3.1).
231
+ struct Libxml2Capture;
232
+
233
+ impl Libxml2Capture {
234
+ fn start() -> Self {
235
+ // SAFETY: sets a thread-local libxml2 global to a valid handler
236
+ // with a null context; the handler never dereferences the context.
237
+ unsafe { sys::xmlSetStructuredErrorFunc(ptr::null_mut(), errors::libxml2_error_handler()) };
238
+ Self
239
+ }
240
+ }
241
+
242
+ impl Drop for Libxml2Capture {
243
+ fn drop(&mut self) {
244
+ // SAFETY: restores libxml2's default (no structured handler).
245
+ unsafe { sys::xmlSetStructuredErrorFunc(ptr::null_mut(), None) };
246
+ }
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Signature node collection
251
+ // ---------------------------------------------------------------------------
252
+
253
+ /// Recursively collects all `<ds:Signature>` nodes in document order.
254
+ fn collect_signature_nodes(node: sys::xmlNodePtr, result: &mut Vec<sys::xmlNodePtr>) {
255
+ if node.is_null() {
256
+ return;
257
+ }
258
+
259
+ // SAFETY: node is non-null and valid within the document's lifetime.
260
+ let node_ref = unsafe { &*node };
261
+
262
+ // Check if this is a <Signature> in the dsig namespace
263
+ if node_ref.type_ == sys::xmlElementType_XML_ELEMENT_NODE {
264
+ let name = unsafe { node_name(node) };
265
+ let ns_href = unsafe { node_ns_href(node) };
266
+
267
+ if name == "Signature" && ns_href == "http://www.w3.org/2000/09/xmldsig#" {
268
+ result.push(node);
269
+ }
270
+ }
271
+
272
+ // Recurse into children
273
+ let mut child = node_ref.children;
274
+ while !child.is_null() {
275
+ collect_signature_nodes(child, result);
276
+ child = unsafe { (*child).next };
277
+ }
278
+ }
279
+
280
+ /// Returns the local name of a node as a Rust string.
281
+ ///
282
+ /// # Safety
283
+ /// `node` must be non-null and point to a valid xmlNode.
284
+ unsafe fn node_name(node: sys::xmlNodePtr) -> String {
285
+ let name_ptr = (*node).name;
286
+ if name_ptr.is_null() {
287
+ return String::new();
288
+ }
289
+ let cstr = std::ffi::CStr::from_ptr(name_ptr as *const _);
290
+ cstr.to_str().unwrap_or("").to_string()
291
+ }
292
+
293
+ /// Returns the namespace href of a node, or "" if none.
294
+ ///
295
+ /// # Safety
296
+ /// `node` must be non-null and point to a valid xmlNode.
297
+ unsafe fn node_ns_href(node: sys::xmlNodePtr) -> String {
298
+ let ns = (*node).ns;
299
+ if ns.is_null() {
300
+ return String::new();
301
+ }
302
+ let href = (*ns).href;
303
+ if href.is_null() {
304
+ return String::new();
305
+ }
306
+ let cstr = std::ffi::CStr::from_ptr(href as *const _);
307
+ cstr.to_str().unwrap_or("").to_string()
308
+ }
309
+
310
+ /// Returns the text content of a node attribute, or None.
311
+ ///
312
+ /// # Safety
313
+ /// `node` must be non-null and valid. `attr_name` is a NUL-terminated C string.
314
+ unsafe fn get_attribute(node: sys::xmlNodePtr, attr_name: &[u8]) -> Option<String> {
315
+ let val = sys::xmlGetProp(node, attr_name.as_ptr().cast());
316
+ if val.is_null() {
317
+ return None;
318
+ }
319
+ let cstr = std::ffi::CStr::from_ptr(val as *const _);
320
+ let result = cstr.to_str().unwrap_or("").to_string();
321
+ unsafe { xml_free(val as *mut _) };
322
+ Some(result)
323
+ }
324
+
325
+ /// The namespace bindings in scope on an element, as (prefix, href) pairs;
326
+ /// the default namespace has an empty prefix.
327
+ ///
328
+ /// # Safety
329
+ /// `node` must be a non-null, valid element.
330
+ unsafe fn in_scope_namespaces(node: sys::xmlNodePtr) -> Vec<(String, String)> {
331
+ let list = sys::xmlGetNsList((*node).doc, node);
332
+ if list.is_null() {
333
+ return Vec::new();
334
+ }
335
+ let mut out = Vec::new();
336
+ let mut entry = list;
337
+ // The list is NULL-terminated and owned by us; its xmlNs entries belong
338
+ // to the document.
339
+ while !(*entry).is_null() {
340
+ let ns = &**entry;
341
+ let text = |ptr: *const sys::xmlChar| {
342
+ if ptr.is_null() {
343
+ String::new()
344
+ } else {
345
+ std::ffi::CStr::from_ptr(ptr.cast()).to_str().unwrap_or("").to_string()
346
+ }
347
+ };
348
+ out.push((text(ns.prefix), text(ns.href)));
349
+ entry = entry.add(1);
350
+ }
351
+ unsafe { xml_free(list.cast()) };
352
+ out
353
+ }
354
+
355
+ /// Returns the text content of a node (all child text concatenated).
356
+ ///
357
+ /// # Safety
358
+ /// `node` must be non-null and valid.
359
+ unsafe fn get_text_content(node: sys::xmlNodePtr) -> String {
360
+ let content = sys::xmlNodeGetContent(node);
361
+ if content.is_null() {
362
+ return String::new();
363
+ }
364
+ let cstr = std::ffi::CStr::from_ptr(content as *const _);
365
+ let result = cstr.to_str().unwrap_or("").to_string();
366
+ unsafe { xml_free(content as *mut _) };
367
+ result
368
+ }
369
+
370
+ /// Finds a direct child element with the given local name and namespace href.
371
+ ///
372
+ /// # Safety
373
+ /// `parent` must be non-null and valid.
374
+ unsafe fn find_child(parent: sys::xmlNodePtr, name: &str, ns_href: &str) -> Option<sys::xmlNodePtr> {
375
+ let mut child = (*parent).children;
376
+ while !child.is_null() {
377
+ let child_ref = &*child;
378
+ if child_ref.type_ == sys::xmlElementType_XML_ELEMENT_NODE
379
+ && node_name(child) == name
380
+ && node_ns_href(child) == ns_href
381
+ {
382
+ return Some(child);
383
+ }
384
+ child = child_ref.next;
385
+ }
386
+ None
387
+ }
388
+
389
+ /// Collects all direct child elements with the given local name and namespace href.
390
+ ///
391
+ /// # Safety
392
+ /// `parent` must be non-null and valid.
393
+ unsafe fn find_children(parent: sys::xmlNodePtr, name: &str, ns_href: &str) -> Vec<sys::xmlNodePtr> {
394
+ let mut result = Vec::new();
395
+ let mut child = (*parent).children;
396
+ while !child.is_null() {
397
+ let child_ref = &*child;
398
+ if child_ref.type_ == sys::xmlElementType_XML_ELEMENT_NODE
399
+ && node_name(child) == name
400
+ && node_ns_href(child) == ns_href
401
+ {
402
+ result.push(child);
403
+ }
404
+ child = child_ref.next;
405
+ }
406
+ result
407
+ }
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // XmlSecKey — RAII wrapper for xmlsec key
411
+ // ---------------------------------------------------------------------------
412
+
413
+ /// An xmlsec key loaded from PEM. Frees itself on drop unless ownership
414
+ /// is transferred via `into_raw()`.
415
+ pub struct XmlSecKey(sys::xmlSecKeyPtr);
416
+
417
+ impl XmlSecKey {
418
+ /// Loads a private or public key from PEM bytes.
419
+ pub fn from_pem(pem: &[u8]) -> Result<Self, &'static str> {
420
+ let len = ffi_len(pem.len()).ok_or("failed to load PEM key: too large")?;
421
+ // SAFETY: pem is a valid slice; xmlSecOpenSSLAppKeyLoadMemory
422
+ // copies what it needs. xmlsec tries a private key first, then a
423
+ // SubjectPublicKeyInfo / RSA public key — the `key:` verify path
424
+ // depends on that fallback.
425
+ let ptr = unsafe {
426
+ sys::xmlSecOpenSSLAppKeyLoadMemory(
427
+ pem.as_ptr(),
428
+ len,
429
+ sys::KEY_FORMAT_PEM,
430
+ ptr::null(), // no password (already decrypted in Ruby)
431
+ ptr::null_mut(),
432
+ ptr::null_mut(),
433
+ )
434
+ };
435
+ if ptr.is_null() {
436
+ return Err("failed to load PEM key");
437
+ }
438
+ Ok(Self(ptr))
439
+ }
440
+
441
+ /// Loads a public key from a PEM certificate.
442
+ ///
443
+ /// `xmlSecKeyDataFormatCertPem` makes xmlsec read the X.509 cert and
444
+ /// extract its SPKI, so the cert can be pinned as `signKey` the same
445
+ /// way a bare public-key PEM is. Without this, a cert PEM is only a
446
+ /// KeysMngr trust anchor and xmlsec still takes `<KeyValue>` from the
447
+ /// document (S-01).
448
+ pub fn from_cert_pem(pem: &[u8]) -> Result<Self, &'static str> {
449
+ let len = ffi_len(pem.len()).ok_or("failed to load PEM certificate: too large")?;
450
+ // SAFETY: pem is a valid slice; xmlSecOpenSSLAppKeyLoadMemory
451
+ // copies what it needs. KEY_FORMAT_CERT_PEM dispatches to
452
+ // xmlSecOpenSSLAppKeyFromCertLoadBIO rather than treating the
453
+ // bytes as a PKCS#1/SPKI key.
454
+ let ptr = unsafe {
455
+ sys::xmlSecOpenSSLAppKeyLoadMemory(
456
+ pem.as_ptr(),
457
+ len,
458
+ sys::KEY_FORMAT_CERT_PEM,
459
+ ptr::null(),
460
+ ptr::null_mut(),
461
+ ptr::null_mut(),
462
+ )
463
+ };
464
+ if ptr.is_null() {
465
+ return Err("failed to load PEM certificate");
466
+ }
467
+ Ok(Self(ptr))
468
+ }
469
+
470
+ /// Attaches a PEM certificate to this key (for KeyInfo population).
471
+ pub fn add_cert_pem(&self, cert_pem: &[u8]) -> Result<(), &'static str> {
472
+ let len = ffi_len(cert_pem.len()).ok_or("failed to attach certificate to key: too large")?;
473
+ // SAFETY: self.0 is valid; cert_pem is a valid slice.
474
+ let rc = unsafe {
475
+ sys::xmlSecOpenSSLAppKeyCertLoadMemory(
476
+ self.0,
477
+ cert_pem.as_ptr(),
478
+ len,
479
+ sys::KEY_FORMAT_PEM,
480
+ )
481
+ };
482
+ if rc < 0 {
483
+ return Err("failed to attach certificate to key");
484
+ }
485
+ Ok(())
486
+ }
487
+
488
+ /// Transfers ownership of the raw pointer. The caller is responsible
489
+ /// for freeing it (typically by adopting it into a KeysMngr or DsigCtx).
490
+ /// This `XmlSecKey` must not be used or dropped after this call.
491
+ pub fn into_raw(self) -> sys::xmlSecKeyPtr {
492
+ let ptr = self.0;
493
+ std::mem::forget(self);
494
+ ptr
495
+ }
496
+ }
497
+
498
+ impl Drop for XmlSecKey {
499
+ fn drop(&mut self) {
500
+ // SAFETY: we're the exclusive owner.
501
+ unsafe { sys::xmlSecKeyDestroy(self.0) }
502
+ }
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // KeysMngr — xmlsec keys manager
507
+ // ---------------------------------------------------------------------------
508
+
509
+ /// An xmlsec keys manager. Frees itself on drop.
510
+ struct KeysMngr(sys::xmlSecKeysMngrPtr);
511
+
512
+ impl KeysMngr {
513
+ /// Creates a new keys manager with trusted and untrusted certificates.
514
+ fn new(
515
+ trusted_pems: &[Vec<u8>],
516
+ untrusted_pems: &[Vec<u8>],
517
+ ) -> Result<Self, &'static str> {
518
+ // SAFETY: no arguments; returns either null or a freshly
519
+ // allocated manager we exclusively own.
520
+ let ptr = unsafe { sys::xmlSecKeysMngrCreate() };
521
+ if ptr.is_null() {
522
+ return Err("failed to create xmlsec keys manager");
523
+ }
524
+ let mngr = Self(ptr);
525
+
526
+ // SAFETY: mngr.0 is non-null and exclusively ours.
527
+ if unsafe { sys::xmlSecOpenSSLAppDefaultKeysMngrInit(mngr.0) } < 0 {
528
+ return Err("failed to initialize xmlsec keys manager");
529
+ }
530
+
531
+ for cert_pem in trusted_pems {
532
+ let len = ffi_len(cert_pem.len()).ok_or("failed to load trusted certificate: too large")?;
533
+ // SAFETY: mngr.0 is valid and initialized; cert_pem is a valid slice.
534
+ let loaded = unsafe {
535
+ sys::xmlSecOpenSSLAppKeysMngrCertLoadMemory(
536
+ mngr.0,
537
+ cert_pem.as_ptr(),
538
+ len,
539
+ sys::xmlSecKeyDataFormat_xmlSecKeyDataFormatPem,
540
+ sys::xmlSecKeyDataTypeTrusted,
541
+ )
542
+ };
543
+ if loaded < 0 {
544
+ return Err("failed to load trusted certificate");
545
+ }
546
+ }
547
+
548
+ for cert_pem in untrusted_pems {
549
+ let len =
550
+ ffi_len(cert_pem.len()).ok_or("failed to load untrusted/intermediate certificate: too large")?;
551
+ // SAFETY: same as above, but with type 0 (not trusted — intermediate).
552
+ let loaded = unsafe {
553
+ sys::xmlSecOpenSSLAppKeysMngrCertLoadMemory(
554
+ mngr.0,
555
+ cert_pem.as_ptr(),
556
+ len,
557
+ sys::xmlSecKeyDataFormat_xmlSecKeyDataFormatPem,
558
+ 0, // not trusted — intermediate for chain building
559
+ )
560
+ };
561
+ if loaded < 0 {
562
+ return Err("failed to load untrusted/intermediate certificate");
563
+ }
564
+ }
565
+
566
+ Ok(mngr)
567
+ }
568
+ }
569
+
570
+ impl Drop for KeysMngr {
571
+ fn drop(&mut self) {
572
+ // SAFETY: we're the exclusive owner; any DsigCtx borrowing us has
573
+ // already been dropped first — enforced by its lifetime parameter.
574
+ unsafe { sys::xmlSecKeysMngrDestroy(self.0) }
575
+ }
576
+ }
577
+
578
+ // ---------------------------------------------------------------------------
579
+ // DsigCtx — xmlsec signature context
580
+ // ---------------------------------------------------------------------------
581
+
582
+ /// An xmlsec signature context bound to a [`KeysMngr`]. Frees itself on
583
+ /// drop.
584
+ struct DsigCtx<'mngr> {
585
+ ptr: sys::xmlSecDSigCtxPtr,
586
+ _mngr: std::marker::PhantomData<&'mngr KeysMngr>,
587
+ }
588
+
589
+ impl<'mngr> DsigCtx<'mngr> {
590
+ fn new(mngr: &'mngr KeysMngr) -> Result<Self, &'static str> {
591
+ // SAFETY: mngr.0 is valid; tying this context's lifetime to
592
+ // `'mngr` makes the borrow checker keep `mngr` alive at least
593
+ // as long as we are.
594
+ let ptr = unsafe { sys::xmlSecDSigCtxCreate(mngr.0) };
595
+ if ptr.is_null() {
596
+ return Err("failed to create xmlsec signature context");
597
+ }
598
+ Ok(Self {
599
+ ptr,
600
+ _mngr: std::marker::PhantomData,
601
+ })
602
+ }
603
+
604
+ /// Creates a signature context with `signKey` already set.
605
+ ///
606
+ /// Used for both signing and verification. On verify, a pre-set
607
+ /// `signKey` makes xmlsec skip KeyInfo — the document cannot pick
608
+ /// the verification key (S-01).
609
+ fn for_signing(mngr: &'mngr KeysMngr, key: XmlSecKey) -> Result<Self, &'static str> {
610
+ let mut ctx = Self::new(mngr)?;
611
+ // SAFETY: setting signKey transfers ownership of the key to the context.
612
+ // xmlSecDSigCtxDestroy will call xmlSecKeyDestroy on it.
613
+ //
614
+ // This is the most ABI-fragile line in the crate: a direct field write
615
+ // into xmlSecDSigCtx, at an offset bindgen computed from whichever
616
+ // xmlsec headers were present at build time. If xmlsec ever moves
617
+ // signKey, bindgen regenerates a different-but-still-valid Rust struct
618
+ // and nothing fails to compile — the write simply lands somewhere else.
619
+ // That is why the vendored builds pin xmlsec (dependencies.yml) rather
620
+ // than binding against whatever the OS ships: headers and archive then
621
+ // come from one source and cannot disagree. For system builds,
622
+ // HankoXml::NativeVersion is the runtime backstop.
623
+ unsafe {
624
+ (*ctx.ptr).signKey = key.into_raw();
625
+ }
626
+ ctx.restrict_reference_uris_to_same_document();
627
+ ctx.resolve_bare_fragments_by_id();
628
+ ctx.ignore_manifests();
629
+ ctx.restrict_transforms()?;
630
+ Ok(ctx)
631
+ }
632
+
633
+ /// By default xmlsec turns `URI="#foo"` into the XPointer
634
+ /// `xpointer(id('foo'))` by string formatting, so an Id containing an
635
+ /// apostrophe produced an invalid expression and could not be signed or
636
+ /// verified (#71). The flag (named for the Visa3D protocol that needed
637
+ /// it first) makes xmlsec look the bare name up with `xmlGetID` instead:
638
+ /// the same ID table and the same node set without comments, minus the
639
+ /// quoting. It is also stricter -- `id('a b')` selects two elements,
640
+ /// the lookup finds none. `#xpointer(...)` URIs are unaffected.
641
+ fn resolve_bare_fragments_by_id(&mut self) {
642
+ // SAFETY: self.ptr is a live xmlSecDSigCtx we exclusively own; flags
643
+ // is application-settable and read when each Reference context is
644
+ // initialized. Same ABI caveat as the signKey write.
645
+ unsafe {
646
+ (*self.ptr).flags |= sys::XMLSEC_DSIG_FLAGS_USE_VISA3D_HACK;
647
+ }
648
+ }
649
+
650
+ /// By default xmlsec also resolves and digests the references inside
651
+ /// every `<Object><Manifest>`, whose URIs, transforms and digest methods
652
+ /// never reach Ruby Policy, which reads only `<SignedInfo>` (#75). XML-DSig
653
+ /// core validation leaves Manifest references to the application, and
654
+ /// this library never treats their targets as signed: a `<Reference>` to
655
+ /// the `<Manifest>` still digests the Manifest element itself, and nothing
656
+ /// it points to.
657
+ fn ignore_manifests(&mut self) {
658
+ // SAFETY: self.ptr is a live xmlSecDSigCtx we exclusively own; flags
659
+ // is application-settable and read while processing the Signature.
660
+ // Same ABI caveat as the signKey write.
661
+ unsafe {
662
+ (*self.ptr).flags |= sys::XMLSEC_DSIG_FLAGS_IGNORE_MANIFESTS;
663
+ }
664
+ }
665
+
666
+ /// xmlSecDSigCtxCreate leaves `enabledReferenceUris` /
667
+ /// `transformCtx.enabledUris` as `xmlSecTransformUriTypeAny`, so a
668
+ /// `<Reference URI="file://…">` or `http://…` is fetched before Ruby
669
+ /// Policy can reject it (S-04). Limit both to empty + same-document;
670
+ /// `referenced_documents:` is the only supported non-fragment path
671
+ /// and it never hits libxml2 I/O.
672
+ fn restrict_reference_uris_to_same_document(&mut self) {
673
+ let allowed = sys::URI_TYPE_EMPTY | sys::URI_TYPE_SAME_DOCUMENT;
674
+ // SAFETY: self.ptr is a live xmlSecDSigCtx we exclusively own.
675
+ // enabledReferenceUris and transformCtx.enabledUris are
676
+ // application-settable; xmlsec reads them when resolving
677
+ // Reference hrefs. Same ABI caveat as the signKey write.
678
+ unsafe {
679
+ (*self.ptr).enabledReferenceUris = allowed;
680
+ (*self.ptr).transformCtx.enabledUris = allowed;
681
+ }
682
+ }
683
+
684
+ /// Lets References name URIs outside the document, for a verification
685
+ /// whose caller passed `referenced_documents:` (#74). Only the IO
686
+ /// callbacks installed by [`init_xmlsec`] can open such a URI, and they
687
+ /// serve nothing but that map: no filesystem, no network.
688
+ fn allow_referenced_documents(&mut self) {
689
+ let allowed = sys::URI_TYPE_EMPTY
690
+ | sys::URI_TYPE_SAME_DOCUMENT
691
+ | sys::xmlSecTransformUriTypeLocal
692
+ | sys::xmlSecTransformUriTypeRemote;
693
+ // SAFETY: same fields and caveat as
694
+ // restrict_reference_uris_to_same_document.
695
+ unsafe {
696
+ (*self.ptr).enabledReferenceUris = allowed;
697
+ (*self.ptr).transformCtx.enabledUris = allowed;
698
+ }
699
+ }
700
+
701
+ /// Restricts `enabledReferenceTransforms` to core DSig transforms only.
702
+ ///
703
+ /// xmlSecDSigCtxCreate leaves `enabledReferenceTransforms` as NULL, so
704
+ /// all registered transforms are allowed, including XSLT when the host
705
+ /// xmlsec was built with libxslt (S-10). Initialize the list and add
706
+ /// only safe core DSig transforms — excluding XSLT, XPath, and XPointer.
707
+ /// SHA-3 digest klasses exist only in xmlsec 1.3+; they are looked up
708
+ /// by href so a 1.2 host still compiles (#60).
709
+ fn restrict_transforms(&mut self) -> Result<(), &'static str> {
710
+ // List of safe transform IDs to allow. These cover the standard
711
+ // DSig operations: canonicalization, base64, enveloped signature.
712
+ // Notably absent: XSLT, XPath, XPointer, and other potentially
713
+ // dangerous transforms.
714
+ let safe_transforms: &[unsafe extern "C" fn() -> sys::xmlSecTransformId] = &[
715
+ sys::xmlSecTransformEnvelopedGetKlass,
716
+ sys::xmlSecTransformBase64GetKlass,
717
+ sys::xmlSecTransformInclC14NGetKlass,
718
+ sys::xmlSecTransformInclC14NWithCommentsGetKlass,
719
+ sys::xmlSecTransformInclC14N11GetKlass,
720
+ sys::xmlSecTransformInclC14N11WithCommentsGetKlass,
721
+ sys::xmlSecTransformExclC14NGetKlass,
722
+ sys::xmlSecTransformExclC14NWithCommentsGetKlass,
723
+ // Digest methods
724
+ sys::xmlSecOpenSSLTransformSha1GetKlass,
725
+ sys::xmlSecOpenSSLTransformSha224GetKlass,
726
+ sys::xmlSecOpenSSLTransformSha256GetKlass,
727
+ sys::xmlSecOpenSSLTransformSha384GetKlass,
728
+ sys::xmlSecOpenSSLTransformSha512GetKlass,
729
+ ];
730
+
731
+ // SAFETY: self.ptr is a live xmlSecDSigCtx we exclusively own.
732
+ // xmlSecDSigCtxEnableReferenceTransform initializes the
733
+ // enabledReferenceTransforms list on first call and adds the
734
+ // transform ID. After this, only transforms in the list are allowed.
735
+ for get_klass in safe_transforms {
736
+ let transform_id = unsafe { get_klass() };
737
+ let rc = unsafe {
738
+ sys::xmlSecDSigCtxEnableReferenceTransform(self.ptr, transform_id)
739
+ };
740
+ if rc < 0 {
741
+ return Err("failed to enable safe transform");
742
+ }
743
+ }
744
+ self.enable_sha3_digest_transforms()
745
+ }
746
+
747
+ /// Enables xmlsec's XPath Filter 2.0 transform, which `restrict_transforms`
748
+ /// leaves out (S-10). Called only when the caller's Policy allows
749
+ /// `:xpath_filter2`, and Ruby Policy has already limited every such
750
+ /// transform to a single `subtract` of the document's signatures (#85).
751
+ /// xmlsec evaluates it; this crate never evaluates XPath itself.
752
+ fn enable_xpath_filter2(&mut self) -> Result<(), &'static str> {
753
+ // SAFETY: self.ptr is a live xmlSecDSigCtx we exclusively own, its
754
+ // enabled list already initialized by restrict_transforms.
755
+ let rc = unsafe {
756
+ sys::xmlSecDSigCtxEnableReferenceTransform(self.ptr, sys::xmlSecTransformXPath2GetKlass())
757
+ };
758
+ if rc < 0 {
759
+ return Err("failed to enable the XPath Filter 2.0 transform");
760
+ }
761
+ Ok(())
762
+ }
763
+
764
+ /// Enables SHA-3 `DigestMethod` klasses when the linked xmlsec registered
765
+ /// them. Looked up by href rather than `xmlSecOpenSSLTransformSha3_*GetKlass`
766
+ /// so Ubuntu's xmlsec 1.2 (no SHA-3 symbols) still compiles (S-10, #60).
767
+ fn enable_sha3_digest_transforms(&mut self) -> Result<(), &'static str> {
768
+ const SHA3_DIGEST_HREFS: &[&[u8]] = &[
769
+ b"http://www.w3.org/2007/05/xmldsig-more#sha3-224",
770
+ b"http://www.w3.org/2007/05/xmldsig-more#sha3-256",
771
+ b"http://www.w3.org/2007/05/xmldsig-more#sha3-384",
772
+ b"http://www.w3.org/2007/05/xmldsig-more#sha3-512",
773
+ ];
774
+
775
+ // SAFETY: xmlSecTransformIdsGet returns the process-global registry
776
+ // list, populated once by init_xmlsec and never mutated afterwards.
777
+ let registry = unsafe { sys::xmlSecTransformIdsGet() };
778
+ if registry.is_null() {
779
+ return Err("xmlsec transform registry is unavailable");
780
+ }
781
+ // SAFETY: registry is a valid list (checked above).
782
+ let size = unsafe { sys::xmlSecPtrListGetSize(registry) };
783
+ for pos in 0..size {
784
+ // SAFETY: pos < size; every item is a static xmlSecTransformKlass.
785
+ let id = unsafe { sys::xmlSecPtrListGetItem(registry, pos) } as sys::xmlSecTransformId;
786
+ if id.is_null() {
787
+ continue;
788
+ }
789
+ // SAFETY: id is a non-null static klass; href is a static
790
+ // NUL-terminated string or null.
791
+ let href = unsafe { (*id).href };
792
+ if href.is_null() {
793
+ continue;
794
+ }
795
+ // SAFETY: href is non-null and NUL-terminated (see above).
796
+ let href = unsafe { CStr::from_ptr(href.cast()) }.to_bytes();
797
+ if !SHA3_DIGEST_HREFS.contains(&href) {
798
+ continue;
799
+ }
800
+ // SAFETY: self.ptr is a live xmlSecDSigCtx we exclusively own;
801
+ // enabledReferenceTransforms was initialized by the SHA-2 loop
802
+ // above, and id is static for the life of the process.
803
+ let rc = unsafe { sys::xmlSecDSigCtxEnableReferenceTransform(self.ptr, id) };
804
+ if rc < 0 {
805
+ return Err("failed to enable SHA-3 digest transform");
806
+ }
807
+ }
808
+ Ok(())
809
+ }
810
+
811
+ /// Lets the signing KeyInfo writer fill `<KeyName>`, `<KeyValue>` and
812
+ /// `<X509Data>`.
813
+ ///
814
+ /// xmlsec 1.3 registers the KeyValue key data *disabled* globally (it
815
+ /// is unsafe to *read*), so with the default empty
816
+ /// `keyInfoWriteCtx.enabledKeyData` a `<KeyValue/>` template stayed
817
+ /// empty. A non-empty local list replaces the global one for this
818
+ /// context only, so it must name every KeyInfo child we write and the
819
+ /// key value classes KeyValue may serialize. Reading is untouched:
820
+ /// verification pins `signKey` and never consults KeyInfo (S-01).
821
+ ///
822
+ /// Classes are picked from xmlsec's registry by name rather than by
823
+ /// `xmlSec*GetKlass` symbol because the EC class is `ec` in 1.3 but
824
+ /// `ecdsa` (a different symbol) in the 1.2 that Linux distros ship.
825
+ fn enable_key_info_writing(&mut self) -> Result<(), &'static str> {
826
+ const WRITABLE: &[&[u8]] = &[b"key-name", b"key-value", b"x509", b"rsa", b"ec", b"ecdsa", b"dsa"];
827
+
828
+ // SAFETY: xmlSecKeyDataIdsGet returns the process-global registry
829
+ // list, populated once by init_xmlsec and never mutated afterwards.
830
+ let registry = unsafe { sys::xmlSecKeyDataIdsGet() };
831
+ if registry.is_null() {
832
+ return Err("xmlsec key data registry is unavailable");
833
+ }
834
+ // SAFETY: registry is a valid list (checked above).
835
+ let size = unsafe { sys::xmlSecPtrListGetSize(registry) };
836
+ for pos in 0..size {
837
+ // SAFETY: pos < size; every item is a static xmlSecKeyDataKlass.
838
+ let id = unsafe { sys::xmlSecPtrListGetItem(registry, pos) } as sys::xmlSecKeyDataId;
839
+ if id.is_null() {
840
+ continue;
841
+ }
842
+ // SAFETY: id is a non-null static klass; name is a static
843
+ // NUL-terminated string or null.
844
+ let name = unsafe { (*id).name };
845
+ if name.is_null() {
846
+ continue;
847
+ }
848
+ // SAFETY: name is non-null and NUL-terminated (see above).
849
+ let name = unsafe { std::ffi::CStr::from_ptr(name.cast()) }.to_bytes();
850
+ if !WRITABLE.contains(&name) {
851
+ continue;
852
+ }
853
+ // SAFETY: self.ptr is a live xmlSecDSigCtx we exclusively own;
854
+ // enabledKeyData was initialized as an xmlSecKeyDataIdListId
855
+ // list by xmlSecDSigCtxCreate, and id is static for the life of
856
+ // the process. Same ABI caveat as the signKey write.
857
+ let rc = unsafe {
858
+ sys::xmlSecPtrListAdd(&mut (*self.ptr).keyInfoWriteCtx.enabledKeyData, id as sys::xmlSecPtr)
859
+ };
860
+ if rc < 0 {
861
+ return Err("failed to enable KeyInfo key data for writing");
862
+ }
863
+ }
864
+ Ok(())
865
+ }
866
+
867
+ /// Signs the document at the given signature node.
868
+ fn sign(&mut self, sig_node: sys::xmlNodePtr) -> Result<(), &'static str> {
869
+ // SAFETY: self.ptr is valid and exclusively ours; sig_node is
870
+ // valid within the document's lifetime.
871
+ let rc = unsafe { sys::xmlSecDSigCtxSign(self.ptr, sig_node) };
872
+ if rc < 0 {
873
+ return Err("xmlSecDSigCtxSign failed");
874
+ }
875
+ Ok(())
876
+ }
877
+
878
+ /// Verifies the signature at the given node. Returns the status.
879
+ fn verify(&mut self, sig_node: sys::xmlNodePtr) -> VerifyStatus {
880
+ // SAFETY: self.ptr is valid; sig_node is valid within the
881
+ // document's lifetime.
882
+ if unsafe { sys::xmlSecDSigCtxVerify(self.ptr, sig_node) } < 0 {
883
+ return VerifyStatus::Unknown;
884
+ }
885
+ // SAFETY: self.ptr is valid; status is a plain enum field.
886
+ let status = unsafe { (*self.ptr).status };
887
+ match status {
888
+ s if s == sys::DSIG_STATUS_SUCCEEDED => VerifyStatus::Succeeded,
889
+ s if s == sys::DSIG_STATUS_INVALID => VerifyStatus::Invalid,
890
+ _ => VerifyStatus::Unknown,
891
+ }
892
+ }
893
+
894
+ /// Per-`<SignedInfo><Reference>` digest verification status, in document
895
+ /// order, after `verify()` has run (S-09). `xmlSecDSigCtxVerify`
896
+ /// processes and digest-checks every SignedInfo Reference before it ever
897
+ /// looks at the overall `SignatureValue` (see xmldsig.c), so this is
898
+ /// independent of the overall `signKey`/`SignatureValue` outcome: a
899
+ /// harmless-fragment Reference and the payload Reference in a
900
+ /// multi-Reference signature can genuinely disagree.
901
+ ///
902
+ /// `Some(true)`/`Some(false)` is a completed status; `None` means the
903
+ /// reference was never processed (verification aborted earlier, e.g. a
904
+ /// malformed `SignedInfo`) and the caller has no real answer to give.
905
+ /// `<Manifest>` references are not read here -- this library does not
906
+ /// otherwise resolve or expose Manifest semantics.
907
+ fn reference_statuses(&self) -> Vec<Option<bool>> {
908
+ // SAFETY: self.ptr is a live xmlSecDSigCtx; signedInfoReferences is
909
+ // an embedded xmlSecPtrList (not a pointer) that verify() populated.
910
+ let list = unsafe { ptr::addr_of!((*self.ptr).signedInfoReferences) as sys::xmlSecPtrListPtr };
911
+ // SAFETY: list points at a valid, initialized xmlSecPtrList for the
912
+ // lifetime of self.
913
+ let size = unsafe { sys::xmlSecPtrListGetSize(list) };
914
+ (0..size)
915
+ .map(|pos| {
916
+ // SAFETY: pos is in [0, size), so GetItem returns one of the
917
+ // list's own xmlSecDSigReferenceCtxPtr entries.
918
+ let item = unsafe { sys::xmlSecPtrListGetItem(list, pos) };
919
+ if item.is_null() {
920
+ return None;
921
+ }
922
+ let reference_ctx = item as sys::xmlSecDSigReferenceCtxPtr;
923
+ // SAFETY: reference_ctx is a live xmlSecDSigReferenceCtx
924
+ // owned by the list; status is a plain enum field.
925
+ let status = unsafe { (*reference_ctx).status };
926
+ match status {
927
+ s if s == sys::DSIG_STATUS_SUCCEEDED => Some(true),
928
+ s if s == sys::DSIG_STATUS_INVALID => Some(false),
929
+ _ => None,
930
+ }
931
+ })
932
+ .collect()
933
+ }
934
+ }
935
+
936
+ impl Drop for DsigCtx<'_> {
937
+ fn drop(&mut self) {
938
+ // SAFETY: we're the exclusive owner of this context.
939
+ unsafe { sys::xmlSecDSigCtxDestroy(self.ptr) }
940
+ }
941
+ }
942
+
943
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
944
+ pub enum VerifyStatus {
945
+ Succeeded,
946
+ Invalid,
947
+ Unknown,
948
+ }
949
+
950
+ impl VerifyStatus {
951
+ pub fn as_str(&self) -> &'static str {
952
+ match self {
953
+ Self::Succeeded => "succeeded",
954
+ Self::Invalid => "invalid",
955
+ Self::Unknown => "unknown",
956
+ }
957
+ }
958
+ }
959
+
960
+ // ---------------------------------------------------------------------------
961
+ // Public API — the §7 contract
962
+ // ---------------------------------------------------------------------------
963
+
964
+ /// Result of `inspect_document`.
965
+ #[derive(Debug)]
966
+ pub struct InspectResult {
967
+ pub signature_method: String,
968
+ pub canonicalization_method: String,
969
+ pub signature_count: usize,
970
+ pub key_names: Vec<String>,
971
+ pub certificates: Vec<String>,
972
+ pub references: Vec<InspectReference>,
973
+ /// Whether `<KeyInfo>` contains a `<RetrievalMethod>` (S-06,
974
+ /// `allow_retrieval_method`) -- checked structurally, before any
975
+ /// dereferencing, the same way S-04 checks Reference URIs.
976
+ pub has_retrieval_method: bool,
977
+ /// Whether `<KeyInfo>` has a `<KeyName>` with no text, which the Signer
978
+ /// must fill before xmlsec signs (#67).
979
+ pub empty_key_names: bool,
980
+ /// Whether a key-derived `<KeyInfo>` leaf (`X509Certificate`,
981
+ /// `X509SubjectName`, `X509IssuerSerial`, `X509SKI`, `KeyValue`) has any
982
+ /// content. xmlsec fills only empty ones, so an adopted template's stale
983
+ /// values would be signed (#72). Lets a Signer without a DOM refuse such
984
+ /// a template (#102).
985
+ pub key_derived_values: bool,
986
+ }
987
+
988
+ #[derive(Debug)]
989
+ pub struct InspectReference {
990
+ pub uri: String,
991
+ pub digest_method: String,
992
+ pub transforms: Vec<String>,
993
+ pub id: Option<String>,
994
+ pub r#type: Option<String>,
995
+ /// The `<XPath>` children of every XPath Filter 2.0 transform, in order,
996
+ /// for Policy's shape check (#85).
997
+ pub xpath_filters: Vec<XPathFilter>,
998
+ }
999
+
1000
+ /// One XPath Filter 2.0 `<XPath>`: its `Filter` type, its expression, and
1001
+ /// the namespace bindings in scope on it, which give the expression's
1002
+ /// prefixes their meaning.
1003
+ #[derive(Debug)]
1004
+ pub struct XPathFilter {
1005
+ pub filter: String,
1006
+ pub expression: String,
1007
+ pub namespaces: Vec<(String, String)>,
1008
+ }
1009
+
1010
+ /// Result of `verify_document`.
1011
+ #[derive(Debug)]
1012
+ pub struct VerifyResult {
1013
+ pub status: VerifyStatus,
1014
+ pub errors: Vec<String>,
1015
+ /// Per SignedInfo `<Reference>`, the document-order element index of the
1016
+ /// node xmlsec's ID table resolved (same table `#id` verification used).
1017
+ /// Empty URI → root element's index. Unresolved / non-fragment → `None`.
1018
+ pub reference_element_indexes: Vec<Option<usize>>,
1019
+ /// Per SignedInfo `<Reference>`, its own digest verification status --
1020
+ /// independent of the overall signature/key outcome (S-09). Same
1021
+ /// indexing/order as `reference_element_indexes`. `None` when the
1022
+ /// reference was never processed (verification aborted before reaching
1023
+ /// it), not when we simply do not know.
1024
+ pub reference_valid: Vec<Option<bool>>,
1025
+ /// Index into `trusted_pems` of the PEM whose key verified the signature,
1026
+ /// or `None` when none did. Lets Ruby name the certificate that verified
1027
+ /// (`Verification#certificate`), which `certificates:` otherwise leaves
1028
+ /// ambiguous.
1029
+ pub trusted_index: Option<usize>,
1030
+ }
1031
+
1032
+ const DSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#";
1033
+ const XPATH_FILTER2_NS: &str = "http://www.w3.org/2002/06/xmldsig-filter2";
1034
+
1035
+ /// Inspects the nth `<ds:Signature>` without performing any cryptography.
1036
+ /// Returns metadata about the signature for policy enforcement.
1037
+ ///
1038
+ /// No `unsafe` in this function body — every FFI call is behind a helper.
1039
+ pub fn inspect_document(
1040
+ xml: &[u8],
1041
+ signature_index: usize,
1042
+ ) -> Result<InspectResult, DsigError> {
1043
+ let doc = OwnedXmlDoc::parse(xml).map_err(DsigError::parse)?;
1044
+ let root = doc
1045
+ .root_element()
1046
+ .ok_or(DsigError::parse("XML document has no root element"))?;
1047
+
1048
+ let sig_count = doc.count_signatures(root);
1049
+ let sig_node = doc
1050
+ .find_nth_signature(root, signature_index)
1051
+ .ok_or(DsigError::native("signature index out of range"))?;
1052
+
1053
+ // Extract <SignedInfo>
1054
+ let signed_info = unsafe { find_child(sig_node, "SignedInfo", DSIG_NS) }
1055
+ .ok_or(DsigError::native("no <SignedInfo> element in <Signature>"))?;
1056
+
1057
+ // CanonicalizationMethod/@Algorithm
1058
+ let c14n_method = unsafe { find_child(signed_info, "CanonicalizationMethod", DSIG_NS) }
1059
+ .and_then(|n| unsafe { get_attribute(n, b"Algorithm\0") })
1060
+ .unwrap_or_default();
1061
+
1062
+ // SignatureMethod/@Algorithm
1063
+ let sig_method = unsafe { find_child(signed_info, "SignatureMethod", DSIG_NS) }
1064
+ .and_then(|n| unsafe { get_attribute(n, b"Algorithm\0") })
1065
+ .unwrap_or_default();
1066
+
1067
+ // References
1068
+ let ref_nodes = unsafe { find_children(signed_info, "Reference", DSIG_NS) };
1069
+ let mut references = Vec::new();
1070
+ for ref_node in ref_nodes {
1071
+ let uri = unsafe { get_attribute(ref_node, b"URI\0") }.unwrap_or_default();
1072
+ let id = unsafe { get_attribute(ref_node, b"Id\0") };
1073
+ let type_attr = unsafe { get_attribute(ref_node, b"Type\0") };
1074
+
1075
+ // DigestMethod/@Algorithm
1076
+ let digest_method = unsafe { find_child(ref_node, "DigestMethod", DSIG_NS) }
1077
+ .and_then(|n| unsafe { get_attribute(n, b"Algorithm\0") })
1078
+ .unwrap_or_default();
1079
+
1080
+ // Transforms
1081
+ let mut transforms = Vec::new();
1082
+ let mut xpath_filters = Vec::new();
1083
+ if let Some(transforms_node) = unsafe { find_child(ref_node, "Transforms", DSIG_NS) } {
1084
+ let transform_nodes = unsafe { find_children(transforms_node, "Transform", DSIG_NS) };
1085
+ for t_node in transform_nodes {
1086
+ if let Some(alg) = unsafe { get_attribute(t_node, b"Algorithm\0") } {
1087
+ if alg == XPATH_FILTER2_NS {
1088
+ for xpath in unsafe { find_children(t_node, "XPath", XPATH_FILTER2_NS) } {
1089
+ xpath_filters.push(XPathFilter {
1090
+ filter: unsafe { get_attribute(xpath, b"Filter\0") }.unwrap_or_default(),
1091
+ expression: unsafe { get_text_content(xpath) },
1092
+ namespaces: unsafe { in_scope_namespaces(xpath) },
1093
+ });
1094
+ }
1095
+ }
1096
+ transforms.push(alg);
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ references.push(InspectReference {
1102
+ uri,
1103
+ digest_method,
1104
+ transforms,
1105
+ id,
1106
+ r#type: type_attr,
1107
+ xpath_filters,
1108
+ });
1109
+ }
1110
+
1111
+ // KeyInfo — KeyName, X509Certificate, and RetrievalMethod presence
1112
+ let mut key_names = Vec::new();
1113
+ let mut certificates = Vec::new();
1114
+ let mut has_retrieval_method = false;
1115
+ let mut empty_key_names = false;
1116
+ let mut key_derived_values = false;
1117
+ if let Some(key_info) = unsafe { find_child(sig_node, "KeyInfo", DSIG_NS) } {
1118
+ // KeyName elements
1119
+ let key_name_nodes = unsafe { find_children(key_info, "KeyName", DSIG_NS) };
1120
+ for kn in key_name_nodes {
1121
+ let text = unsafe { get_text_content(kn) };
1122
+ if text.trim().is_empty() {
1123
+ empty_key_names = true;
1124
+ }
1125
+ if !text.is_empty() {
1126
+ key_names.push(text);
1127
+ }
1128
+ }
1129
+
1130
+ // Key-derived leaves with any child node, as the Ruby DOM path
1131
+ // checks them (Signer#empty_key_derived_leaves!).
1132
+ let mut leaves = unsafe { find_children(key_info, "KeyValue", DSIG_NS) };
1133
+ for x509_data in unsafe { find_children(key_info, "X509Data", DSIG_NS) } {
1134
+ for name in ["X509Certificate", "X509SubjectName", "X509IssuerSerial", "X509SKI"] {
1135
+ leaves.extend(unsafe { find_children(x509_data, name, DSIG_NS) });
1136
+ }
1137
+ }
1138
+ // SAFETY: each leaf is a live element of this document.
1139
+ key_derived_values = leaves.iter().any(|&leaf| unsafe { !(*leaf).children.is_null() });
1140
+
1141
+ // X509Data/X509Certificate elements
1142
+ let x509_data_nodes = unsafe { find_children(key_info, "X509Data", DSIG_NS) };
1143
+ for x509_data in x509_data_nodes {
1144
+ let cert_nodes = unsafe { find_children(x509_data, "X509Certificate", DSIG_NS) };
1145
+ for cert_node in cert_nodes {
1146
+ let text = unsafe { get_text_content(cert_node) }.trim().to_string();
1147
+ if !text.is_empty() {
1148
+ // Wrap in PEM markers for consistency
1149
+ let pem = format!("-----BEGIN CERTIFICATE-----\n{text}\n-----END CERTIFICATE-----");
1150
+ certificates.push(pem);
1151
+ }
1152
+ }
1153
+ }
1154
+
1155
+ has_retrieval_method =
1156
+ unsafe { find_child(key_info, "RetrievalMethod", DSIG_NS) }.is_some();
1157
+ }
1158
+
1159
+ Ok(InspectResult {
1160
+ signature_method: sig_method,
1161
+ canonicalization_method: c14n_method,
1162
+ signature_count: sig_count,
1163
+ key_names,
1164
+ certificates,
1165
+ references,
1166
+ has_retrieval_method,
1167
+ empty_key_names,
1168
+ key_derived_values,
1169
+ })
1170
+ }
1171
+
1172
+ /// Result of `scan_document`: the structural facts the Ruby layer used to
1173
+ /// read from a Nokogiri DOM, taken from the same parse xmlsec works on.
1174
+ #[derive(Debug)]
1175
+ pub struct ScanResult {
1176
+ pub signature_count: usize,
1177
+ /// Per `<ds:Signature>` in document order, whether its
1178
+ /// `<SignatureValue>` has non-whitespace content.
1179
+ pub signature_values: Vec<bool>,
1180
+ /// Per `<ds:Signature>`, its document-order element index (root = 0),
1181
+ /// the numbering `Native.verify`'s `reference_element_indexes` use.
1182
+ pub signature_element_indexes: Vec<Option<usize>>,
1183
+ /// Whether two ID attributes (any of `id_attrs`, matched by local name
1184
+ /// the way `xmlSecAddIDs` matches them) carry the same value (S-06).
1185
+ pub duplicate_ids: bool,
1186
+ /// Values `#value` resolves to the root element through the ID table
1187
+ /// xmlsec uses, for `require_signed_root` (S-06).
1188
+ pub root_ids: Vec<String>,
1189
+ }
1190
+
1191
+ /// Scans the document's structure without cryptography: signature count
1192
+ /// and presence, duplicate IDs, and the root's IDs. `strict` rejects any
1193
+ /// libxml2 error, the way a strict Nokogiri parse does; it is set for input
1194
+ /// that arrived as bytes, and unset for a DOM the caller already holds.
1195
+ pub fn scan_document(xml: &[u8], id_attrs: &[String], strict: bool) -> Result<ScanResult, DsigError> {
1196
+ errors::clear_errors();
1197
+
1198
+ let doc = if strict { OwnedXmlDoc::parse_strict(xml) } else { OwnedXmlDoc::parse(xml) }
1199
+ .map_err(DsigError::parse)?;
1200
+ let root = doc
1201
+ .root_element()
1202
+ .ok_or(DsigError::parse("XML document has no root element"))?;
1203
+
1204
+ let mut signatures = Vec::new();
1205
+ collect_signature_nodes(root, &mut signatures);
1206
+ let signature_values = signatures
1207
+ .iter()
1208
+ .map(|&sig| {
1209
+ unsafe { find_child(sig, "SignatureValue", DSIG_NS) }
1210
+ .map(|value| !ruby_strip(&unsafe { get_text_content(value) }).is_empty())
1211
+ .unwrap_or(false)
1212
+ })
1213
+ .collect();
1214
+ let signature_element_indexes = signatures
1215
+ .iter()
1216
+ .map(|&sig| element_document_index(root, sig))
1217
+ .collect();
1218
+
1219
+ let mut id_values: Vec<(sys::xmlAttrPtr, String)> = Vec::new();
1220
+ collect_id_attributes(root, id_attrs, &mut id_values);
1221
+ let mut seen = std::collections::HashSet::new();
1222
+ let duplicate_ids = id_values.iter().any(|(_, value)| !seen.insert(value.as_str()));
1223
+
1224
+ doc.register_id_attributes(root, id_attrs);
1225
+ let root_ids = id_values
1226
+ .iter()
1227
+ .filter(|(attr, _)| unsafe { (**attr).parent } == root)
1228
+ .filter(|(_, value)| element_for_id(&doc, value) == Some(root))
1229
+ .map(|(_, value)| value.clone())
1230
+ .collect();
1231
+
1232
+ errors::clear_errors();
1233
+ Ok(ScanResult {
1234
+ signature_count: signatures.len(),
1235
+ signature_values,
1236
+ signature_element_indexes,
1237
+ duplicate_ids,
1238
+ root_ids,
1239
+ })
1240
+ }
1241
+
1242
+ /// Ruby's `String#strip`: ASCII whitespace and NUL, nothing else, so a
1243
+ /// `<SignatureValue>` holding only U+00A0 counts as present in both layers.
1244
+ fn ruby_strip(text: &str) -> &str {
1245
+ text.trim_matches(|c: char| c.is_ascii_whitespace() || c == '\x0b' || c == '\0')
1246
+ }
1247
+
1248
+ /// Every attribute, in document order, whose local name is one of
1249
+ /// `id_attrs`, whatever its namespace: the attributes `xmlSecAddIDs`
1250
+ /// registers.
1251
+ fn collect_id_attributes(node: sys::xmlNodePtr, id_attrs: &[String], out: &mut Vec<(sys::xmlAttrPtr, String)>) {
1252
+ if node.is_null() || id_attrs.is_empty() {
1253
+ return;
1254
+ }
1255
+ // SAFETY: node is non-null and valid within the document's lifetime.
1256
+ let node_ref = unsafe { &*node };
1257
+ if node_ref.type_ == sys::xmlElementType_XML_ELEMENT_NODE {
1258
+ let mut attr = node_ref.properties;
1259
+ while !attr.is_null() {
1260
+ // SAFETY: attr is a valid attribute of this element.
1261
+ let attr_ref = unsafe { &*attr };
1262
+ let name = if attr_ref.name.is_null() {
1263
+ ""
1264
+ } else {
1265
+ // SAFETY: attribute names are NUL-terminated.
1266
+ unsafe { CStr::from_ptr(attr_ref.name.cast()) }.to_str().unwrap_or("")
1267
+ };
1268
+ if id_attrs.iter().any(|id| id == name) {
1269
+ // SAFETY: attr's children are its value's text nodes.
1270
+ let value = unsafe { sys::xmlNodeListGetString(node_ref.doc, attr_ref.children, 1) };
1271
+ let text = if value.is_null() {
1272
+ String::new()
1273
+ } else {
1274
+ // SAFETY: non-null, NUL-terminated, freed right after.
1275
+ let text = unsafe { CStr::from_ptr(value.cast()) }.to_str().unwrap_or("").to_string();
1276
+ unsafe { xml_free(value.cast()) };
1277
+ text
1278
+ };
1279
+ out.push((attr, text));
1280
+ }
1281
+ attr = attr_ref.next;
1282
+ }
1283
+ }
1284
+ let mut child = node_ref.children;
1285
+ while !child.is_null() {
1286
+ collect_id_attributes(child, id_attrs, out);
1287
+ // SAFETY: child is a valid sibling in this document.
1288
+ child = unsafe { (*child).next };
1289
+ }
1290
+ }
1291
+
1292
+ /// Validates a copy of the nth `<ds:Signature>`, as the root of a fresh
1293
+ /// document, against the XSD at `xsd_path` (relative imports resolve
1294
+ /// against it). Returns libxml2's validation errors; empty means valid.
1295
+ /// Nokogiri's `schema: :default` path validated the same copy (#102).
1296
+ pub fn validate_signature_schema(
1297
+ xml: &[u8],
1298
+ signature_index: usize,
1299
+ xsd_path: &str,
1300
+ ) -> Result<Vec<String>, DsigError> {
1301
+ errors::clear_errors();
1302
+
1303
+ let doc = OwnedXmlDoc::parse(xml).map_err(DsigError::parse)?;
1304
+ let root = doc
1305
+ .root_element()
1306
+ .ok_or(DsigError::parse("XML document has no root element"))?;
1307
+ let sig_node = doc
1308
+ .find_nth_signature(root, signature_index)
1309
+ .ok_or(DsigError::native("signature index out of range"))?;
1310
+
1311
+ let mut path = xsd_path.as_bytes().to_vec();
1312
+ path.push(0);
1313
+ errors::clear_errors();
1314
+ let _capture = Libxml2Capture::start();
1315
+
1316
+ // SAFETY: path is NUL-terminated for the call; the parser context is
1317
+ // freed below on every path.
1318
+ let parser = unsafe { sys::xmlSchemaNewParserCtxt(path.as_ptr().cast()) };
1319
+ if parser.is_null() {
1320
+ return Err(DsigError::native("failed to load the XML-DSig schema"));
1321
+ }
1322
+ // SAFETY: parser is valid; the handler takes a null context.
1323
+ unsafe { sys::xmlSchemaSetParserStructuredErrors(parser, errors::libxml2_error_handler(), ptr::null_mut()) };
1324
+ // SAFETY: parser is valid. The parsed schema outlives its parser.
1325
+ let schema = unsafe { sys::xmlSchemaParse(parser) };
1326
+ // SAFETY: freed exactly once, after xmlSchemaParse.
1327
+ unsafe { sys::xmlSchemaFreeParserCtxt(parser) };
1328
+ if schema.is_null() {
1329
+ return Err(DsigError::native("failed to load the XML-DSig schema"));
1330
+ }
1331
+
1332
+ // SAFETY: "1.0" is NUL-terminated; the new document is owned below.
1333
+ let copy = unsafe { sys::xmlNewDoc(c"1.0".as_ptr().cast()) };
1334
+ if copy.is_null() {
1335
+ // SAFETY: schema is valid and freed once.
1336
+ unsafe { sys::xmlSchemaFree(schema) };
1337
+ return Err(DsigError::native("failed to copy <Signature> for schema validation"));
1338
+ }
1339
+ let copy = OwnedXmlDoc(copy);
1340
+ // SAFETY: sig_node is valid in doc; the recursive copy belongs to copy.0
1341
+ // once set as its root, and is freed with it.
1342
+ let copied = unsafe { sys::xmlDocCopyNode(sig_node, copy.0, 1) };
1343
+ if !copied.is_null() {
1344
+ // SAFETY: copy.0 has no root yet, so nothing is unlinked or leaked.
1345
+ unsafe { sys::xmlDocSetRootElement(copy.0, copied) };
1346
+ }
1347
+
1348
+ errors::clear_errors();
1349
+ // SAFETY: schema is valid; the context is freed below.
1350
+ let valid_ctxt = unsafe { sys::xmlSchemaNewValidCtxt(schema) };
1351
+ let result = if valid_ctxt.is_null() || copied.is_null() {
1352
+ Err(DsigError::native("failed to set up schema validation"))
1353
+ } else {
1354
+ // SAFETY: valid_ctxt and copy.0 are valid for the call.
1355
+ let status = unsafe {
1356
+ sys::xmlSchemaSetValidStructuredErrors(valid_ctxt, errors::libxml2_error_handler(), ptr::null_mut());
1357
+ sys::xmlSchemaValidateDoc(valid_ctxt, copy.0)
1358
+ };
1359
+ let mut messages = errors::drain_errors();
1360
+ if status != 0 && messages.is_empty() {
1361
+ messages.push("<Signature> does not validate against the XML-DSig schema".into());
1362
+ }
1363
+ Ok(messages)
1364
+ };
1365
+ // SAFETY: each freed once; a null context is skipped.
1366
+ unsafe {
1367
+ if !valid_ctxt.is_null() {
1368
+ sys::xmlSchemaFreeValidCtxt(valid_ctxt);
1369
+ }
1370
+ sys::xmlSchemaFree(schema);
1371
+ }
1372
+ result
1373
+ }
1374
+
1375
+ /// Signs the document's nth `<ds:Signature>` template.
1376
+ ///
1377
+ /// The XML must already contain a `<ds:Signature>` skeleton with empty
1378
+ /// `<DigestValue>`, `<SignatureValue>` etc. — built by Ruby's Template
1379
+ /// layer.
1380
+ pub fn sign_document(
1381
+ xml: &[u8],
1382
+ key_pem: &[u8],
1383
+ cert_pems: &[Vec<u8>],
1384
+ id_attrs: &[String],
1385
+ signature_index: usize,
1386
+ ) -> Result<Vec<u8>, DsigError> {
1387
+ errors::clear_errors();
1388
+
1389
+ let doc = OwnedXmlDoc::parse(xml).map_err(DsigError::parse)?;
1390
+ let root = doc
1391
+ .root_element()
1392
+ .ok_or(DsigError::parse("XML document has no root element"))?;
1393
+
1394
+ doc.register_id_attributes(root, id_attrs);
1395
+
1396
+ let sig_node = doc
1397
+ .find_nth_signature(root, signature_index)
1398
+ .ok_or(DsigError::native("signature index out of range or no <Signature> found"))?;
1399
+
1400
+ let key = XmlSecKey::from_pem(key_pem).map_err(DsigError::key_load)?;
1401
+
1402
+ for cert_pem in cert_pems {
1403
+ key.add_cert_pem(cert_pem).map_err(DsigError::key_load)?;
1404
+ }
1405
+
1406
+ let mngr = KeysMngr::new(&[], &[]).map_err(DsigError::native)?;
1407
+
1408
+ let mut ctx = DsigCtx::for_signing(&mngr, key).map_err(DsigError::native)?;
1409
+ ctx.enable_key_info_writing().map_err(DsigError::native)?;
1410
+
1411
+ ctx.sign(sig_node).map_err(DsigError::signing)?;
1412
+
1413
+ doc.serialize().map_err(DsigError::native)
1414
+ }
1415
+
1416
+ /// Verifies the nth `<ds:Signature>` against trusted/untrusted certs.
1417
+ ///
1418
+ /// `trusted_pems` may contain certificate PEMs, key PEMs, or both.
1419
+ /// Every trusted PEM is converted to a public key and pinned on the
1420
+ /// dsig context as `signKey`, which makes xmlsec skip KeyInfo lookup.
1421
+ /// That is the `key:` / `certificate:` contract: the document cannot
1422
+ /// supply the verification key via `<KeyValue>`, `<DEREncodedKeyValue>`,
1423
+ /// or `<RetrievalMethod>` (S-01). Multiple certificate PEMs are tried
1424
+ /// in order until one verifies (`certificates:` — any one of these).
1425
+ /// An empty trust list fails closed (`:invalid`), never by reading
1426
+ /// KeyInfo.
1427
+ ///
1428
+ /// Returns a `VerifyResult` with status and captured error messages.
1429
+ /// Only raises (returns `Err`) for conditions that are not a verification
1430
+ /// verdict (unparseable input, out-of-range index, unreadable key).
1431
+ pub fn verify_document(
1432
+ xml: &[u8],
1433
+ trusted_pems: &[Vec<u8>],
1434
+ untrusted_pems: &[Vec<u8>],
1435
+ id_attrs: &[String],
1436
+ signature_index: usize,
1437
+ referenced_documents: HashMap<Vec<u8>, Vec<u8>>,
1438
+ allow_xpath_filter2: bool,
1439
+ ) -> Result<VerifyResult, DsigError> {
1440
+ errors::clear_errors();
1441
+ let serves_external = !referenced_documents.is_empty();
1442
+ let _referenced = ReferencedDocuments::activate(referenced_documents);
1443
+
1444
+ let doc = OwnedXmlDoc::parse(xml).map_err(DsigError::parse)?;
1445
+ let root = doc
1446
+ .root_element()
1447
+ .ok_or(DsigError::parse("XML document has no root element"))?;
1448
+
1449
+ doc.register_id_attributes(root, id_attrs);
1450
+
1451
+ let sig_node = doc
1452
+ .find_nth_signature(root, signature_index)
1453
+ .ok_or(DsigError::native("no <Signature> element found in document"))?;
1454
+
1455
+ let mut pinned_key_pems: Vec<&[u8]> = Vec::new();
1456
+ let mut cert_pems: Vec<&[u8]> = Vec::new();
1457
+ let mut key_indexes = Vec::new();
1458
+ let mut cert_indexes = Vec::new();
1459
+ for (index, pem) in trusted_pems.iter().enumerate() {
1460
+ if pem_is_certificate(pem) {
1461
+ cert_pems.push(pem);
1462
+ cert_indexes.push(index);
1463
+ } else {
1464
+ pinned_key_pems.push(pem);
1465
+ key_indexes.push(index);
1466
+ }
1467
+ }
1468
+
1469
+ if pinned_key_pems.len() > 1 {
1470
+ return Err(DsigError::native("at most one trusted key PEM is supported"));
1471
+ }
1472
+
1473
+ // Chain building stays a Ruby `store:` concern. Native only ever pins
1474
+ // a verification key; untrusted PEMs are still accepted so the FFI
1475
+ // shape is unchanged, but they are not consulted once signKey is set.
1476
+ // Loads the untrusted PEMs, so a failure is about certificate input.
1477
+ let mngr = KeysMngr::new(&[], untrusted_pems).map_err(DsigError::key_load)?;
1478
+
1479
+ let keys_to_try = load_pinned_keys(&pinned_key_pems, &cert_pems)?;
1480
+ // load_pinned_keys tries the one key PEM alone, or else every certificate.
1481
+ let tried_indexes = if pinned_key_pems.is_empty() { cert_indexes } else { key_indexes };
1482
+ let reference_element_indexes =
1483
+ collect_reference_element_indexes(&doc, root, sig_node);
1484
+
1485
+ if keys_to_try.is_empty() {
1486
+ return Ok(VerifyResult {
1487
+ status: VerifyStatus::Invalid,
1488
+ errors: vec!["no trusted key or certificate provided".into()],
1489
+ reference_valid: vec![None; reference_element_indexes.len()],
1490
+ reference_element_indexes,
1491
+ trusted_index: None,
1492
+ });
1493
+ }
1494
+
1495
+ let mut status = VerifyStatus::Invalid;
1496
+ let mut captured_errors = Vec::new();
1497
+ let mut reference_valid = Vec::new();
1498
+ let mut trusted_index = None;
1499
+ for (key, index) in keys_to_try.into_iter().zip(tried_indexes) {
1500
+ errors::clear_errors();
1501
+ let mut ctx = DsigCtx::for_signing(&mngr, key).map_err(DsigError::native)?;
1502
+ if serves_external {
1503
+ ctx.allow_referenced_documents();
1504
+ }
1505
+ if allow_xpath_filter2 {
1506
+ ctx.enable_xpath_filter2().map_err(DsigError::native)?;
1507
+ }
1508
+ status = ctx.verify(sig_node);
1509
+ captured_errors = errors::drain_errors();
1510
+ reference_valid = ctx.reference_statuses();
1511
+ if status == VerifyStatus::Succeeded {
1512
+ trusted_index = Some(index);
1513
+ break;
1514
+ }
1515
+ }
1516
+
1517
+ Ok(VerifyResult {
1518
+ status,
1519
+ errors: captured_errors,
1520
+ reference_element_indexes,
1521
+ reference_valid,
1522
+ trusted_index,
1523
+ })
1524
+ }
1525
+
1526
+ /// One key PEM pins immediately. Otherwise each certificate PEM is loaded
1527
+ /// as a public key so `certificates:` can try them until one matches.
1528
+ fn load_pinned_keys(
1529
+ key_pems: &[&[u8]],
1530
+ cert_pems: &[&[u8]],
1531
+ ) -> Result<Vec<XmlSecKey>, DsigError> {
1532
+ if let Some(pem) = key_pems.first() {
1533
+ return Ok(vec![XmlSecKey::from_pem(pem).map_err(DsigError::key_load)?]);
1534
+ }
1535
+
1536
+ cert_pems
1537
+ .iter()
1538
+ .map(|pem| XmlSecKey::from_cert_pem(pem).map_err(DsigError::key_load))
1539
+ .collect()
1540
+ }
1541
+
1542
+ /// For each SignedInfo `<Reference>`, report the document-order element index
1543
+ /// of the node the libxml2 ID table resolves — the same table xmlsec used for
1544
+ /// `#id` fragment verification. Empty URI → root index. Non-fragment / missing
1545
+ /// ID → `None`. `#xpointer(/)` is the whole document too, with comments (#85).
1546
+ fn collect_reference_element_indexes(
1547
+ doc: &OwnedXmlDoc,
1548
+ root: sys::xmlNodePtr,
1549
+ sig_node: sys::xmlNodePtr,
1550
+ ) -> Vec<Option<usize>> {
1551
+ let signed_info = unsafe { find_child(sig_node, "SignedInfo", DSIG_NS) };
1552
+ let Some(signed_info) = signed_info else {
1553
+ return Vec::new();
1554
+ };
1555
+
1556
+ let ref_nodes = unsafe { find_children(signed_info, "Reference", DSIG_NS) };
1557
+ ref_nodes
1558
+ .into_iter()
1559
+ .map(|ref_node| {
1560
+ let uri = unsafe { get_attribute(ref_node, b"URI\0") }.unwrap_or_default();
1561
+ resolve_reference_element(doc, root, &uri)
1562
+ .and_then(|node| element_document_index(root, node))
1563
+ })
1564
+ .collect()
1565
+ }
1566
+
1567
+ fn resolve_reference_element(
1568
+ doc: &OwnedXmlDoc,
1569
+ root: sys::xmlNodePtr,
1570
+ uri: &str,
1571
+ ) -> Option<sys::xmlNodePtr> {
1572
+ if uri.is_empty() || uri == "#xpointer(/)" {
1573
+ return Some(root);
1574
+ }
1575
+ if let Some(id) = uri.strip_prefix('#') {
1576
+ // Bare fragment only — other xpointer(...) forms are left unresolved
1577
+ // here (Ruby already treats them as node: nil).
1578
+ if id.is_empty() || id.contains('(') {
1579
+ return None;
1580
+ }
1581
+ return element_for_id(doc, id);
1582
+ }
1583
+ None
1584
+ }
1585
+
1586
+ fn element_for_id(doc: &OwnedXmlDoc, id: &str) -> Option<sys::xmlNodePtr> {
1587
+ let mut id_bytes = id.as_bytes().to_vec();
1588
+ id_bytes.push(0);
1589
+
1590
+ // SAFETY: doc.0 is a live OwnedXmlDoc; id_bytes is NUL-terminated for the
1591
+ // duration of the call. xmlGetID returns a pointer into the document's ID
1592
+ // table (not newly allocated).
1593
+ let attr = unsafe { sys::xmlGetID(doc.0, id_bytes.as_ptr().cast()) };
1594
+ if attr.is_null() {
1595
+ return None;
1596
+ }
1597
+
1598
+ // SAFETY: attr is a valid xmlAttr from this document; its parent is the
1599
+ // element that owns the ID attribute.
1600
+ let parent = unsafe { (*attr).parent };
1601
+ if parent.is_null() {
1602
+ return None;
1603
+ }
1604
+ let parent_ref = unsafe { &*parent };
1605
+ if parent_ref.type_ != sys::xmlElementType_XML_ELEMENT_NODE {
1606
+ return None;
1607
+ }
1608
+ Some(parent)
1609
+ }
1610
+
1611
+ /// Document-order index among element nodes only (root = 0), matching
1612
+ /// Nokogiri/`//*` order for the same serialized tree.
1613
+ fn element_document_index(root: sys::xmlNodePtr, target: sys::xmlNodePtr) -> Option<usize> {
1614
+ let mut index = 0usize;
1615
+ find_element_index(root, target, &mut index)
1616
+ }
1617
+
1618
+ fn find_element_index(
1619
+ node: sys::xmlNodePtr,
1620
+ target: sys::xmlNodePtr,
1621
+ index: &mut usize,
1622
+ ) -> Option<usize> {
1623
+ if node.is_null() {
1624
+ return None;
1625
+ }
1626
+
1627
+ // SAFETY: node is non-null within the document lifetime.
1628
+ let node_ref = unsafe { &*node };
1629
+ if node_ref.type_ == sys::xmlElementType_XML_ELEMENT_NODE {
1630
+ if std::ptr::eq(node, target) {
1631
+ return Some(*index);
1632
+ }
1633
+ *index += 1;
1634
+
1635
+ let mut child = node_ref.children;
1636
+ while !child.is_null() {
1637
+ if let Some(found) = find_element_index(child, target, index) {
1638
+ return Some(found);
1639
+ }
1640
+ child = unsafe { (*child).next };
1641
+ }
1642
+ }
1643
+ None
1644
+ }
1645
+
1646
+ fn pem_is_certificate(pem: &[u8]) -> bool {
1647
+ const MARKER: &[u8] = b"BEGIN CERTIFICATE";
1648
+ pem.windows(MARKER.len()).any(|window| window == MARKER)
1649
+ }
1650
+
1651
+ /// Returns version information for the linked libraries.
1652
+ pub fn versions() -> (String, String, String) {
1653
+ let xmlsec_version = std::str::from_utf8(sys::XMLSEC_VERSION)
1654
+ .unwrap_or("unknown\0")
1655
+ .trim_matches('\0')
1656
+ .to_string();
1657
+
1658
+ let libxml2_version = std::str::from_utf8(sys::LIBXML_DOTTED_VERSION)
1659
+ .unwrap_or("unknown\0")
1660
+ .trim_matches('\0')
1661
+ .to_string();
1662
+
1663
+ // The crypto backend is always openssl in our build
1664
+ let crypto = "openssl".to_string();
1665
+
1666
+ (xmlsec_version, libxml2_version, crypto)
1667
+ }
1668
+
1669
+ /// Initializes xmlsec + its OpenSSL crypto backend + our error callback.
1670
+ /// `crate` wraps this in a `std::sync::Once` so it runs exactly once per
1671
+ /// process.
1672
+ pub fn init_xmlsec() -> Result<(), &'static str> {
1673
+ // SAFETY: each of these takes either no arguments or a null config
1674
+ // pointer (meaning "use defaults"). They must run in this exact order,
1675
+ // before any other xmlsec call — guaranteed by the caller's Once.
1676
+ unsafe {
1677
+ if sys::xmlSecInit() < 0 {
1678
+ return Err("xmlSecInit failed");
1679
+ }
1680
+ if sys::xmlSecOpenSSLAppInit(ptr::null()) < 0 {
1681
+ return Err("xmlSecOpenSSLAppInit failed");
1682
+ }
1683
+ if sys::xmlSecOpenSSLInit() < 0 {
1684
+ return Err("xmlSecOpenSSLInit failed");
1685
+ }
1686
+ errors::install_error_callback();
1687
+
1688
+ // xmlSecInit registers file, HTTP and FTP readers for Reference
1689
+ // URIs. Replace them with one that reads only the caller's
1690
+ // referenced_documents: map (#74), so no URI can ever reach the
1691
+ // filesystem or network, whatever enabledReferenceUris allows. This
1692
+ // is xmlsec's own input list; libxml2's parser I/O (Nokogiri's) is
1693
+ // untouched.
1694
+ sys::xmlSecIOCleanupCallbacks();
1695
+ if sys::xmlSecIORegisterCallbacks(
1696
+ Some(referenced_match),
1697
+ Some(referenced_open),
1698
+ Some(referenced_read),
1699
+ Some(referenced_close),
1700
+ ) < 0
1701
+ {
1702
+ return Err("xmlSecIORegisterCallbacks failed");
1703
+ }
1704
+ }
1705
+ Ok(())
1706
+ }
1707
+
1708
+ // ---------------------------------------------------------------------------
1709
+ // ReferencedDocuments — the only source xmlsec may read a URI from
1710
+ // ---------------------------------------------------------------------------
1711
+
1712
+ thread_local! {
1713
+ /// The `referenced_documents:` of the verification running on this
1714
+ /// thread, keyed by URI (the Reference URI up to any `#`). The GVL is held
1715
+ /// for the whole native call, and xmlsec calls the IO callbacks on the
1716
+ /// calling thread, so a thread-local is enough (§7.1).
1717
+ static REFERENCED: RefCell<HashMap<Vec<u8>, Vec<u8>>> = RefCell::new(HashMap::new());
1718
+ }
1719
+
1720
+ /// Makes a map visible to the IO callbacks until dropped, so an early
1721
+ /// return cannot leave one verification's documents readable by the next.
1722
+ struct ReferencedDocuments;
1723
+
1724
+ impl ReferencedDocuments {
1725
+ fn activate(documents: HashMap<Vec<u8>, Vec<u8>>) -> Self {
1726
+ REFERENCED.with(|map| *map.borrow_mut() = documents);
1727
+ Self
1728
+ }
1729
+ }
1730
+
1731
+ impl Drop for ReferencedDocuments {
1732
+ fn drop(&mut self) {
1733
+ REFERENCED.with(|map| map.borrow_mut().clear());
1734
+ }
1735
+ }
1736
+
1737
+ /// An open referenced document: its bytes and the read position.
1738
+ struct ReferencedReader {
1739
+ data: Vec<u8>,
1740
+ pos: usize,
1741
+ }
1742
+
1743
+ /// # Safety
1744
+ ///
1745
+ /// `uri` is null or a NUL-terminated string, per `xmlInputMatchCallback`.
1746
+ unsafe extern "C" fn referenced_match(uri: *const c_char) -> c_int {
1747
+ if uri.is_null() {
1748
+ return 0;
1749
+ }
1750
+ // SAFETY: non-null and NUL-terminated (see above).
1751
+ let uri = unsafe { CStr::from_ptr(uri) }.to_bytes();
1752
+ REFERENCED.with(|map| map.try_borrow().map(|m| m.contains_key(uri)).unwrap_or(false)) as c_int
1753
+ }
1754
+
1755
+ /// # Safety
1756
+ ///
1757
+ /// `uri` is null or a NUL-terminated string, per `xmlInputOpenCallback`.
1758
+ unsafe extern "C" fn referenced_open(uri: *const c_char) -> *mut c_void {
1759
+ if uri.is_null() {
1760
+ return ptr::null_mut();
1761
+ }
1762
+ // SAFETY: non-null and NUL-terminated (see above).
1763
+ let uri = unsafe { CStr::from_ptr(uri) }.to_bytes();
1764
+ let data = REFERENCED.with(|map| map.try_borrow().ok().and_then(|m| m.get(uri).cloned()));
1765
+ match data {
1766
+ Some(data) => Box::into_raw(Box::new(ReferencedReader { data, pos: 0 })).cast(),
1767
+ None => ptr::null_mut(),
1768
+ }
1769
+ }
1770
+
1771
+ /// # Safety
1772
+ ///
1773
+ /// `context` came from [`referenced_open`] and has not been closed;
1774
+ /// `buffer` has room for `len` bytes.
1775
+ unsafe extern "C" fn referenced_read(context: *mut c_void, buffer: *mut c_char, len: c_int) -> c_int {
1776
+ if context.is_null() || buffer.is_null() || len < 0 {
1777
+ return -1;
1778
+ }
1779
+ // SAFETY: context is a live ReferencedReader (see above).
1780
+ let reader = unsafe { &mut *context.cast::<ReferencedReader>() };
1781
+ let count = (reader.data.len() - reader.pos).min(len as usize);
1782
+ // SAFETY: count <= len bytes fit in buffer, and count bytes remain in data.
1783
+ unsafe { ptr::copy_nonoverlapping(reader.data.as_ptr().add(reader.pos), buffer.cast::<u8>(), count) };
1784
+ reader.pos += count;
1785
+ count as c_int
1786
+ }
1787
+
1788
+ /// # Safety
1789
+ ///
1790
+ /// `context` came from [`referenced_open`] and is closed exactly once.
1791
+ unsafe extern "C" fn referenced_close(context: *mut c_void) -> c_int {
1792
+ if !context.is_null() {
1793
+ // SAFETY: context is the Box leaked by referenced_open.
1794
+ drop(unsafe { Box::from_raw(context.cast::<ReferencedReader>()) });
1795
+ }
1796
+ 0
1797
+ }
1798
+
1799
+ /// True when the linked libxmlsec1 matches the version these bindings were
1800
+ /// generated against, down to the patch level.
1801
+ ///
1802
+ /// Stricter than [`abi_compatible`] and used only for reporting: an exact
1803
+ /// mismatch is worth telling the user about, but on its own it is not a
1804
+ /// reason to refuse to load.
1805
+ pub fn check_version() -> bool {
1806
+ check_version_mode(sys::xmlSecCheckVersionMode_xmlSecCheckVersionExactMatch)
1807
+ }
1808
+
1809
+ /// True when the linked libxmlsec1 is ABI compatible with the headers these
1810
+ /// bindings were generated from — same major version, and a minor version no
1811
+ /// older than ours.
1812
+ ///
1813
+ /// This is the check that actually matters. bindgen bakes xmlsec's and
1814
+ /// libxml2's struct layouts into this extension at build time, and nothing
1815
+ /// about a layout change is visible to the dynamic linker: every symbol still
1816
+ /// resolves, the library still loads, and then a field gets read at the offset
1817
+ /// our headers claimed rather than the one the loaded library used. Refusing
1818
+ /// to load is the only safe response in a library whose job is deciding
1819
+ /// whether a signature can be trusted.
1820
+ pub fn abi_compatible() -> bool {
1821
+ check_version_mode(sys::xmlSecCheckVersionMode_xmlSecCheckVersionABICompatible)
1822
+ }
1823
+
1824
+ fn check_version_mode(mode: sys::xmlSecCheckVersionMode) -> bool {
1825
+ // SAFETY: takes only plain integers — no pointers, no preconditions
1826
+ // beyond the xmlsec headers/library being loaded.
1827
+ unsafe {
1828
+ sys::xmlSecCheckVersionExt(
1829
+ sys::XMLSEC_VERSION_MAJOR as i32,
1830
+ sys::XMLSEC_VERSION_MINOR as i32,
1831
+ sys::XMLSEC_VERSION_SUBMINOR as i32,
1832
+ mode,
1833
+ ) == 1
1834
+ }
1835
+ }
1836
+
1837
+
1838
+ #[cfg(test)]
1839
+ mod tests {
1840
+ use super::ffi_len;
1841
+ use std::os::raw::c_int;
1842
+
1843
+ #[test]
1844
+ fn ffi_len_rejects_lengths_that_would_wrap() {
1845
+ assert_eq!(ffi_len::<c_int>(0), Some(0));
1846
+ assert_eq!(ffi_len::<c_int>(c_int::MAX as usize), Some(c_int::MAX));
1847
+ assert_eq!(ffi_len::<c_int>(c_int::MAX as usize + 1), None);
1848
+ assert_eq!(ffi_len::<u32>(u32::MAX as usize + 1), None);
1849
+ assert_eq!(ffi_len::<c_int>(usize::MAX), None);
1850
+ }
1851
+ }