libeufin

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

commit a960ec7c8e4119db5e9f45c7be97dd36d4b9181b
parent fa03b8e97ebe07dd5dd3fc6dfbeefd3952c6a297
Author: Antoine A <>
Date:   Wed, 24 Jun 2026 12:20:24 +0200

ebics:
- verify bank signature
- reuse allocation in C14N normalization
- reduce xml signature allocation

Diffstat:
Mlibeufin-bank/src/api.rs | 2+-
Mlibeufin-ebics/src/ebics.rs | 37+++++++++++++++++++++++++++----------
Mlibeufin-ebics/src/ebics/bts.rs | 23++++++++++++-----------
Mlibeufin-ebics/src/ebics/key_management.rs | 16++++++++--------
Mlibeufin-ebics/src/test.rs | 14+++++---------
Mlibeufin-ebics/src/xml_sign.rs | 367++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mlibeufin-ebisync/src/lib.rs | 16+++++++++++++++-
7 files changed, 329 insertions(+), 146 deletions(-)

diff --git a/libeufin-bank/src/api.rs b/libeufin-bank/src/api.rs @@ -199,7 +199,7 @@ async fn config(State(state): State<Arc<BankState>>) -> Response { } pub fn bank_api(state: Arc<BankState>) -> Router { - let router = Router::new().route("/config", get(config)); // TODO monitor + let router = Router::new().route("/config", get(config)); if let Some(path) = &state.cfg.spa_path { router .nest_service("/webui/", ServeDir::new(path)) diff --git a/libeufin-ebics/src/ebics.rs b/libeufin-ebics/src/ebics.rs @@ -22,7 +22,7 @@ use std::{borrow::Cow, io::Write as _}; use aws_lc_rs::{ digest::Digest, encoding::AsDer, - rsa::{self, PrivateDecryptingKey}, + rsa::{self, PrivateDecryptingKey, PublicKey}, }; use compact_str::CompactString; use flate2::write::ZlibDecoder; @@ -32,6 +32,7 @@ use reqwest::{ Client, ClientBuilder, StatusCode, header::{CONTENT_TYPE, HeaderValue}, }; +use roxmltree::Document; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use sqlx::PgPool; use taler_common::encoding::base64; @@ -59,6 +60,7 @@ use crate::{ keys::{BankKeys, ClientKeys}, utils::deflate, xml, + xml_sign::{VerifyError, verify_ebics}, }; pub mod administrative; @@ -253,6 +255,9 @@ pub enum EbicsErrKind { #[error(transparent)] Zip(#[from] zip::result::ZipError), + #[error(transparent)] + Signature(#[from] VerifyError), + #[error("{0}")] Custom(Cow<'static, str>), } @@ -337,11 +342,12 @@ impl<'a> EbicsClient<'a> { &self, xml: String, ctx: &EbicsCtx<'_>, - parse: impl FnOnce(&[u8]) -> xml::Result<EbicsResponse<T>>, + key: &PublicKey, + parse: impl FnOnce(Document) -> xml::Result<EbicsResponse<T>>, ) -> Result<T, EbicsError> { let xml = self.post_to_bank(xml, ctx).await?; - // TODO verify ebics signature - let res = parse(&xml).ctx(ctx)?; + let doc = verify_ebics(&xml, key).ctx(ctx)?; + let res = parse(doc).ctx(ctx)?; trace!(target: "ebics", "{ctx}: {} {} - {}", res.technical_code, @@ -428,7 +434,7 @@ impl<'a> EbicsClient<'a> { while let Some(tx_id) = ebics_first(db).await.ctx(&ctx)? { let ctx = EbicsCtx::new(order).interrupt(&tx_id); let xml = receipt(&self.cfg, client, order, &tx_id, false); - if let Err(e) = self.post_bts(xml, &ctx, parse_d_init).await { + if let Err(e) = self.post_bts(xml, &ctx, &bank.auth, parse_d_init).await { if !matches!( e.kind, // Transaction already closed or expired - EBICS protocol error @@ -455,7 +461,7 @@ impl<'a> EbicsClient<'a> { nb_segments, segment, data_encryption_info, - } = self.post_bts(xml, &ctx, parse_d_init).await?; + } = self.post_bts(xml, &ctx, &bank.auth, parse_d_init).await?; ebics_register(db, &tx_id).await.ctx(&ctx)?; // Transfer phase @@ -463,7 +469,9 @@ impl<'a> EbicsClient<'a> { for segment_nb in 2..=nb_segments { ctx = ctx.transfer(&tx_id, segment_nb); let xml = d_transfer(&self.cfg, client, order, nb_segments, segment_nb, &tx_id); - let DTransfer { segment, .. } = self.post_bts(xml, &ctx, parse_d_transfer).await?; + let DTransfer { segment, .. } = self + .post_bts(xml, &ctx, &bank.auth, parse_d_transfer) + .await?; segments.push(segment); } @@ -477,7 +485,7 @@ impl<'a> EbicsClient<'a> { ctx = ctx.receipt(&tx_id); let xml = receipt(&self.cfg, client, order, &tx_id, res.is_ok() && !peek); if let Err(e) = async { - self.post_bts(xml, &ctx, parse_receipt).await?; + self.post_bts(xml, &ctx, &bank.auth, parse_receipt).await?; ebics_remove(db, &tx_id).await.ctx(&ctx) } .await @@ -511,13 +519,14 @@ impl<'a> EbicsClient<'a> { // Init phase ctx = ctx.init(); let xml = u_init(&self.cfg, bank, client, order, &payload); - let U { tx_id, order_id } = self.post_bts(xml, &ctx, parse_u_init).await?; + let U { tx_id, order_id } = self.post_bts(xml, &ctx, &bank.auth, parse_u_init).await?; // Transfer phase for segment_nb in 1..=payload.nb_segments() { ctx = ctx.transfer(&tx_id, segment_nb); let xml = u_transfer(&self.cfg, client, order, &tx_id, &payload, segment_nb); - self.post_bts(xml, &ctx, parse_u_transfer).await?; + self.post_bts(xml, &ctx, &bank.auth, parse_u_transfer) + .await?; } Ok(order_id) @@ -642,6 +651,7 @@ pub async fn tx_check( .post_bts( d_init(&ebics.cfg, bank, client, fetch, &None), &ctx, + &bank.auth, parse_d_init, ) .await?; @@ -652,6 +662,7 @@ pub async fn tx_check( .post_bts( d_init(&ebics.cfg, bank, client, fetch, &None), &ctx, + &bank.auth, parse_d_init, ) .await @@ -664,6 +675,7 @@ pub async fn tx_check( .post_bts( receipt(&ebics.cfg, client, fetch, &tx_id, false), &ctx, + &bank.auth, parse_receipt, ) .await?; @@ -691,6 +703,7 @@ pub async fn tx_check( .post_bts( u_init(&ebics.cfg, bank, client, submit, &payload), &ctx, + &bank.auth, parse_u_init, ) .await @@ -702,6 +715,7 @@ pub async fn tx_check( .post_bts( u_transfer(&ebics.cfg, client, fetch, &tx_id, &payload, 1), &ctx, + &bank.auth, parse_u_transfer, ) .await?; @@ -710,6 +724,7 @@ pub async fn tx_check( .post_bts( u_init(&ebics.cfg, bank, client, submit, &payload), &ctx, + &bank.auth, parse_u_init, ) .await @@ -739,6 +754,7 @@ pub async fn tx_check( .post_bts( receipt(&ebics.cfg, client, fetch, &tx_id, false), &ctx, + &bank.auth, parse_receipt, ) .await?; @@ -751,6 +767,7 @@ pub async fn tx_check( .post_bts( receipt(&ebics.cfg, client, fetch, &tx_id, false), &ctx, + &bank.auth, parse_receipt, ) .await diff --git a/libeufin-ebics/src/ebics/bts.rs b/libeufin-ebics/src/ebics/bts.rs @@ -21,6 +21,7 @@ use compact_str::CompactString; use jiff::{Timestamp, Zoned, tz::TimeZone}; +use roxmltree::Document; use taler_common::encoding::base64; use crate::{ @@ -53,7 +54,7 @@ fn signed_request( @ lambda } ); - sign_ebics(doc, &client.auth) + sign_ebics(doc, &client.auth).expect("EBICS signature never fails") } fn bank_digest(w: &mut XmlWriter, bank: &BankKeys) { @@ -303,8 +304,8 @@ pub struct DInit { pub nb_segments: usize, } -pub fn parse_d_init(xml: &[u8]) -> xml::Result<EbicsResponse<DInit>> { - Xml::parse(xml, "ebicsResponse", |root| { +pub fn parse_d_init(xml: Document) -> xml::Result<EbicsResponse<DInit>> { + Xml::doc(xml, "ebicsResponse", |root| { let header = root.one_signed("header")?; let st = header.one("static")?; let mutable = header.one("mutable")?; @@ -350,8 +351,8 @@ pub struct DTransfer { pub nb_segments: usize, } -pub fn parse_d_transfer(xml: &[u8]) -> xml::Result<EbicsResponse<DTransfer>> { - Xml::parse(xml, "ebicsResponse", |root| { +pub fn parse_d_transfer(xml: Document) -> xml::Result<EbicsResponse<DTransfer>> { + Xml::doc(xml, "ebicsResponse", |root| { let header = root.one_signed("header")?; let st = header.one("static")?; let mutable = header.one("mutable")?; @@ -389,8 +390,8 @@ pub struct Receipt { pub tx_id: CompactString, } -pub fn parse_receipt(xml: &[u8]) -> xml::Result<EbicsResponse<Receipt>> { - Xml::parse(xml, "ebicsResponse", |root| { +pub fn parse_receipt(xml: Document) -> xml::Result<EbicsResponse<Receipt>> { + Xml::doc(xml, "ebicsResponse", |root| { let header = root.one_signed("header")?; let st = header.one("static")?; let mutable = header.one("mutable")?; @@ -427,8 +428,8 @@ pub struct U { pub order_id: CompactString, } -pub fn parse_u_init(xml: &[u8]) -> xml::Result<EbicsResponse<U>> { - Xml::parse(xml, "ebicsResponse", |root| { +pub fn parse_u_init(xml: Document) -> xml::Result<EbicsResponse<U>> { + Xml::doc(xml, "ebicsResponse", |root| { let header = root.one_signed("header")?; let st = header.one("static")?; let mutable = header.one("mutable")?; @@ -461,8 +462,8 @@ pub fn parse_u_init(xml: &[u8]) -> xml::Result<EbicsResponse<U>> { }) } -pub fn parse_u_transfer(xml: &[u8]) -> xml::Result<EbicsResponse<U>> { - Xml::parse(xml, "ebicsResponse", |root| { +pub fn parse_u_transfer(xml: Document) -> xml::Result<EbicsResponse<U>> { + Xml::doc(xml, "ebicsResponse", |root| { let header = root.one_signed("header")?; let st = header.one("static")?; let mutable = header.one("mutable")?; diff --git a/libeufin-ebics/src/ebics/key_management.rs b/libeufin-ebics/src/ebics/key_management.rs @@ -55,7 +55,7 @@ impl EbicsClient<'_> { if !matches!(order, Order::INI | Order::HIA) { unreachable!("Only INI & HIA are supported for client keys"); } - let res = self.key_management(client, &order).await?; + let res = self.key_management(client, &order, &ctx).await?; if res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_STATE || res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_OR_USER_STATE @@ -80,7 +80,8 @@ impl EbicsClient<'_> { /** Perform an EBICS private key management HPB using [client] */ pub async fn hpb(&self, client: &ClientKeys) -> anyhow::Result<BankKeys> { let order = Order::HPB; - let res = self.key_management(client, &order).await?; + let ctx = EbicsCtx::new(&order); + let res = self.key_management(client, &order, &ctx).await?; if res.technical_code == EbicsReturnCode::EBICS_AUTHENTICATION_FAILED { bail!( "{order} status code {}: could not download bank keys, send client keys (and/or related PDF document with --generate-registration-pdf) to the bank", @@ -88,7 +89,7 @@ impl EbicsClient<'_> { ) } let order_data = res.ok_or_fail()?.expect("{order}: missing order data"); - + self.logger.log_payload(&ctx, &order_data, "xml")?; Ok(Xml::parse(&order_data, "HPBResponseOrderData", |root| { let auth_pub = root.one("AuthenticationPubKeyInfo")?; let version = auth_pub.one("AuthenticationVersion")?.text(); @@ -118,6 +119,7 @@ impl EbicsClient<'_> { &self, client: &ClientKeys, order: &Order, + ctx: &EbicsCtx<'_>, ) -> Result<EbicsResponse<Option<Vec<u8>>>, EbicsError> { let EbicsHostCfg { host_id, @@ -125,7 +127,6 @@ impl EbicsClient<'_> { partner_id, .. } = &self.cfg; - let ctx = EbicsCtx::new(order); info!(target: "ebics", "Doing key request {order}"); let (name, security_medium) = match order { @@ -145,7 +146,6 @@ impl EbicsClient<'_> { "PartnerID": &cfg.partner_id, "UserID": &cfg.user_id }); - // Deflate TODO write inside the compressor directly let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); encoder.write_all(xml.as_bytes()).unwrap(); let compressed = encoder.finish().unwrap(); @@ -224,11 +224,11 @@ impl EbicsClient<'_> { } ); let signed = if sign { - sign_ebics(msg, &client.auth) + sign_ebics(msg, &client.auth).expect("EBICS signature never fails") } else { msg }; - let res = self.post_to_bank(signed, &ctx).await?; + let res = self.post_to_bank(signed, ctx).await?; Xml::parse(&res, "ebicsKeyManagementResponse", |root| { let body = root.one("body")?; let mutable = root.one_signed("header").one("mutable")?; @@ -250,7 +250,7 @@ impl EbicsClient<'_> { }), }) }) - .ctx(&ctx) + .ctx(ctx) } } diff --git a/libeufin-ebics/src/test.rs b/libeufin-ebics/src/test.rs @@ -68,9 +68,7 @@ pub enum EbicsRes { Failure, } pub struct EbicsState { - bank_sign: KeyPair, - bank_enc: KeyPair, - bank_auth: KeyPair, + bank: KeyPair, client_sign: Option<PublicKey>, client_enc: Option<PublicKey>, @@ -83,9 +81,7 @@ pub struct EbicsState { impl EbicsState { pub fn new() -> Self { Self { - bank_sign: KeyPair::generate(KeySize::Rsa2048).unwrap(), - bank_enc: KeyPair::generate(KeySize::Rsa2048).unwrap(), - bank_auth: KeyPair::generate(KeySize::Rsa2048).unwrap(), + bank: KeyPair::generate(KeySize::Rsa2048).unwrap(), client_sign: None, client_enc: None, client_auth: None, @@ -132,7 +128,7 @@ impl EbicsState { } fn signed_response(&self, xml: String) -> EbicsRes { - EbicsRes::Ok(sign_ebics(xml, &self.bank_auth)) + EbicsRes::Ok(sign_ebics(xml, &self.bank).expect("EBICS signature never fails")) } fn ebics_response_payload(&mut self, payload: &str, last: bool) -> EbicsRes { @@ -267,11 +263,11 @@ impl EbicsState { let payload = xml!("HPBResponseOrderData" "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" { "AuthenticationPubKeyInfo" { - @ |w| rsa_key_xml(w, &self.bank_auth), + @ |w| rsa_key_xml(w, &self.bank), "AuthenticationVersion": "X002" }, "EncryptionPubKeyInfo" { - @ |w| rsa_key_xml(w, &self.bank_enc), + @ |w| rsa_key_xml(w, &self.bank), "EncryptionVersion": "E002" } }); diff --git a/libeufin-ebics/src/xml_sign.rs b/libeufin-ebics/src/xml_sign.rs @@ -17,41 +17,45 @@ * <http://www.gnu.org/licenses/> */ -use std::{ - borrow::Cow, - collections::{BTreeMap, HashSet}, -}; +use std::{collections::HashSet, io::Write, rc::Rc}; use aws_lc_rs::{ - digest::Digest, + digest::{Context, Digest}, + error::Unspecified, rand::SystemRandom, + rsa::PublicKey, signature::{RSA_PKCS1_SHA256, RsaKeyPair}, }; use roxmltree::{Document, Node}; use taler_common::encoding::base64; +use thiserror::Error; + +use crate::xml::{self, Xml, XmlAccess as _}; -fn escape<'a>(text: &'a str, replacements: &[(char, &str)]) -> Cow<'a, str> { +fn escape<'a>( + text: &'a str, + replacements: &[(char, &str)], + out: &mut impl Write, +) -> std::io::Result<()> { // 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 + return write!(out, "{text}"); // No escaping needed }; - // Pre-allocate with a reasonable estimate - let mut output = String::with_capacity(text.len() + 16); - output.push_str(&text[..first_pos]); + write!(out, "{}", &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), + Some((_, escaped)) => write!(out, "{escaped}")?, + None => write!(out, "{ch}")?, } } - Cow::Owned(output) + Ok(()) } // C14N requires specific escaping for Text nodes -fn escape_text(text: &str) -> Cow<'_, str> { +fn escape_text(text: &str, out: &mut impl Write) -> std::io::Result<()> { escape( text, &[ @@ -60,11 +64,12 @@ fn escape_text(text: &str) -> Cow<'_, str> { ('>', "&gt;"), ('\r', "&#xD;"), ], + out, ) } // C14N requires specific escaping for Attributes -fn escape_attr(text: &str) -> Cow<'_, str> { +fn escape_attr(text: &str, out: &mut impl Write) -> std::io::Result<()> { escape( text, &[ @@ -75,99 +80,135 @@ fn escape_attr(text: &str) -> Cow<'_, str> { ('\n', "&#xA;"), ('\r', "&#xD;"), ], + out, ) } -/// 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('"'); +/// C14N normalization context +struct C14N<'a> { + active_namespaces: Rc<HashSet<(&'a str, &'a str)>>, + sorted: Vec<(&'a str, &'a str)>, +} + +impl<'a> C14N<'a> { + pub fn new() -> Self { + Self { + active_namespaces: Rc::new(HashSet::new()), + sorted: Vec::new(), } + } - // 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('"'); + /// C14N inclusive normalization + pub fn c14n_inclusive( + &mut self, + node: Node<'a, 'a>, + out: &mut impl Write, + ) -> std::io::Result<()> { + let Self { + active_namespaces, + sorted, + } = self; + Rc::make_mut(active_namespaces).clear(); + fn inner<'a, W: Write>( + node: Node<'a, 'a>, + mut active_namespaces: Rc<HashSet<(&'a str, &'a str)>>, + sorted: &mut Vec<(&'a str, &'a str)>, + out: &mut W, + ) -> std::io::Result<()> { + if node.is_text() { + if let Some(text) = node.text() { + escape_text(text, out)?; + } + } else if node.is_element() { + let prefix = node + .tag_name() + .namespace() + .and_then(|uri| node.lookup_prefix(uri)); + let tag_name = std::fmt::from_fn(|f| { + if let Some(ns) = prefix { + write!(f, "{ns}:")?; + }; + write!(f, "{}", node.tag_name().name()) + }); + + // Open element + write!(out, "<{tag_name}")?; + + // Write sorted missing namespaces + sorted.clear(); + sorted.extend(node.namespaces().filter_map(|ns| { + let value = (ns.name().unwrap_or_default(), ns.uri()); + if active_namespaces.contains(&value) { + None + } else { + Rc::make_mut(&mut active_namespaces).insert(value); + Some(value) + } + })); + sorted.sort_unstable(); + for (prefix, uri) in &*sorted { + write!(out, " xmlns")?; + if !prefix.is_empty() { + write!(out, ":{prefix}")?; + } + write!(out, "=\"{uri}\"")?; + } + + // Write sorted attributes + sorted.clear(); + sorted.extend(node.attributes().map(|it| (it.name(), it.value()))); + sorted.sort_unstable(); + for (k, v) in &*sorted { + write!(out, " {k}=\"")?; + escape_attr(v, out)?; + write!(out, "\"")?; + } + + write!(out, ">")?; + + for child in node.children() { + // Pass the cloned active_namespaces down to children + inner(child, active_namespaces.clone(), sorted, out)?; + } + + write!(out, "</{tag_name}>")?; + } + Ok(()) } + inner(node, self.active_namespaces.clone(), sorted, out) + } +} - out.push('>'); +/// Digest wrapper to implement std::io::Write +struct DigestWriter<'a>(&'a mut Context); - for child in node.children() { - // Pass the cloned active_namespaces down to children - c14n_inclusive(child, active_namespaces.clone(), out); - } +impl<'a> Write for DigestWriter<'a> { + fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { + self.0.update(buf); + Ok(buf.len()) + } - out.push_str("</"); - push_tag_name(out); - out.push('>'); + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) } } -fn digest_authenticated(doc: &Document) -> Digest { - fn find_top_level_authenticators<'a>(node: Node<'a, 'a>, results: &mut Vec<Node<'a, 'a>>) { +/// Digest of all authenticated node +fn digest_authenticated<'a>(doc: &'a Document, c14n: &mut C14N<'a>) -> Digest { + fn inner<'a>(node: Node<'a, 'a>, c14n: &mut C14N<'a>, out: &mut Context) { if node.attribute("authenticate") == Some("true") { - results.push(node); + c14n.c14n_inclusive(node, &mut DigestWriter(out)) + .expect("digest never fails"); } else { for child in node.children().filter(|n| n.is_element()) { - find_top_level_authenticators(child, results); + inner(child, c14n, out); } } } - 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()) + let mut out = aws_lc_rs::digest::Context::new(&aws_lc_rs::digest::SHA256); + inner(doc.root(), c14n, &mut out); + out.finish() } const C14N_ALG: &str = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315"; @@ -175,17 +216,26 @@ 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(); +#[derive(Debug, Error)] +pub enum SignError { + #[error(transparent)] + Xml(#[from] roxmltree::Error), + #[error("Must contain an empty AuthSignature element")] + NoAuthSignature, + #[error("bad signature")] + Signature(#[from] Unspecified), +} + +/** Sign an EBICS document */ +pub fn sign_ebics(mut xml: String, key: &RsaKeyPair) -> Result<String, SignError> { + let doc = Document::parse(&xml)?; + let mut c14n = C14N::new(); - let digest = digest_authenticated(&doc); + let digest = digest_authenticated(&doc, &mut c14n); let digest = base64::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 default_namespace = doc.root_element().default_namespace().unwrap_or_default(); 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>"## ); @@ -195,25 +245,74 @@ pub fn sign_ebics(mut xml: String, key: &RsaKeyPair) -> String { &SystemRandom::new(), signed_info.as_bytes(), &mut sig, - ) - .unwrap(); + )?; let sig = base64::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(); + let start = xml.find(pattern).ok_or(SignError::NoAuthSignature)?; xml.replace_range(start..start + pattern.len(), &signature); - xml + Ok(xml) +} + +#[derive(Debug, Error)] +pub enum VerifyError { + #[error(transparent)] + Utf8(#[from] std::str::Utf8Error), + #[error(transparent)] + Xml(#[from] roxmltree::Error), + #[error(transparent)] + Schema(#[from] xml::Error), + #[error("bad digest")] + Digest, + #[error("bad signature")] + Signature(#[from] Unspecified), +} + +/** Verify an EBICS document */ +pub fn verify_ebics<'a>(xml: &'a [u8], key: &PublicKey) -> Result<Document<'a>, VerifyError> { + let str = std::str::from_utf8(xml)?; + let doc = Document::parse(str)?; + let mut c14n = C14N::new(); + + let auth_signature = Xml { + node: doc.root_element(), + } + .one("AuthSignature")?; + let signed_info = auth_signature.one("SignedInfo")?; + let signature = auth_signature.one("SignatureValue").b64()?; + + // Check digest + let digest = signed_info.one("Reference").one("DigestValue").b64()?; + if digest_authenticated(&doc, &mut c14n).as_ref() != digest { + return Err(VerifyError::Digest); + } + + // Check signature + let mut out = aws_lc_rs::digest::Context::new(&aws_lc_rs::digest::SHA256); + c14n.c14n_inclusive(signed_info.node, &mut DigestWriter(&mut out)) + .expect("digest never fails"); + let digest = out.finish(); + aws_lc_rs::signature::UnparsedPublicKey::new( + &aws_lc_rs::signature::RSA_PKCS1_2048_8192_SHA256, + key, + ) + .verify_digest(&digest, &signature)?; + + Ok(doc) } #[cfg(test)] mod test { - use aws_lc_rs::signature::RsaKeyPair; + use aws_lc_rs::{ + rsa::PublicKey, + signature::{KeyPair, RsaKeyPair}, + }; use roxmltree::Document; use taler_common::encoding::{base32, base64}; - use crate::xml_sign::{digest_authenticated, sign_ebics}; + use crate::xml_sign::{C14N, digest_authenticated, sign_ebics, verify_ebics}; #[test] fn canonicalize() { @@ -225,7 +324,8 @@ kJMyqsK/0dKW+4n3JgWuVGK8YdoUuvmYooqgFw==</ds:SignatureValue></AuthSignature><bod "##; let doc = Document::parse(xml).unwrap(); - let res = digest_authenticated(&doc); + let mut c14n = C14N::new(); + let res = digest_authenticated(&doc, &mut c14n); let hex = base64::encode(res); assert_eq!(hex, "ws6QyiLpZVu+CbpqlhQ11PGwCdHSgmtmL7FvwrqZqmU="); @@ -263,7 +363,7 @@ kJMyqsK/0dKW+4n3JgWuVGK8YdoUuvmYooqgFw==</ds:SignatureValue></AuthSignature><bod </ebicsResponse> "##; let doc = Document::parse(xml).unwrap(); - let res = digest_authenticated(&doc); + let res = digest_authenticated(&doc, &mut c14n); assert_eq!( base64::encode(res), "WJz3HUYjV3HMK0Cy+69XCnAcmiD21mJ5BRiQPwsi1VI=" @@ -278,8 +378,8 @@ kJMyqsK/0dKW+4n3JgWuVGK8YdoUuvmYooqgFw==</ds:SignatureValue></AuthSignature><bod 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 signed = sign_ebics(xml, &key).unwrap(); + let doc = verify_ebics(signed.as_bytes(), key.public_key()).unwrap(); let signature = doc .descendants() .find(|it| it.has_tag_name("SignatureValue")) @@ -291,4 +391,59 @@ kJMyqsK/0dKW+4n3JgWuVGK8YdoUuvmYooqgFw==</ds:SignatureValue></AuthSignature><bod "eYyb1v/dGVOPndpMhXZlVQM2q9H9BJP77nYOWaa7jjoeLef7/8HjKIv8oq6Kaf6Z9mAfh/Pcip3a75gkdKpz7ocl1YdsaD+CcQkO1J/n4NwY821ccSh0Ahm2PBE168hyEMzPJrDeDtJrYqs+J/+nC8ek0hbo4/WPsH4UoxVu+ANsHR+BnQFQW3k9BFv+XKZbrBltIY62SN73tYwU8QzRtINJLzjhNB3T6S101n4CYwycXpL5b/oXXOUxxfDnn9EmIFt4DIgjxxqDYdQEBytULLORdkIdf563aw2wDaN12OQV2TB9gAs4Uu203FkUbmIagarMhbKKlqa1NkOteZ13Xw==" ); } + + #[test] + fn verify() { + 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>846BEEFDCBB4293BA764FAC72CD99B4E</TransactionID> + <NumSegments>1</NumSegments> + </static> + <mutable> + <TransactionPhase>Initialisation</TransactionPhase> + <SegmentNumber lastSegment="true">1</SegmentNumber> + <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>9ifuDmTeB7+ZR0qAksWBqcuS2SgO+cIZrMCSrwmGfVw=</ds:DigestValue> + </ds:Reference> + </ds:SignedInfo> + <ds:SignatureValue>eeMBzXQTshVSBH5ryz2ducFgTegLyZhkZhGom5o6Se2lujaD3ixVVxnNlzbOVvWL8yvv2zfki877qTe5b+u4ejYfrR1rc6l1jB/lS9pqT9wyB0pD52LXNfrxhAkOsljv8V858Wjj7KSuUT0+7v8q6BRRg55BrIIJBWqPwWKY2a7AmFWTkYck/fLxawtQvrBOTxBMufQ9d060Ad8Ndewjzjt2lLSCH7K3mM2+E2AMmpwgXnYrlDr5GU5/Qt2KBG8XYJqe354+MKfp0FLqRq9tXnucjFOaRqQVzMNfFBkfFpvwNnoAC1FAgOQeXPyr4jOW6qlUBuBqM/jTCOyJe5RMvA==</ds:SignatureValue> + </AuthSignature> + <body> + <DataTransfer> + <DataEncryptionInfo authenticate="true"> + <EncryptionPubKeyDigest Version="E002" Algorithm="http://www.w3.org/2001/04/xmlenc#sha256">ujHlW/uumNiBAls2bUCmMFdYUOoxe8QMjNCPKlLF5ow=</EncryptionPubKeyDigest> + <TransactionKey>PpGWjNbu4bx1TjV/5OgEOAQF92NNQ5LaW61lKGI6/2/ycYmI1usfNC4x1bq/dlusCw6E/fOnHzDFyaD1C/ef0fZO2Ir++6s/ZK0e87+KBU7bhkL4ftoWulkRpdZlI5GgNlDxP5II4ZZ3KhlLqgv34bhJuL4Thi3mZz0ajR21PP4cV+NeWVpCsDyPQlRRwBy8dNAY+QrZxXvOmu3V9UZyRhRZLjStEeOFBwsEyGwmd6LIE/jJjpFY4PaxXL/TtjvE3qPS2SKQ0W1uVwWvMLMuNYgHHt02bUS5LZt4tJdHmNkMqQZbv262a9xRPxwVa7QhY+5GIMpSY4ZMoFPRlTHaqw==</TransactionKey> + </DataEncryptionInfo> + <OrderData>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</OrderData> + </DataTransfer> + <ReturnCode authenticate="true">000000</ReturnCode> + <TimestampBankParameter authenticate="true">2020-11-25T19:03:45.693Z</TimestampBankParameter> + </body> +</ebicsResponse> +"##; + let doc = Document::parse(xml).unwrap(); + let mut c14n = C14N::new(); + let res = digest_authenticated(&doc, &mut c14n); + assert_eq!( + base64::encode(res), + "9ifuDmTeB7+ZR0qAksWBqcuS2SgO+cIZrMCSrwmGfVw=" + ); + let key = "621028HG1M30JAM6923FE381040GA003G80GY01GG80GM0M2040G1EACATA11EF5SVKNBNBYF1S3WSKQ2A2R9VZ7RW2HRX00293JPZ7VQ780RFRVYTQKKDDNJAQGBH4659GT9QYBMJCG1RKZEH1WDJ0GAAY7B7NBMW6FWXCKFYRMZQME0WBGZ1AAMY2VBQ5XAFV8216EFNF2EPG6M5ZGHG9RG6EGED56TK9JESQ02Q7AAVBRAAARVBN9NHCN64KQ3SRRHYXB8RWRK4TSSC93XG8RWMQH4ZDJSBYDCEXFY6G3AWTZ0EZNCJJAYB98T4GNFWZMN81AVYCQHXT1APX81AXCAYNK7J9XETF5CN1J1WV0BVA2BYG4VAMAW123REPN67JF1TNWPTADBMHS17N2V1GFYT8JRWX4TGM2996NXTEPMA8C2CDDE0CRY2A6HT8C5H2D6C62YGRSCF820C0G008"; + let key = PublicKey::from_der(&base32::decode(key).unwrap()).unwrap(); + verify_ebics(xml.as_bytes(), &key).unwrap(); + } } diff --git a/libeufin-ebisync/src/lib.rs b/libeufin-ebisync/src/lib.rs @@ -40,6 +40,7 @@ use libeufin_ebics::{ ebics_code::EbicsReturnCode, order::{Order, OrderDoc}, }, + iso20022::{HacAction, hac::parse_hac}, keys::{BankKeys, ClientKeys}, ws::listen_for_notification, }; @@ -279,7 +280,20 @@ pub async fn ebics_fetch( transient && peek, async |content| { if order.doc() == Some(OrderDoc::acknowledgement) { - // TODO HAC + for ack in parse_hac(&content)? { + debug!(target: "fetch", "{ack}"); + if let Some(order_id) = &ack.order_id { + match ack.action { + HacAction::ORDER_HAC_FINAL_POS => { + info!(target: "fetch", "Order {order_id} accepted at {}", ack.timestamp); + } + HacAction::ORDER_HAC_FINAL_NEG => { + info!(target: "fetch", "Order {order_id} refused at {}", ack.timestamp); + } + _ => {} + } + } + } } else { let mut z = zip::ZipArchive::new(Cursor::new(content))?; for i in 0..z.len() {