PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
wss_signer.cpp
Go to the documentation of this file.
1// BSD 3-Clause License
2// Copyright (c) 2021-2025, 🍀☀🌕🌥 🌊
3// See the LICENSE file in the project root for full license information.
4
39#include "wss_signer.h"
40
41#include "soap_envelope.h"
42
43#include <openssl/bio.h>
44#include <openssl/err.h>
45#include <openssl/evp.h>
46#include <openssl/pem.h>
47#include <openssl/sha.h>
48#include <openssl/x509.h>
49
50#include <pugixml.hpp>
51
52#include <cstring>
53#include <cstdio>
54#include <memory>
55#include <string>
56#include <vector>
57
59
60namespace {
61
62// Project-local canonicalization identifier. See the canonicalization
63// policy section at the top of this file. Single-source to prevent
64// drift between sign_envelope and verify_envelope - any divergence
65// would silently accept signatures with a different byte stream.
66constexpr const char* kKcenonC14nUri =
67 "urn:kcenon:xds:c14n:pugixml-format-raw-v1";
68
69struct bio_deleter {
70 void operator()(BIO* bio) const noexcept {
71 if (bio) BIO_free_all(bio);
72 }
73};
74using bio_ptr = std::unique_ptr<BIO, bio_deleter>;
75
76struct x509_deleter {
77 void operator()(X509* x) const noexcept {
78 if (x) X509_free(x);
79 }
80};
81using x509_ptr = std::unique_ptr<X509, x509_deleter>;
82
83struct pkey_deleter {
84 void operator()(EVP_PKEY* p) const noexcept {
85 if (p) EVP_PKEY_free(p);
86 }
87};
88using pkey_ptr = std::unique_ptr<EVP_PKEY, pkey_deleter>;
89
90struct md_ctx_deleter {
91 void operator()(EVP_MD_CTX* c) const noexcept {
92 if (c) EVP_MD_CTX_free(c);
93 }
94};
95using md_ctx_ptr = std::unique_ptr<EVP_MD_CTX, md_ctx_deleter>;
96
97int pem_password_callback(char* buf, int size, int /*rwflag*/, void* userdata) {
98 if (userdata == nullptr) return 0;
99 const auto* pass = static_cast<const std::string*>(userdata);
100 const int to_copy =
101 (static_cast<int>(pass->size()) < size) ? static_cast<int>(pass->size())
102 : size;
103 std::memcpy(buf, pass->data(), static_cast<std::size_t>(to_copy));
104 return to_copy;
105}
106
107x509_ptr load_cert(std::string_view pem) {
108 bio_ptr bio(BIO_new_mem_buf(pem.data(), static_cast<int>(pem.size())));
109 if (!bio) return nullptr;
110 X509* raw = PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr);
111 return x509_ptr(raw);
112}
113
114pkey_ptr load_key(std::string_view pem, std::string_view password) {
115 bio_ptr bio(BIO_new_mem_buf(pem.data(), static_cast<int>(pem.size())));
116 if (!bio) return nullptr;
117 std::string pw(password);
118 EVP_PKEY* raw = PEM_read_bio_PrivateKey(
119 bio.get(), nullptr, pem_password_callback,
120 pw.empty() ? nullptr : static_cast<void*>(&pw));
121 return pkey_ptr(raw);
122}
123
124std::string base64_encode(const unsigned char* data, std::size_t len) {
125 bio_ptr b64(BIO_new(BIO_f_base64()));
126 if (!b64) return {};
127 BIO* mem = BIO_new(BIO_s_mem());
128 if (!mem) return {};
129 BIO* chain = BIO_push(b64.release(), mem);
130 bio_ptr owner(chain);
131 BIO_set_flags(owner.get(), BIO_FLAGS_BASE64_NO_NL);
132 BIO_write(owner.get(), data, static_cast<int>(len));
133 BIO_flush(owner.get());
134 BUF_MEM* bptr = nullptr;
135 BIO_get_mem_ptr(owner.get(), &bptr);
136 if (!bptr) return {};
137 return std::string(bptr->data, bptr->length);
138}
139
140// Strip any whitespace (CR/LF/space/tab) the BST text may carry - the
141// signer emits a single-line base64 string but a real registry will often
142// wrap to 64 or 76 columns. OpenSSL's BIO_f_base64 decoder requires clean
143// input or BIO_FLAGS_BASE64_NO_NL; stripping is simpler and avoids
144// heuristics about which flag to use.
145std::string strip_b64_whitespace(std::string_view s) {
146 std::string out;
147 out.reserve(s.size());
148 for (const char c : s) {
149 if (c != '\r' && c != '\n' && c != ' ' && c != '\t') {
150 out.push_back(c);
151 }
152 }
153 return out;
154}
155
156std::vector<unsigned char> base64_decode(std::string_view b64_in) {
157 const std::string cleaned = strip_b64_whitespace(b64_in);
158 if (cleaned.empty()) return {};
159 bio_ptr b64(BIO_new(BIO_f_base64()));
160 if (!b64) return {};
161 BIO* mem =
162 BIO_new_mem_buf(cleaned.data(), static_cast<int>(cleaned.size()));
163 if (!mem) return {};
164 BIO* chain = BIO_push(b64.release(), mem);
165 bio_ptr owner(chain);
166 BIO_set_flags(owner.get(), BIO_FLAGS_BASE64_NO_NL);
167 std::vector<unsigned char> out(cleaned.size());
168 const int n = BIO_read(owner.get(), out.data(), static_cast<int>(out.size()));
169 if (n <= 0) return {};
170 out.resize(static_cast<std::size_t>(n));
171 return out;
172}
173
174std::vector<unsigned char> cert_to_der(X509* cert) {
175 int len = i2d_X509(cert, nullptr);
176 if (len <= 0) return {};
177 std::vector<unsigned char> out(static_cast<std::size_t>(len));
178 unsigned char* p = out.data();
179 if (i2d_X509(cert, &p) != len) return {};
180 return out;
181}
182
183std::string sha256_base64(std::string_view bytes) {
184 unsigned char digest[SHA256_DIGEST_LENGTH];
185 SHA256(reinterpret_cast<const unsigned char*>(bytes.data()), bytes.size(),
186 digest);
187 return base64_encode(digest, SHA256_DIGEST_LENGTH);
188}
189
190struct xml_string_writer : pugi::xml_writer {
191 std::string out;
192 void write(const void* data, std::size_t size) override {
193 out.append(static_cast<const char*>(data), size);
194 }
195};
196
197std::string serialize_subtree(pugi::xml_node node) {
198 xml_string_writer w;
199 node.print(w, "", pugi::format_raw | pugi::format_no_declaration,
200 pugi::encoding_utf8);
201 return w.out;
202}
203
204} // namespace
205
207 std::string_view cert_pem,
208 std::string_view key_pem,
209 std::string_view key_password) {
210 if (cert_pem.empty()) {
211 return kcenon::common::make_error<bool>(
213 "certificate PEM is empty", std::string(error_source));
214 }
215 if (key_pem.empty()) {
216 return kcenon::common::make_error<bool>(
218 "private key PEM is empty", std::string(error_source));
219 }
220
221 auto cert = load_cert(cert_pem);
222 if (!cert) {
223 return kcenon::common::make_error<bool>(
225 "PEM_read_bio_X509 failed", std::string(error_source));
226 }
227 auto key = load_key(key_pem, key_password);
228 if (!key) {
229 return kcenon::common::make_error<bool>(
231 "PEM_read_bio_PrivateKey failed", std::string(error_source));
232 }
233
234 const auto der = cert_to_der(cert.get());
235 if (der.empty()) {
236 return kcenon::common::make_error<bool>(
238 "i2d_X509 failed", std::string(error_source));
239 }
240 const std::string cert_b64 = base64_encode(der.data(), der.size());
241 if (cert_b64.empty()) {
242 return kcenon::common::make_error<bool>(
244 "base64 of DER certificate failed", std::string(error_source));
245 }
246
247 pugi::xml_document doc;
248 auto parse_result =
249 doc.load_buffer(env.xml.data(), env.xml.size(),
250 pugi::parse_default, pugi::encoding_utf8);
251 if (!parse_result) {
252 return kcenon::common::make_error<bool>(
253 static_cast<int>(error_code::envelope_build_failed),
254 std::string("failed to reparse envelope: ") +
255 parse_result.description(),
256 std::string(error_source));
257 }
258
259 auto envelope = doc.child("soap:Envelope");
260 auto header = envelope.child("soap:Header");
261 auto security = header.child("wsse:Security");
262 auto bst = security.child("wsse:BinarySecurityToken");
263 auto timestamp = security.child("wsu:Timestamp");
264 auto body = envelope.child("soap:Body");
265
266 if (!envelope || !header || !security || !bst || !timestamp || !body) {
267 return kcenon::common::make_error<bool>(
268 static_cast<int>(error_code::envelope_build_failed),
269 "envelope is missing required WS-Security scaffolding",
270 std::string(error_source));
271 }
272
273 bst.text().set(cert_b64.c_str());
274
275 const std::string body_c14n = serialize_subtree(body);
276 const std::string ts_c14n = serialize_subtree(timestamp);
277
278 if (body_c14n.empty() || ts_c14n.empty()) {
279 return kcenon::common::make_error<bool>(
280 static_cast<int>(error_code::signer_digest_failed),
281 "serialized Body or Timestamp is empty",
282 std::string(error_source));
283 }
284
285 const std::string body_digest = sha256_base64(body_c14n);
286 const std::string ts_digest = sha256_base64(ts_c14n);
287 if (body_digest.empty() || ts_digest.empty()) {
288 return kcenon::common::make_error<bool>(
289 static_cast<int>(error_code::signer_digest_failed),
290 "SHA-256 digest failed", std::string(error_source));
291 }
292
293 auto signature = security.append_child("ds:Signature");
294 signature.append_attribute("xmlns:ds") = "http://www.w3.org/2000/09/xmldsig#";
295
296 auto signed_info = signature.append_child("ds:SignedInfo");
297 auto c14n_method = signed_info.append_child("ds:CanonicalizationMethod");
298 c14n_method.append_attribute("Algorithm") = kKcenonC14nUri;
299 auto sig_method = signed_info.append_child("ds:SignatureMethod");
300 sig_method.append_attribute("Algorithm") =
301 "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
302
303 const auto add_reference = [&](const std::string& uri,
304 const std::string& digest_b64) {
305 auto ref = signed_info.append_child("ds:Reference");
306 ref.append_attribute("URI") = ("#" + uri).c_str();
307 auto transforms = ref.append_child("ds:Transforms");
308 auto xform = transforms.append_child("ds:Transform");
309 xform.append_attribute("Algorithm") = kKcenonC14nUri;
310 auto digest_method = ref.append_child("ds:DigestMethod");
311 digest_method.append_attribute("Algorithm") =
312 "http://www.w3.org/2001/04/xmlenc#sha256";
313 ref.append_child("ds:DigestValue").text().set(digest_b64.c_str());
314 };
315 add_reference(env.body_id, body_digest);
316 add_reference(env.timestamp_id, ts_digest);
317
318 const std::string signed_info_bytes = serialize_subtree(signed_info);
319 if (signed_info_bytes.empty()) {
320 return kcenon::common::make_error<bool>(
321 static_cast<int>(error_code::signer_sign_failed),
322 "serialized SignedInfo is empty", std::string(error_source));
323 }
324
325 md_ctx_ptr md(EVP_MD_CTX_new());
326 if (!md) {
327 return kcenon::common::make_error<bool>(
328 static_cast<int>(error_code::signer_sign_failed),
329 "EVP_MD_CTX_new failed", std::string(error_source));
330 }
331 if (EVP_DigestSignInit(md.get(), nullptr, EVP_sha256(), nullptr,
332 key.get()) != 1) {
333 return kcenon::common::make_error<bool>(
334 static_cast<int>(error_code::signer_sign_failed),
335 "EVP_DigestSignInit failed", std::string(error_source));
336 }
337 if (EVP_DigestSignUpdate(
338 md.get(), signed_info_bytes.data(), signed_info_bytes.size()) != 1) {
339 return kcenon::common::make_error<bool>(
340 static_cast<int>(error_code::signer_sign_failed),
341 "EVP_DigestSignUpdate failed", std::string(error_source));
342 }
343
344 std::size_t sig_len = 0;
345 if (EVP_DigestSignFinal(md.get(), nullptr, &sig_len) != 1 || sig_len == 0) {
346 return kcenon::common::make_error<bool>(
347 static_cast<int>(error_code::signer_sign_failed),
348 "EVP_DigestSignFinal probe failed", std::string(error_source));
349 }
350 std::vector<unsigned char> sig(sig_len);
351 if (EVP_DigestSignFinal(md.get(), sig.data(), &sig_len) != 1) {
352 return kcenon::common::make_error<bool>(
353 static_cast<int>(error_code::signer_sign_failed),
354 "EVP_DigestSignFinal failed", std::string(error_source));
355 }
356 sig.resize(sig_len);
357 const std::string sig_b64 = base64_encode(sig.data(), sig.size());
358
359 signature.append_child("ds:SignatureValue").text().set(sig_b64.c_str());
360
361 auto key_info = signature.append_child("ds:KeyInfo");
362 auto sec_ref = key_info.append_child("wsse:SecurityTokenReference");
363 auto ref = sec_ref.append_child("wsse:Reference");
364 ref.append_attribute("URI") = ("#" + env.binary_security_token_id).c_str();
365 ref.append_attribute("ValueType") =
366 "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-"
367 "profile-1.0#X509v3";
368
369 xml_string_writer w;
370 doc.save(w, "", pugi::format_raw | pugi::format_no_declaration,
371 pugi::encoding_utf8);
372 env.xml = R"(<?xml version="1.0" encoding="UTF-8"?>)" + w.out;
373 return true;
374}
375
376namespace {
377
378// The ds:SignedInfo Reference uses "#<wsu:Id>" as its URI attribute. Strip
379// the leading '#' so we can match it against the actual id values emitted
380// on wsu:Timestamp and soap:Body.
381std::string strip_uri_fragment(const char* uri) {
382 if (uri == nullptr) return {};
383 if (uri[0] == '#') return std::string(uri + 1);
384 return std::string(uri);
385}
386
387// pugixml's XPath evaluator has no way to bind the wsu namespace prefix
388// for a `@wsu:Id` predicate without a registered xpath_variable_set, so
389// walk the tree manually. The envelope is small (headers + a single body
390// referenced from the Signature); a linear walk is fast.
391struct wsu_id_walker : pugi::xml_tree_walker {
392 std::string needle;
393 pugi::xml_node match;
394 bool for_each(pugi::xml_node& n) override {
395 const auto attr = n.attribute("wsu:Id");
396 if (!attr.empty() && attr.as_string() == needle) {
397 match = n;
398 return false;
399 }
400 return true;
401 }
402};
403
404pugi::xml_node find_by_wsu_id(pugi::xml_node root, const std::string& id) {
405 wsu_id_walker w;
406 w.needle = id;
407 root.traverse(w);
408 return w.match;
409}
410
411} // namespace
412
414 std::string_view signed_xml) {
415 if (signed_xml.empty()) {
416 return kcenon::common::make_error<bool>(
417 static_cast<int>(
419 "signed envelope is empty", std::string(error_source));
420 }
421
422 pugi::xml_document doc;
423 auto parse = doc.load_buffer(signed_xml.data(), signed_xml.size(),
424 pugi::parse_default, pugi::encoding_utf8);
425 if (!parse) {
426 return kcenon::common::make_error<bool>(
427 static_cast<int>(
429 std::string("failed to reparse signed envelope: ") +
430 parse.description(),
431 std::string(error_source));
432 }
433
434 auto envelope = doc.child("soap:Envelope");
435 auto header = envelope.child("soap:Header");
436 auto security = header.child("wsse:Security");
437 auto signature = security.child("ds:Signature");
438 auto bst = security.child("wsse:BinarySecurityToken");
439
440 if (!envelope || !header || !security) {
441 return kcenon::common::make_error<bool>(
442 static_cast<int>(
444 "envelope lacks WS-Security scaffolding",
445 std::string(error_source));
446 }
447 if (!signature) {
448 return kcenon::common::make_error<bool>(
450 "envelope has no ds:Signature element",
451 std::string(error_source));
452 }
453 if (!bst) {
454 return kcenon::common::make_error<bool>(
455 static_cast<int>(
457 "envelope has no wsse:BinarySecurityToken",
458 std::string(error_source));
459 }
460
461 // Enforce honest-URI policy on every Algorithm attribute within
462 // SignedInfo before trusting the digests.
463 auto signed_info = signature.child("ds:SignedInfo");
464 if (!signed_info) {
465 return kcenon::common::make_error<bool>(
466 static_cast<int>(
468 "Signature has no SignedInfo", std::string(error_source));
469 }
470 auto c14n = signed_info.child("ds:CanonicalizationMethod");
471 if (!c14n ||
472 std::string(c14n.attribute("Algorithm").as_string()) != kKcenonC14nUri) {
473 return kcenon::common::make_error<bool>(
474 static_cast<int>(
476 "CanonicalizationMethod is not the kcenon project-local URI",
477 std::string(error_source));
478 }
479
480 // Load public key from BST.
481 const std::string bst_b64 = strip_b64_whitespace(bst.text().as_string());
482 const auto der = base64_decode(bst_b64);
483 if (der.empty()) {
484 return kcenon::common::make_error<bool>(
485 static_cast<int>(
487 "BinarySecurityToken base64 decode failed",
488 std::string(error_source));
489 }
490
491 // SECURITY NOTE: the X.509 we are about to pull out of the BST is
492 // used only to recover a public key for EVP_DigestVerify; it is NOT
493 // validated against a trust anchor. See the Doxygen on
494 // verify_envelope_integrity for the deferral and tracking issue.
495
496 const unsigned char* der_ptr = der.data();
497 x509_ptr cert(d2i_X509(nullptr, &der_ptr, static_cast<long>(der.size())));
498 if (!cert) {
499 return kcenon::common::make_error<bool>(
500 static_cast<int>(
502 "d2i_X509 failed on BinarySecurityToken",
503 std::string(error_source));
504 }
505 pkey_ptr pkey(X509_get_pubkey(cert.get()));
506 if (!pkey) {
507 return kcenon::common::make_error<bool>(
508 static_cast<int>(
510 "X509_get_pubkey failed", std::string(error_source));
511 }
512
513 // Recompute digests for every Reference in SignedInfo and compare
514 // against the advertised DigestValue.
515 for (auto ref = signed_info.child("ds:Reference"); ref;
516 ref = ref.next_sibling("ds:Reference")) {
517 for (auto xform = ref.child("ds:Transforms").child("ds:Transform");
518 xform; xform = xform.next_sibling("ds:Transform")) {
519 if (std::string(xform.attribute("Algorithm").as_string()) !=
520 kKcenonC14nUri) {
521 return kcenon::common::make_error<bool>(
522 static_cast<int>(
524 "Reference Transform is not the kcenon project-local URI",
525 std::string(error_source));
526 }
527 }
528 const std::string uri = strip_uri_fragment(
529 ref.attribute("URI").as_string());
530 if (uri.empty()) {
531 return kcenon::common::make_error<bool>(
532 static_cast<int>(
534 "Reference URI is empty", std::string(error_source));
535 }
536 auto target = find_by_wsu_id(envelope, uri);
537 if (!target) {
538 return kcenon::common::make_error<bool>(
539 static_cast<int>(
541 "Reference target not found: " + uri,
542 std::string(error_source));
543 }
544 const std::string bytes = serialize_subtree(target);
545 const std::string want_digest = sha256_base64(bytes);
546 const std::string have_digest = strip_b64_whitespace(
547 ref.child("ds:DigestValue").text().as_string());
548 if (want_digest != have_digest) {
549 return kcenon::common::make_error<bool>(
550 static_cast<int>(
552 "DigestValue mismatch for reference: " + uri,
553 std::string(error_source));
554 }
555 }
556
557 // Verify the signature over SignedInfo.
558 const std::string signed_info_bytes = serialize_subtree(signed_info);
559 if (signed_info_bytes.empty()) {
560 return kcenon::common::make_error<bool>(
561 static_cast<int>(
563 "serialized SignedInfo is empty", std::string(error_source));
564 }
565 const auto sig_bytes = base64_decode(
566 signature.child("ds:SignatureValue").text().as_string());
567 if (sig_bytes.empty()) {
568 return kcenon::common::make_error<bool>(
569 static_cast<int>(
571 "SignatureValue base64 decode failed",
572 std::string(error_source));
573 }
574
575 md_ctx_ptr md(EVP_MD_CTX_new());
576 if (!md) {
577 return kcenon::common::make_error<bool>(
578 static_cast<int>(
580 "EVP_MD_CTX_new failed", std::string(error_source));
581 }
582 if (EVP_DigestVerifyInit(md.get(), nullptr, EVP_sha256(), nullptr,
583 pkey.get()) != 1) {
584 return kcenon::common::make_error<bool>(
585 static_cast<int>(
587 "EVP_DigestVerifyInit failed", std::string(error_source));
588 }
589 if (EVP_DigestVerifyUpdate(md.get(), signed_info_bytes.data(),
590 signed_info_bytes.size()) != 1) {
591 return kcenon::common::make_error<bool>(
592 static_cast<int>(
594 "EVP_DigestVerifyUpdate failed", std::string(error_source));
595 }
596 const int verdict =
597 EVP_DigestVerifyFinal(md.get(), sig_bytes.data(), sig_bytes.size());
598 if (verdict != 1) {
599 return kcenon::common::make_error<bool>(
600 static_cast<int>(
602 "EVP_DigestVerifyFinal rejected the signature",
603 std::string(error_source));
604 }
605 return true;
606}
607
608std::vector<std::uint8_t> base64_decode_bytes(std::string_view b64) {
609 const auto raw = base64_decode(b64);
610 std::vector<std::uint8_t> out;
611 out.reserve(raw.size());
612 for (const unsigned char c : raw) {
613 out.push_back(static_cast<std::uint8_t>(c));
614 }
615 return out;
616}
617
618} // namespace kcenon::pacs::ihe::xds::detail
std::string base64_encode(std::string_view input)
Encode data to Base64.
std::vector< std::uint8_t > base64_decode_bytes(std::string_view b64)
Base64-decode b64 using the same BIO-backed decoder the signer and verifier use internally.
kcenon::common::Result< bool > sign_envelope(built_envelope &env, std::string_view cert_pem, std::string_view key_pem, std::string_view key_password)
Sign the body + timestamp of env in place.
kcenon::common::Result< bool > verify_envelope_integrity(std::string_view signed_xml)
Verify the structural integrity of a previously signed envelope against the certificate embedded in i...
constexpr std::string_view error_source
Short identifier used as the "source" field of make_error.
Definition errors.h:95
bool base64_decode(const std::string &in, std::vector< std::uint8_t > &out)
Standard base64 decode; returns false on malformed input.
@ id
Implant Displaced (alternate code)
std::string out
SOAP 1.2 envelope builder for IHE XDS.b ITI-41 requests.
Result of envelope construction.
pugi::xml_node match
std::string needle
WS-Security XML-DSig signer backed by OpenSSL EVP_DigestSign.