commit 35772d0b6eb2e695b1552c03fa41608e1155e919
parent a537dd277ff1b961d16d2711d7e9a728ab5cd4c9
Author: Antoine A <>
Date: Fri, 24 Apr 2026 10:37:56 +0200
Optimize signature
Diffstat:
| M | src/main.rs | | | 245 | +------------------------------------------------------------------------------ |
| A | src/xml_sign.rs | | | 292 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
2 files changed, 295 insertions(+), 242 deletions(-)
diff --git a/src/main.rs b/src/main.rs
@@ -17,29 +17,21 @@
* <http://www.gnu.org/licenses/>
*/
-use std::{
- collections::{BTreeMap, HashSet},
- fmt::Display,
-};
+use std::fmt::Display;
use anyhow::bail;
-use aws_lc_rs::{
- digest::Digest, rand::SystemRandom, signature::RSA_PKCS1_SHA256, signature::RsaKeyPair,
-};
-use base64::{Engine, prelude::BASE64_STANDARD};
use clap::Parser;
use reqwest::{
Client, StatusCode,
header::{CONTENT_TYPE, HeaderValue},
};
-use roxmltree::{Document, Node};
use taler_build::long_version;
use taler_common::{CommonArgs, config::parser::ConfigSource, taler_main};
use tracing::{debug, info};
use crate::{
config::{EbicsHostCfg, NexusCfg},
- ebics_code::EbicsReturnCode,
+ ebics_code::EbicsReturnCode, xml_sign::sign_ebics,
};
use crate::{keys::ClientPriKeysFile, xml::XmlReader};
@@ -47,6 +39,7 @@ pub mod config;
pub mod ebics_code;
pub mod keys;
pub mod xml;
+pub mod xml_sign;
const SOURCE: ConfigSource = ConfigSource::new("libeufin", "libeufin-nexus", "libeufin-nexus");
@@ -122,44 +115,6 @@ pub async fn hev(http: &Client, cfg: &EbicsHostCfg) -> anyhow::Result<Vec<Versio
.ok_or_fail(phase)
}
-const C14N_ALG: &str = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315";
-const SIG_ALG: &str = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
-const DIGEST_ALG: &str = "http://www.w3.org/2001/04/xmlenc#sha256";
-const DSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#";
-
-pub fn sign_ebics(mut xml: String, key: &RsaKeyPair) -> String {
- let doc = Document::parse(&xml).unwrap();
-
- let digest = digest_authenticated(&doc);
- let digest = BASE64_STANDARD.encode(digest.as_ref());
-
- // Wrap signed info for signature
- // TODO xmlns builder
- let signed_info = format!(
- r##"<ds:SignedInfo xmlns="urn:org:ebics:H005" xmlns:ds="{DSIG_NS}"><ds:CanonicalizationMethod Algorithm="{C14N_ALG}"/><ds:SignatureMethod Algorithm="{SIG_ALG}"/><ds:Reference URI="#xpointer(//*[@authenticate='true'])"><ds:Transforms><ds:Transform Algorithm="{C14N_ALG}"/></ds:Transforms><ds:DigestMethod Algorithm="{DIGEST_ALG}"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:SignedInfo>"##
- );
- let signed_info = roxmltree::Document::parse(&signed_info).unwrap();
-
- let mut sig = vec![0u8; key.public_modulus_len()];
- let mut c14n = String::new();
- c14n_inclusive(signed_info.root_element(), HashSet::new(), &mut c14n);
- key.sign(
- &RSA_PKCS1_SHA256,
- &SystemRandom::new(),
- c14n.as_bytes(),
- &mut sig,
- )
- .unwrap();
- let sig = BASE64_STANDARD.encode(sig);
- // TODO xmlns builder
- let sig_block = format!(
- r##"<AuthSignature><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="{C14N_ALG}"/><ds:SignatureMethod Algorithm="{SIG_ALG}"/><ds:Reference URI="#xpointer(//*[@authenticate='true'])"><ds:Transforms><ds:Transform Algorithm="{C14N_ALG}"/></ds:Transforms><ds:DigestMethod Algorithm="{DIGEST_ALG}"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>{sig}</ds:SignatureValue></AuthSignature>"##
- );
- let pattern = "<AuthSignature/>";
- let start = xml.find(pattern).unwrap();
- xml.replace_range(start..start + pattern.len(), &sig_block);
- xml
-}
/*
fn parse_signature(xml: Document) -> ((usize, usize), Vec<u8>, Vec<(String, Vec<u8>)>) {
XmlDestructor::parse_doc(xml, "ebicsNoPubKeyDigestsRequest", |r| {
@@ -325,197 +280,3 @@ impl Display for VersionNumber {
write!(f, "{number}:{schema}")
}
}
-
-// C14N requires specific escaping for Text nodes
-fn escape_text(text: &str) -> String {
- text.replace('&', "&")
- .replace('<', "<")
- .replace('>', ">")
- .replace('\r', "
")
-}
-
-// C14N requires specific escaping for Attributes
-fn escape_attr(text: &str) -> String {
- text.replace('&', "&")
- .replace('<', "<")
- .replace('"', """)
- .replace('\t', "	")
- .replace('\n', "
")
- .replace('\r', "
")
-}
-
-/// Updated C14N logic to prevent redundant namespace declarations
-fn c14n_inclusive(node: Node, mut active_namespaces: HashSet<String>, out: &mut String) {
- if node.is_text() {
- out.push_str(&escape_text(node.text().unwrap_or("")));
- } else if node.is_element() {
- out.push('<');
-
- // --- FIX: Reconstruct the Tag Name with Prefix ---
- let prefix = node
- .tag_name()
- .namespace()
- .and_then(|uri| node.lookup_prefix(uri));
- let tag_name = if let Some(ns) = prefix {
- format!("{}:{}", ns, node.tag_name().name())
- } else {
- node.tag_name().name().to_string()
- };
- out.push_str(&tag_name);
-
- let mut ns_to_render = BTreeMap::new();
- let mut attributes = BTreeMap::new();
-
- // 1. Inclusive Namespaces (In-scope namespaces)
- for ns in node.namespaces() {
- let prefix = ns.name().unwrap_or("");
- let key = if prefix.is_empty() {
- "xmlns".to_string()
- } else {
- format!("xmlns:{}", prefix)
- };
- let uri = ns.uri().to_string();
-
- let decl = format!("{}=\"{}\"", key, uri);
- if !active_namespaces.contains(&decl) {
- ns_to_render.insert(key, uri);
- active_namespaces.insert(decl);
- }
- }
-
- // 2. Attributes (same as before)
- for attr in node.attributes() {
- attributes.insert(attr.name().to_string(), attr.value().to_string());
- }
-
- // Render sorted Namespaces then Attributes
- for (k, v) in ns_to_render {
- out.push_str(&format!(" {}=\"{}\"", k, escape_attr(&v)));
- }
- for (k, v) in attributes {
- out.push_str(&format!(" {}=\"{}\"", k, escape_attr(&v)));
- }
-
- out.push('>');
-
- for child in node.children() {
- // Pass the cloned active_namespaces down to children
- c14n_inclusive(child, active_namespaces.clone(), out);
- }
-
- out.push_str(&format!("</{}>", tag_name));
- }
-}
-
-pub fn digest_authenticated(doc: &Document) -> Digest {
- fn find_top_level_authenticators<'a>(node: Node<'a, 'a>, results: &mut Vec<Node<'a, 'a>>) {
- // Check if this specific node meets the criteria
- if node.attribute("authenticate") == Some("true") {
- results.push(node);
- // CRITICAL: We do NOT iterate over node.children() here.
- // This "prunes" the search for this branch.
- } else {
- // If this node didn't match, check its children
- for child in node.children().filter(|n| n.is_element()) {
- find_top_level_authenticators(child, results);
- }
- }
- }
- let mut nodes = Vec::new();
-
- find_top_level_authenticators(doc.root(), &mut nodes);
-
- let mut out = String::new();
- for node in nodes {
- c14n_inclusive(node, HashSet::new(), &mut out);
- }
-
- aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, out.as_bytes())
-}
-
-#[cfg(test)]
-mod test {
- use aws_lc_rs::signature::RsaKeyPair;
- use base64::{Engine as _, prelude::BASE64_STANDARD};
- use roxmltree::Document;
- use taler_common::types::base32;
-
- use crate::{digest_authenticated, sign_ebics};
-
- #[test]
- fn canonicalize() {
- let xml = r##"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><ebicsNoPubKeyDigestsRequest xmlns="urn:org:ebics:H005" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Revision="1" Version="H005"><header authenticate="true"><static><HostID>PFEBICS</HostID><Nonce>BC750C641453F93EBF236A9B25F6B70A</Nonce><Timestamp>2026-02-14T18:10:31.125926573Z</Timestamp><PartnerID>PFC00563</PartnerID><UserID>PFC00563</UserID><OrderDetails><AdminOrderType>HPB</AdminOrderType></OrderDetails><SecurityMedium>0000</SecurityMedium></static><mutable/></header><AuthSignature><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#xpointer(//*[@authenticate='true'])"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>ws6QyiLpZVu+CbpqlhQ11PGwCdHSgmtmL7FvwrqZqmU=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>RvVxaDRsgtyZITf3C/UfmWGLERFRWZFxbwb5yhoJBOu5f6KsythhNvF28gznE1VN7E+5oP+nRkba
-hUBX3Y+0PahH+XeOnPGuUYdiOy0/FydtG2E1oQELNRojWhxxJMKPpN6jO9Y3j8QmS31oAWUiLjgA
-S//AU924Wh0rIwA8L3riSzGZDAgf6c0Wg+loPk581AD9QtzMiDi6onLVQvlKYtlVJNheTIreG54i
-a6vPTIqlMWB5iA5ZqoE6zO+VWr4sxTPswlHD29dDar7B4YJ1vYLLTzFHc0yJaDjWaURQNr0mDqUC
-kJMyqsK/0dKW+4n3JgWuVGK8YdoUuvmYooqgFw==</ds:SignatureValue></AuthSignature><body/></ebicsNoPubKeyDigestsRequest>
-"##;
-
- let doc = Document::parse(xml).unwrap();
- let res = digest_authenticated(&doc);
- let hex = BASE64_STANDARD.encode(res);
- assert_eq!(hex, "ws6QyiLpZVu+CbpqlhQ11PGwCdHSgmtmL7FvwrqZqmU=");
-
- let xml = r##"<?xml version="1.0" encoding="UTF-8"?>
-<ebicsResponse xmlns="urn:org:ebics:H005" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" Version="H005" Revision="1" xsi:schemaLocation="urn:org:ebics:H005 ebics_response_H005.xsd">
- <header authenticate="true">
- <static>
- <TransactionID>7FD993238073A6ADAE3B0E5C2A8010E6</TransactionID>
- </static>
- <mutable>
- <TransactionPhase>Initialisation</TransactionPhase>
- <OrderID>N0NU</OrderID>
- <ReturnCode>000000</ReturnCode>
- <ReportText>[EBICS_OK] OK</ReportText>
- </mutable>
- </header>
- <AuthSignature>
- <ds:SignedInfo>
- <ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
- <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
- <ds:Reference URI="#xpointer(//*[@authenticate='true'])">
- <ds:Transforms>
- <ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
- </ds:Transforms>
- <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
- <ds:DigestValue>WJz3HUYjV3HMK0Cy+69XCnAcmiD21mJ5BRiQPwsi1VI=</ds:DigestValue>
- </ds:Reference>
- </ds:SignedInfo>
- <ds:SignatureValue>Ug6LWlR5FCrOjKjqa37Y6D/vYdYxDp3FcLnj/SEJU5kCGpqd+MrEJDg0/q726ozlxkw50hEbK+Kh+MDxRPTztxOdc78V9PuAK9mzo41+G6cv26SKZqX3wtCIrcaFhsEfzIqe9m8NwlnQ3aATMxEevjVPLE+TzSd+Tb6vFybt3a6Qi3iHmjTTeNVPTcAte91A2wqI/k+aPbg2ndRio/stGjuvVYDXNy9YuXvg8XEtgkbDtkx90O5shexaUMI/W5YqY49kd7aY4gSY6jf1/rfkWHU556mtPjuYBLg0TL9nOQWIrzw3eIWpVB0xoPvdPfzRtYvT7KEuk5LtSwEfHiLqgw==</ds:SignatureValue>
- </AuthSignature>
- <body>
- <ReturnCode authenticate="true">000000</ReturnCode>
- <TimestampBankParameter authenticate="true">2020-11-25T19:03:45.693Z</TimestampBankParameter>
- </body>
-</ebicsResponse>
-"##;
- let doc = Document::parse(xml).unwrap();
- let res = digest_authenticated(&doc);
- assert_eq!(
- BASE64_STANDARD.encode(res),
- "WJz3HUYjV3HMK0Cy+69XCnAcmiD21mJ5BRiQPwsi1VI="
- );
- }
-
- #[test]
- fn sign() {
- let key = "62109F820403038614N8CJ46YW6G20810M0090G4MWR84153080G00M2040G18GPPFA8VSJD6G0ENC3SH2ET37BXQ1RTRAX162GWVTW33BX14FBAC0N7DFJBKKNS0EJ4DY07TABNKDHGX0EX7XWV47P456NXX8DP33MVZ010X2F248GDXQK3WWYZKAYMA61KYNTJ4QD1BAZWES5GBA8F9WD9EM9WN9ZYQHFGNWVDQ2BEE54CQAGF82AFR0NJEJWFT4QKNVR8QB79VESG623W5NZPFQM1ZSJ20ZDYCJC7KJ1Q23TDJA0C1SY3KWRM97R60BZXKQ9Q9FM1AFQ8CAYDG0FJSQQM0D5W8QQENK79W8VZ0605A1FKYZYBFQX34FBFJKTCSDEZWSYDQM4HD9JF8F86HXW3F2GE9A7H7JHZG7441MJ91H24ND5M8YK4VXYAB7RX8JAXSS8K33BQXF5QBRR20C0G0082G80G0079GETRHX75MR929BDEYWFKTXE82F0QV71AHY3MKKQXNK545W4XSGHGZARZTH5TGABDTW2SJHKXQ787X2CQFY8ZDDHN5WEMXT5VMBZK5B3TJW5XM98GRREWWPA8AJPA8QZ30Q9RYX49228NHSAM8F2DEH40KAXMFT8HB1PDVCVQRQV5HKYBD1Z6Z1ZZZXXJ114X0E4X6R3FMVWQGANAKYRT2S75FKCG00C96EBM1B8RBZPBQZ7FVA9ZB8F64PYG4KRFHT4CYQZ5D6HP1K42PKYB7TA45SZKRDXE3PYTZE6XASJSP9H1EH1JM0ZPMC1Y2FBWPQ8SR2233J55HX6PXWSJ2ZJYTC8V9FM9F442SQM5EGNYK59RCSEGWMZ0WSF98WX4QVDX4CVN3E1GQPNH9K8ERG82G60G1M763Z4MZSJG1MJZQV32HYNMBEV26J8JKC6E53GWKY101RCB1JXN08H8P64P8QTDV9WRS3EQV30557E7QJAYG1K5A4D76DYTNE0GBC7KE5FD6S8ADSJV9XWVVQHVV1BRQVZ4ZWWSK5M9VEVZNRXXBJVZPKR26XEBT6ANVEBTSQ538K1BZQGBQTKWVMDFMG91A4ATXQM2B5J1MC183080RTHA7FVF7SN90B4XQ87GST3Z50H6T6CRQGR0PDDV51HGH1JE76AAWP1VCEWGASWZ2C9KVB8Z5GH71SCW0MF89A02NFNGVQ9D3QV3PMZQSGM6RC35DDBD33J8N5G2V2SN5MCNB3RA7SMJVVG5DG7QHCJ83QX11G5P8KHQ8MFEV69HGXSS7DTHBV71EWP78PPEMSXP7C7ASYZC20M1G02CCQEFM8PYF0M5DPZBEYG56RRD8JYK9PDADYXM7P1SGSZT2J07705TZNKF0Y34W9T28FZ340PRAA5HSDX8SP3EFSFMNV8RC109HKRW0ZP4WRE1E8QJVGS19CZVPAKMRQA17VF9H4HK3FVXYCA2B3FAZTMRVABA9D03P01BYNERVDEJMQ2173562N24FEBYB18EYF94WD3GVX82G60G15KVSJV527Q6723V93NANH5CYPN6H8JE8CTXXA030S1EEBEDT4KYEDZE1BVJ2A42GPCS60BA448VKT83A793QEW5A7EDKFCKWFQYPSZTSCBWRS4ZQTYG00Z5T2G4JMY6PWPS92D6YNJCYK0EGNNFF7QGDXXYWN33MM0296SQ1MK0R0F45EXAQT5S2Q39SXAA8KHR32A8BC0HG418300KMYBR5ZKNTX7SANSJB5SSYGP9RZVHN23RC1J29QRH5XFSMABMDA582GJH5JAE0D31AH5PTAHP04Y61KNCSKNC748N1PRN503VZY7EA1C4G75C0F13K04TE8RVP63K0TQ693E2XB23WSHYERKSZ6AKCTR3E15N1AF70HEKK13E4QCCY2JN896YEWWT9B8CVW50D8C87S75EK3G";
-
- let key: RsaKeyPair =
- RsaKeyPair::from_pkcs8(&base32::decode(key.as_bytes()).unwrap()).unwrap();
- let tmp = r##"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><ebicsNoPubKeyDigestsRequest xmlns="urn:org:ebics:H005" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Revision="1" Version="H005"><header authenticate="true"><static><HostID>PFEBICS</HostID><Nonce>6BC48C9C2576ABD00295788E56DFCD0A</Nonce><Timestamp>2026-02-21T17:01:53.186561035Z</Timestamp><PartnerID>PFC00563</PartnerID><UserID>PFC00563</UserID><OrderDetails><AdminOrderType>HPB</AdminOrderType></OrderDetails><SecurityMedium>0000</SecurityMedium></static><mutable/></header><AuthSignature/><body/></ebicsNoPubKeyDigestsRequest>"##;
- let xml = tmp.to_owned();
- let signed = sign_ebics(xml, &key);
- let doc = Document::parse(&signed).unwrap();
- let signature = doc
- .descendants()
- .find(|it| it.has_tag_name("SignatureValue"))
- .unwrap()
- .text()
- .unwrap();
- assert_eq!(
- signature,
- "eYyb1v/dGVOPndpMhXZlVQM2q9H9BJP77nYOWaa7jjoeLef7/8HjKIv8oq6Kaf6Z9mAfh/Pcip3a75gkdKpz7ocl1YdsaD+CcQkO1J/n4NwY821ccSh0Ahm2PBE168hyEMzPJrDeDtJrYqs+J/+nC8ek0hbo4/WPsH4UoxVu+ANsHR+BnQFQW3k9BFv+XKZbrBltIY62SN73tYwU8QzRtINJLzjhNB3T6S101n4CYwycXpL5b/oXXOUxxfDnn9EmIFt4DIgjxxqDYdQEBytULLORdkIdf563aw2wDaN12OQV2TB9gAs4Uu203FkUbmIagarMhbKKlqa1NkOteZ13Xw=="
- );
- }
-}
diff --git a/src/xml_sign.rs b/src/xml_sign.rs
@@ -0,0 +1,292 @@
+/*
+* This file is part of LibEuFin.
+* Copyright (C) 2026 Taler Systems S.A.
+
+* LibEuFin is free software; you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation; either version 3, or
+* (at your option) any later version.
+
+* LibEuFin is distributed in the hope that it will be useful, but
+* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+* Public License for more details.
+
+* You should have received a copy of the GNU Affero General Public
+* License along with LibEuFin; see the file COPYING. If not, see
+* <http://www.gnu.org/licenses/>
+*/
+
+use std::{
+ borrow::Cow,
+ collections::{BTreeMap, HashSet},
+};
+
+use aws_lc_rs::{digest::Digest, signature::RSA_PKCS1_SHA256};
+use aws_lc_rs::{rand::SystemRandom, signature::RsaKeyPair};
+use base64::{Engine as _, prelude::BASE64_STANDARD};
+use roxmltree::{Document, Node};
+
+fn escape<'a>(text: &'a str, replacements: &[(char, &str)]) -> Cow<'a, str> {
+ // Find the first character that needs escaping
+ let Some(first_pos) = text.find(|c| replacements.iter().any(|(r, _)| *r == c)) else {
+ return Cow::Borrowed(text); // No escaping needed — zero allocations
+ };
+
+ // Pre-allocate with a reasonable estimate
+ let mut output = String::with_capacity(text.len() + 16);
+ output.push_str(&text[..first_pos]);
+
+ for ch in text[first_pos..].chars() {
+ match replacements.iter().find(|(r, _)| *r == ch) {
+ Some((_, escaped)) => output.push_str(escaped),
+ None => output.push(ch),
+ }
+ }
+
+ Cow::Owned(output)
+}
+
+// C14N requires specific escaping for Text nodes
+fn escape_text(text: &str) -> Cow<'_, str> {
+ escape(
+ text,
+ &[
+ ('&', "&"),
+ ('<', "<"),
+ ('>', ">"),
+ ('\r', "
"),
+ ],
+ )
+}
+
+// C14N requires specific escaping for Attributes
+fn escape_attr(text: &str) -> Cow<'_, str> {
+ escape(
+ text,
+ &[
+ ('&', "&"),
+ ('<', "<"),
+ ('"', """),
+ ('\t', "	"),
+ ('\n', "
"),
+ ('\r', "
"),
+ ],
+ )
+}
+
+/// Updated C14N logic to prevent redundant namespace declarations
+fn c14n_inclusive<'a>(
+ node: Node<'a, 'a>,
+ mut active_namespaces: HashSet<(&'a str, &'a str)>,
+ out: &mut String,
+) {
+ if node.is_text() {
+ out.push_str(&escape_text(node.text().unwrap_or("")));
+ } else if node.is_element() {
+ let prefix = node
+ .tag_name()
+ .namespace()
+ .and_then(|uri| node.lookup_prefix(uri));
+ let push_tag_name = |out: &mut String| {
+ if let Some(ns) = prefix {
+ out.push_str(ns);
+ out.push(':');
+ };
+ out.push_str(node.tag_name().name());
+ };
+
+ // Open element
+ out.push('<');
+ push_tag_name(out);
+
+ // Write sorted missing namespaces
+ let missing: BTreeMap<&str, &str> = node
+ .namespaces()
+ .filter_map(|ns| {
+ let value = (ns.name().unwrap_or_default(), ns.uri());
+ active_namespaces.insert(value).then_some(value)
+ })
+ .collect();
+ for (prefix, uri) in missing {
+ out.push(' ');
+ out.push_str("xmlns");
+ if !prefix.is_empty() {
+ out.push(':');
+ out.push_str(prefix);
+ }
+ out.push_str("=\"");
+ out.push_str(uri);
+ out.push('"');
+ }
+
+ // Write sorted attributes
+ let attributes: BTreeMap<&str, &str> = node
+ .attributes()
+ .map(|it| (it.name(), it.value()))
+ .collect();
+ for (k, v) in attributes {
+ out.push(' ');
+ out.push_str(&k);
+ out.push_str("=\"");
+ out.push_str(&escape_attr(&v));
+ out.push('"');
+ }
+
+ out.push('>');
+
+ for child in node.children() {
+ // Pass the cloned active_namespaces down to children
+ c14n_inclusive(child, active_namespaces.clone(), out);
+ }
+
+ out.push_str("</");
+ push_tag_name(out);
+ out.push('>');
+ }
+}
+
+fn digest_authenticated(doc: &Document) -> Digest {
+ fn find_top_level_authenticators<'a>(node: Node<'a, 'a>, results: &mut Vec<Node<'a, 'a>>) {
+ if node.attribute("authenticate") == Some("true") {
+ results.push(node);
+ } else {
+ for child in node.children().filter(|n| n.is_element()) {
+ find_top_level_authenticators(child, results);
+ }
+ }
+ }
+ let mut nodes = Vec::new();
+
+ find_top_level_authenticators(doc.root(), &mut nodes);
+
+ let mut out = String::new();
+ for node in nodes {
+ c14n_inclusive(node, HashSet::new(), &mut out);
+ }
+ aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, out.as_bytes())
+}
+
+const C14N_ALG: &str = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315";
+const SIG_ALG: &str = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
+const DIGEST_ALG: &str = "http://www.w3.org/2001/04/xmlenc#sha256";
+const DSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#";
+
+pub fn sign_ebics(mut xml: String, key: &RsaKeyPair) -> String {
+ let doc = Document::parse(&xml).unwrap();
+
+ let digest = digest_authenticated(&doc);
+ let digest = BASE64_STANDARD.encode(digest.as_ref());
+
+ // Wrap signed info for signature in a canonical form
+ let default_namespace = doc
+ .root_element()
+ .default_namespace()
+ .expect("must be a root EBICS schema namespace");
+ let signed_info = format!(
+ r##"<ds:SignedInfo xmlns="{default_namespace}" xmlns:ds="{DSIG_NS}"><ds:CanonicalizationMethod Algorithm="{C14N_ALG}"></ds:CanonicalizationMethod><ds:SignatureMethod Algorithm="{SIG_ALG}"></ds:SignatureMethod><ds:Reference URI="#xpointer(//*[@authenticate='true'])"><ds:Transforms><ds:Transform Algorithm="{C14N_ALG}"></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="{DIGEST_ALG}"></ds:DigestMethod><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:SignedInfo>"##
+ );
+ let mut sig = vec![0u8; key.public_modulus_len()];
+ key.sign(
+ &RSA_PKCS1_SHA256,
+ &SystemRandom::new(),
+ signed_info.as_bytes(),
+ &mut sig,
+ )
+ .unwrap();
+ let sig = BASE64_STANDARD.encode(sig);
+ let signature = format!(
+ r##"<AuthSignature><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="{C14N_ALG}"/><ds:SignatureMethod Algorithm="{SIG_ALG}"/><ds:Reference URI="#xpointer(//*[@authenticate='true'])"><ds:Transforms><ds:Transform Algorithm="{C14N_ALG}"/></ds:Transforms><ds:DigestMethod Algorithm="{DIGEST_ALG}"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>{sig}</ds:SignatureValue></AuthSignature>"##
+ );
+ let pattern = "<AuthSignature/>";
+ let start = xml.find(pattern).unwrap();
+ xml.replace_range(start..start + pattern.len(), &signature);
+ xml
+}
+
+#[cfg(test)]
+mod test {
+ use aws_lc_rs::signature::RsaKeyPair;
+ use base64::{Engine as _, prelude::BASE64_STANDARD};
+ use roxmltree::Document;
+ use taler_common::types::base32;
+
+ use crate::xml_sign::{digest_authenticated, sign_ebics};
+
+ #[test]
+ fn canonicalize() {
+ let xml = r##"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><ebicsNoPubKeyDigestsRequest xmlns="urn:org:ebics:H005" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Revision="1" Version="H005"><header authenticate="true"><static><HostID>PFEBICS</HostID><Nonce>BC750C641453F93EBF236A9B25F6B70A</Nonce><Timestamp>2026-02-14T18:10:31.125926573Z</Timestamp><PartnerID>PFC00563</PartnerID><UserID>PFC00563</UserID><OrderDetails><AdminOrderType>HPB</AdminOrderType></OrderDetails><SecurityMedium>0000</SecurityMedium></static><mutable/></header><AuthSignature><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#xpointer(//*[@authenticate='true'])"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>ws6QyiLpZVu+CbpqlhQ11PGwCdHSgmtmL7FvwrqZqmU=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>RvVxaDRsgtyZITf3C/UfmWGLERFRWZFxbwb5yhoJBOu5f6KsythhNvF28gznE1VN7E+5oP+nRkba
+hUBX3Y+0PahH+XeOnPGuUYdiOy0/FydtG2E1oQELNRojWhxxJMKPpN6jO9Y3j8QmS31oAWUiLjgA
+S//AU924Wh0rIwA8L3riSzGZDAgf6c0Wg+loPk581AD9QtzMiDi6onLVQvlKYtlVJNheTIreG54i
+a6vPTIqlMWB5iA5ZqoE6zO+VWr4sxTPswlHD29dDar7B4YJ1vYLLTzFHc0yJaDjWaURQNr0mDqUC
+kJMyqsK/0dKW+4n3JgWuVGK8YdoUuvmYooqgFw==</ds:SignatureValue></AuthSignature><body/></ebicsNoPubKeyDigestsRequest>
+"##;
+
+ let doc = Document::parse(xml).unwrap();
+ let res = digest_authenticated(&doc);
+ let hex = BASE64_STANDARD.encode(res);
+ assert_eq!(hex, "ws6QyiLpZVu+CbpqlhQ11PGwCdHSgmtmL7FvwrqZqmU=");
+
+ let xml = r##"<?xml version="1.0" encoding="UTF-8"?>
+<ebicsResponse xmlns="urn:org:ebics:H005" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" Version="H005" Revision="1" xsi:schemaLocation="urn:org:ebics:H005 ebics_response_H005.xsd">
+ <header authenticate="true">
+ <static>
+ <TransactionID>7FD993238073A6ADAE3B0E5C2A8010E6</TransactionID>
+ </static>
+ <mutable>
+ <TransactionPhase>Initialisation</TransactionPhase>
+ <OrderID>N0NU</OrderID>
+ <ReturnCode>000000</ReturnCode>
+ <ReportText>[EBICS_OK] OK</ReportText>
+ </mutable>
+ </header>
+ <AuthSignature>
+ <ds:SignedInfo>
+ <ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
+ <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
+ <ds:Reference URI="#xpointer(//*[@authenticate='true'])">
+ <ds:Transforms>
+ <ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
+ </ds:Transforms>
+ <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
+ <ds:DigestValue>WJz3HUYjV3HMK0Cy+69XCnAcmiD21mJ5BRiQPwsi1VI=</ds:DigestValue>
+ </ds:Reference>
+ </ds:SignedInfo>
+ <ds:SignatureValue>Ug6LWlR5FCrOjKjqa37Y6D/vYdYxDp3FcLnj/SEJU5kCGpqd+MrEJDg0/q726ozlxkw50hEbK+Kh+MDxRPTztxOdc78V9PuAK9mzo41+G6cv26SKZqX3wtCIrcaFhsEfzIqe9m8NwlnQ3aATMxEevjVPLE+TzSd+Tb6vFybt3a6Qi3iHmjTTeNVPTcAte91A2wqI/k+aPbg2ndRio/stGjuvVYDXNy9YuXvg8XEtgkbDtkx90O5shexaUMI/W5YqY49kd7aY4gSY6jf1/rfkWHU556mtPjuYBLg0TL9nOQWIrzw3eIWpVB0xoPvdPfzRtYvT7KEuk5LtSwEfHiLqgw==</ds:SignatureValue>
+ </AuthSignature>
+ <body>
+ <ReturnCode authenticate="true">000000</ReturnCode>
+ <TimestampBankParameter authenticate="true">2020-11-25T19:03:45.693Z</TimestampBankParameter>
+ </body>
+</ebicsResponse>
+"##;
+ let doc = Document::parse(xml).unwrap();
+ let res = digest_authenticated(&doc);
+ assert_eq!(
+ BASE64_STANDARD.encode(res),
+ "WJz3HUYjV3HMK0Cy+69XCnAcmiD21mJ5BRiQPwsi1VI="
+ );
+ }
+
+ #[test]
+ fn sign() {
+ let key = "62109F820403038614N8CJ46YW6G20810M0090G4MWR84153080G00M2040G18GPPFA8VSJD6G0ENC3SH2ET37BXQ1RTRAX162GWVTW33BX14FBAC0N7DFJBKKNS0EJ4DY07TABNKDHGX0EX7XWV47P456NXX8DP33MVZ010X2F248GDXQK3WWYZKAYMA61KYNTJ4QD1BAZWES5GBA8F9WD9EM9WN9ZYQHFGNWVDQ2BEE54CQAGF82AFR0NJEJWFT4QKNVR8QB79VESG623W5NZPFQM1ZSJ20ZDYCJC7KJ1Q23TDJA0C1SY3KWRM97R60BZXKQ9Q9FM1AFQ8CAYDG0FJSQQM0D5W8QQENK79W8VZ0605A1FKYZYBFQX34FBFJKTCSDEZWSYDQM4HD9JF8F86HXW3F2GE9A7H7JHZG7441MJ91H24ND5M8YK4VXYAB7RX8JAXSS8K33BQXF5QBRR20C0G0082G80G0079GETRHX75MR929BDEYWFKTXE82F0QV71AHY3MKKQXNK545W4XSGHGZARZTH5TGABDTW2SJHKXQ787X2CQFY8ZDDHN5WEMXT5VMBZK5B3TJW5XM98GRREWWPA8AJPA8QZ30Q9RYX49228NHSAM8F2DEH40KAXMFT8HB1PDVCVQRQV5HKYBD1Z6Z1ZZZXXJ114X0E4X6R3FMVWQGANAKYRT2S75FKCG00C96EBM1B8RBZPBQZ7FVA9ZB8F64PYG4KRFHT4CYQZ5D6HP1K42PKYB7TA45SZKRDXE3PYTZE6XASJSP9H1EH1JM0ZPMC1Y2FBWPQ8SR2233J55HX6PXWSJ2ZJYTC8V9FM9F442SQM5EGNYK59RCSEGWMZ0WSF98WX4QVDX4CVN3E1GQPNH9K8ERG82G60G1M763Z4MZSJG1MJZQV32HYNMBEV26J8JKC6E53GWKY101RCB1JXN08H8P64P8QTDV9WRS3EQV30557E7QJAYG1K5A4D76DYTNE0GBC7KE5FD6S8ADSJV9XWVVQHVV1BRQVZ4ZWWSK5M9VEVZNRXXBJVZPKR26XEBT6ANVEBTSQ538K1BZQGBQTKWVMDFMG91A4ATXQM2B5J1MC183080RTHA7FVF7SN90B4XQ87GST3Z50H6T6CRQGR0PDDV51HGH1JE76AAWP1VCEWGASWZ2C9KVB8Z5GH71SCW0MF89A02NFNGVQ9D3QV3PMZQSGM6RC35DDBD33J8N5G2V2SN5MCNB3RA7SMJVVG5DG7QHCJ83QX11G5P8KHQ8MFEV69HGXSS7DTHBV71EWP78PPEMSXP7C7ASYZC20M1G02CCQEFM8PYF0M5DPZBEYG56RRD8JYK9PDADYXM7P1SGSZT2J07705TZNKF0Y34W9T28FZ340PRAA5HSDX8SP3EFSFMNV8RC109HKRW0ZP4WRE1E8QJVGS19CZVPAKMRQA17VF9H4HK3FVXYCA2B3FAZTMRVABA9D03P01BYNERVDEJMQ2173562N24FEBYB18EYF94WD3GVX82G60G15KVSJV527Q6723V93NANH5CYPN6H8JE8CTXXA030S1EEBEDT4KYEDZE1BVJ2A42GPCS60BA448VKT83A793QEW5A7EDKFCKWFQYPSZTSCBWRS4ZQTYG00Z5T2G4JMY6PWPS92D6YNJCYK0EGNNFF7QGDXXYWN33MM0296SQ1MK0R0F45EXAQT5S2Q39SXAA8KHR32A8BC0HG418300KMYBR5ZKNTX7SANSJB5SSYGP9RZVHN23RC1J29QRH5XFSMABMDA582GJH5JAE0D31AH5PTAHP04Y61KNCSKNC748N1PRN503VZY7EA1C4G75C0F13K04TE8RVP63K0TQ693E2XB23WSHYERKSZ6AKCTR3E15N1AF70HEKK13E4QCCY2JN896YEWWT9B8CVW50D8C87S75EK3G";
+
+ let key: RsaKeyPair =
+ RsaKeyPair::from_pkcs8(&base32::decode(key.as_bytes()).unwrap()).unwrap();
+ let tmp = r##"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><ebicsNoPubKeyDigestsRequest xmlns="urn:org:ebics:H005" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Revision="1" Version="H005"><header authenticate="true"><static><HostID>PFEBICS</HostID><Nonce>6BC48C9C2576ABD00295788E56DFCD0A</Nonce><Timestamp>2026-02-21T17:01:53.186561035Z</Timestamp><PartnerID>PFC00563</PartnerID><UserID>PFC00563</UserID><OrderDetails><AdminOrderType>HPB</AdminOrderType></OrderDetails><SecurityMedium>0000</SecurityMedium></static><mutable/></header><AuthSignature/><body/></ebicsNoPubKeyDigestsRequest>"##;
+ let xml = tmp.to_owned();
+ let signed = sign_ebics(xml, &key);
+ let doc = Document::parse(&signed).unwrap();
+ let signature = doc
+ .descendants()
+ .find(|it| it.has_tag_name("SignatureValue"))
+ .unwrap()
+ .text()
+ .unwrap();
+ assert_eq!(
+ signature,
+ "eYyb1v/dGVOPndpMhXZlVQM2q9H9BJP77nYOWaa7jjoeLef7/8HjKIv8oq6Kaf6Z9mAfh/Pcip3a75gkdKpz7ocl1YdsaD+CcQkO1J/n4NwY821ccSh0Ahm2PBE168hyEMzPJrDeDtJrYqs+J/+nC8ek0hbo4/WPsH4UoxVu+ANsHR+BnQFQW3k9BFv+XKZbrBltIY62SN73tYwU8QzRtINJLzjhNB3T6S101n4CYwycXpL5b/oXXOUxxfDnn9EmIFt4DIgjxxqDYdQEBytULLORdkIdf563aw2wDaN12OQV2TB9gAs4Uu203FkUbmIagarMhbKKlqa1NkOteZ13Xw=="
+ );
+ }
+}