libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 35772d0b6eb2e695b1552c03fa41608e1155e919
parent a537dd277ff1b961d16d2711d7e9a728ab5cd4c9
Author: Antoine A <>
Date:   Fri, 24 Apr 2026 10:37:56 +0200

Optimize signature

Diffstat:
Msrc/main.rs | 245+------------------------------------------------------------------------------
Asrc/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('&', "&amp;") - .replace('<', "&lt;") - .replace('>', "&gt;") - .replace('\r', "&#xD;") -} - -// C14N requires specific escaping for Attributes -fn escape_attr(text: &str) -> String { - text.replace('&', "&amp;") - .replace('<', "&lt;") - .replace('"', "&quot;") - .replace('\t', "&#x9;") - .replace('\n', "&#xA;") - .replace('\r', "&#xD;") -} - -/// 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&#13; -hUBX3Y+0PahH+XeOnPGuUYdiOy0/FydtG2E1oQELNRojWhxxJMKPpN6jO9Y3j8QmS31oAWUiLjgA&#13; -S//AU924Wh0rIwA8L3riSzGZDAgf6c0Wg+loPk581AD9QtzMiDi6onLVQvlKYtlVJNheTIreG54i&#13; -a6vPTIqlMWB5iA5ZqoE6zO+VWr4sxTPswlHD29dDar7B4YJ1vYLLTzFHc0yJaDjWaURQNr0mDqUC&#13; -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, + &[ + ('&', "&amp;"), + ('<', "&lt;"), + ('>', "&gt;"), + ('\r', "&#xD;"), + ], + ) +} + +// C14N requires specific escaping for Attributes +fn escape_attr(text: &str) -> Cow<'_, str> { + escape( + text, + &[ + ('&', "&amp;"), + ('<', "&lt;"), + ('"', "&quot;"), + ('\t', "&#x9;"), + ('\n', "&#xA;"), + ('\r', "&#xD;"), + ], + ) +} + +/// 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&#13; +hUBX3Y+0PahH+XeOnPGuUYdiOy0/FydtG2E1oQELNRojWhxxJMKPpN6jO9Y3j8QmS31oAWUiLjgA&#13; +S//AU924Wh0rIwA8L3riSzGZDAgf6c0Wg+loPk581AD9QtzMiDi6onLVQvlKYtlVJNheTIreG54i&#13; +a6vPTIqlMWB5iA5ZqoE6zO+VWr4sxTPswlHD29dDar7B4YJ1vYLLTzFHc0yJaDjWaURQNr0mDqUC&#13; +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==" + ); + } +}