libeufin

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

commit b3043819e7e2af8f18b2c434b7266c25dcace578
parent 3294a1ff9b7cd43b9ec72ea4a9bfaae04931a914
Author: Antoine A <>
Date:   Tue,  8 Sep 2026 11:06:46 +0200

ebics: many fixes

Diffstat:
Mlibeufin-ebics/sample/platform/postfinance_camt054.xml | 2+-
Mlibeufin-ebics/src/crypto.rs | 1-
Mlibeufin-ebics/src/ebics.rs | 59+++++++++++++++++++++++++++++++++++++----------------------
Mlibeufin-ebics/src/ebics/bts.rs | 21+++++++++++----------
Mlibeufin-ebics/src/ebics/key_management.rs | 22+++++++++++++++++-----
Mlibeufin-ebics/src/iso20022/camt.rs | 26+++++++++++++++++++++++---
Mlibeufin-ebics/src/ws.rs | 22++++++++++++++--------
Mtestbench/src/main.rs | 10++++++----
8 files changed, 109 insertions(+), 54 deletions(-)

diff --git a/libeufin-ebics/sample/platform/postfinance_camt054.xml b/libeufin-ebics/sample/platform/postfinance_camt054.xml @@ -111,7 +111,7 @@ <Refs> <AcctSvcrRef>231121CH0AZWCVR1</AcctSvcrRef> <EndToEndId>NOTPROVIDED</EndToEndId> - <UETR>62e2b511-7313-4ccd-8d40-c9d8e612cd71</UETR> + <UETR>62e2b511-7313-4ccd-8d40-c9d8e612cd72</UETR> </Refs> <Amt Ccy="CHF">2.53</Amt> <CdtDbtInd>CRDT</CdtDbtInd> diff --git a/libeufin-ebics/src/crypto.rs b/libeufin-ebics/src/crypto.rs @@ -91,7 +91,6 @@ pub fn rsa_private_from_b64_x509_certificate(encoded: &str) -> anyhow::Result<Pu let der = base64::decode(encoded)?; let (_, cert) = X509Certificate::from_der(&der)?; let issuer_public_key = cert.public_key(); - cert.verify_signature(Some(issuer_public_key))?; Ok(PublicKey::from_der(issuer_public_key.raw)?) } diff --git a/libeufin-ebics/src/ebics.rs b/libeufin-ebics/src/ebics.rs @@ -22,7 +22,9 @@ use std::{borrow::Cow, io::Write as _}; use aws_lc_rs::{ digest::Digest, encoding::AsDer, + error::KeyRejected, rsa::{self, PrivateDecryptingKey, PublicKey}, + signature::KeyPair, }; use compact_str::CompactString; use flate2::write::ZlibDecoder; @@ -36,20 +38,21 @@ use roxmltree::Document; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use sqlx::PgPool; use taler_common::encoding::base64; +use thiserror::Error; use tracing::{debug, info, trace, warn}; use crate::{ cli::EbicsLogs, config::EbicsHostCfg, crypto::{ - decrypt_ebics_e002, decrypt_ebics_e002_key, digest_ebics_order_a006, encrypt_ebics_e002, - gen_ebics_e002_key, sign_ebics_a006, + decrypt_ebics_e002, decrypt_ebics_e002_key, digest_ebics_order_a006, ebics_pub_key_hash, + encrypt_ebics_e002, gen_ebics_e002_key, sign_ebics_a006, }, db::{ebics_first, ebics_register, ebics_remove}, ebics::{ administrative::{HAA, HKD, VersionNumber, hev_msg, parse_haa, parse_hev, parse_hkd}, bts::{ - DInit, DTransfer, DataEncryptionInfo, U, d_init, d_transfer, parse_d_init, + DInit, DTransfer, DataEncryptionInfo, UInit, d_init, d_transfer, parse_d_init, parse_d_transfer, parse_receipt, parse_u_init, parse_u_transfer, receipt, u_init, u_transfer, }, @@ -215,7 +218,7 @@ fn fmt_code( if technical.is_error() { write!(f, "technical error: {technical}") } else { - write!(f, "technical error: {bank}") + write!(f, "bank error: {bank}") } } @@ -258,6 +261,9 @@ pub enum EbicsErrKind { #[error(transparent)] Signature(#[from] VerifyError), + #[error(transparent)] + DecDe(#[from] DecDeErr), + #[error("{0}")] Custom(Cow<'static, str>), } @@ -278,7 +284,7 @@ impl EbicsErrKind { pub struct EbicsResponse<T> { pub technical_code: EbicsReturnCode, pub bank_code: EbicsReturnCode, - pub technical_text: String, + pub technical_text: CompactString, pub content: Option<T>, } @@ -434,7 +440,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, &bank.auth, parse_d_init).await { + if let Err(e) = self.post_bts(xml, &ctx, &bank.auth, parse_receipt).await { if !matches!( e.kind, // Transaction already closed or expired - EBICS protocol error @@ -465,19 +471,22 @@ impl<'a> EbicsClient<'a> { ebics_register(db, &tx_id).await.ctx(&ctx)?; // Transfer phase - let mut segments = vec![segment]; + let mut payload = segment; 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, &bank.auth, parse_d_transfer) .await?; - segments.push(segment); + payload.extend(segment); } // Processing phase ctx = ctx.process(&tx_id); - let payload = decrypt_and_decompress_payload(&client.enc, data_encryption_info, segments); + let payload = decrypt_and_decompress_payload(&client.enc, data_encryption_info, payload) + .map_err(|e| { + EbicsErrKind::Custom(Cow::Owned(format!("decrypt & decompress: {e}"))).ctx(&ctx) + })?; self.logger.log_payload(&ctx, &payload, order.file_type())?; let res = processing(payload).await.ctx(&ctx); @@ -519,7 +528,7 @@ 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, &bank.auth, parse_u_init).await?; + let UInit { 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() { @@ -554,22 +563,28 @@ impl PreparedUploadData { } } +#[derive(Debug, Error)] +pub enum DecDeErr { + #[error(transparent)] + Zlib(#[from] std::io::Error), + #[error("bank E002 encryption public-key digest mismatch")] + Digest, + #[error(transparent)] + Key(#[from] KeyRejected), +} + /** Decrypts and decompresses EBICS BTS payload */ fn decrypt_and_decompress_payload( key_pair: &rsa::KeyPair, encryption_info: DataEncryptionInfo, - segments: Vec<Vec<u8>>, -) -> Vec<u8> { - let client_encryption_key = - PrivateDecryptingKey::from_pkcs8(key_pair.as_der().unwrap().as_ref()).unwrap(); - // TODO check bank_pub_digest - let tx_key = decrypt_ebics_e002_key(client_encryption_key, &encryption_info.tx_key); + payload: Vec<u8>, +) -> Result<Vec<u8>, DecDeErr> { + let enc_key = PrivateDecryptingKey::from_pkcs8(key_pair.as_der().unwrap().as_ref())?; + let tx_key = decrypt_ebics_e002_key(enc_key, &encryption_info.tx_key); let mut decoder = ZlibDecoder::new(Vec::new()); - for segment in segments { - let decrypted = decrypt_ebics_e002(&tx_key, segment); - decoder.write_all(&decrypted).unwrap(); - } - decoder.finish().unwrap() + let decrypted = decrypt_ebics_e002(&tx_key, payload); + decoder.write_all(&decrypted)?; + Ok(decoder.finish()?) } /** Signs, encrypts and format EBICS BTS payload */ @@ -708,7 +723,7 @@ pub async fn tx_check( ) .await { - Ok(U { tx_id, .. }) => { + Ok(UInit { tx_id, .. }) => { result.concurrent_fetch_and_submit = true; let ctx = ctx.transfer(&tx_id, 1); ebics diff --git a/libeufin-ebics/src/ebics/bts.rs b/libeufin-ebics/src/ebics/bts.rs @@ -285,7 +285,7 @@ pub fn u_transfer( pub struct DataEncryptionInfo { pub tx_key: Vec<u8>, - pub bank_pub_digest: Vec<u8>, + pub enc_pub_digest: Vec<u8>, } fn expect_phase(n: Xml<'_>, phase: &str) -> xml::Result<()> { @@ -336,7 +336,7 @@ pub fn parse_d_init(xml: Document) -> xml::Result<EbicsResponse<DInit>> { tx_id: st.one("TransactionID").parse()?, data_encryption_info: DataEncryptionInfo { tx_key: enc_info.one("TransactionKey").b64()?, - bank_pub_digest: enc_info.one("EncryptionPubKeyDigest").b64()?, + enc_pub_digest: enc_info.one("EncryptionPubKeyDigest").b64()?, }, segment: data.one("OrderData").b64()?, nb_segments: st.one("NumSegments").parse()?, @@ -348,7 +348,6 @@ pub fn parse_d_init(xml: Document) -> xml::Result<EbicsResponse<DInit>> { pub struct DTransfer { pub tx_id: CompactString, pub segment: Vec<u8>, - pub nb_segments: usize, } pub fn parse_d_transfer(xml: Document) -> xml::Result<EbicsResponse<DTransfer>> { @@ -380,7 +379,6 @@ pub fn parse_d_transfer(xml: Document) -> xml::Result<EbicsResponse<DTransfer>> content: Some(DTransfer { tx_id: st.one("TransactionID").parse()?, segment: body.one("DataTransfer").one("OrderData").b64()?, - nb_segments: st.one("NumSegments").parse()?, }), }) }) @@ -423,12 +421,12 @@ pub fn parse_receipt(xml: Document) -> xml::Result<EbicsResponse<Receipt>> { }) } -pub struct U { +pub struct UInit { pub tx_id: CompactString, pub order_id: CompactString, } -pub fn parse_u_init(xml: Document) -> xml::Result<EbicsResponse<U>> { +pub fn parse_u_init(xml: Document) -> xml::Result<EbicsResponse<UInit>> { Xml::doc(xml, "ebicsResponse", |root| { let header = root.one_signed("header")?; let st = header.one("static")?; @@ -454,7 +452,7 @@ pub fn parse_u_init(xml: Document) -> xml::Result<EbicsResponse<U>> { technical_code, bank_code, technical_text, - content: Some(U { + content: Some(UInit { order_id: mutable.one("OrderID").parse()?, tx_id: st.one("TransactionID").parse()?, }), @@ -462,7 +460,11 @@ pub fn parse_u_init(xml: Document) -> xml::Result<EbicsResponse<U>> { }) } -pub fn parse_u_transfer(xml: Document) -> xml::Result<EbicsResponse<U>> { +pub struct UTransfer { + pub tx_id: CompactString, +} + +pub fn parse_u_transfer(xml: Document) -> xml::Result<EbicsResponse<UTransfer>> { Xml::doc(xml, "ebicsResponse", |root| { let header = root.one_signed("header")?; let st = header.one("static")?; @@ -488,8 +490,7 @@ pub fn parse_u_transfer(xml: Document) -> xml::Result<EbicsResponse<U>> { technical_code, bank_code, technical_text, - content: Some(U { - order_id: mutable.one("OrderID").parse()?, + content: Some(UTransfer { tx_id: st.one("TransactionID").parse()?, }), }) diff --git a/libeufin-ebics/src/ebics/key_management.rs b/libeufin-ebics/src/ebics/key_management.rs @@ -229,7 +229,7 @@ impl EbicsClient<'_> { msg }; let res = self.post_to_bank(signed, ctx).await?; - Xml::parse(&res, "ebicsKeyManagementResponse", |root| { + let parsed = Xml::parse(&res, "ebicsKeyManagementResponse", |root| { let body = root.one("body")?; let mutable = root.one_signed("header").one("mutable")?; Ok(EbicsResponse { @@ -240,17 +240,29 @@ impl EbicsClient<'_> { let info = data.one_signed("DataEncryptionInfo")?; let info = DataEncryptionInfo { tx_key: info.one("TransactionKey").b64()?, - bank_pub_digest: info.one("EncryptionPubKeyDigest").b64()?, + enc_pub_digest: info.one("EncryptionPubKeyDigest").b64()?, }; let chunk = data.one("OrderData").b64()?; - let decoded = decrypt_and_decompress_payload(&client.enc, info, vec![chunk]); - Some(decoded) + Some((info, chunk)) } else { None }), }) }) - .ctx(ctx) + .ctx(ctx)?; + let decoded = match parsed.content { + Some(Some((info, chunk))) => Some(Some( + decrypt_and_decompress_payload(&client.enc, info, chunk).ctx(ctx)?, + )), + Some(None) => Some(None), + None => None, + }; + Ok(EbicsResponse { + content: decoded, + technical_code: parsed.technical_code, + bank_code: parsed.bank_code, + technical_text: parsed.technical_text, + }) } } diff --git a/libeufin-ebics/src/iso20022/camt.rs b/libeufin-ebics/src/iso20022/camt.rs @@ -215,8 +215,28 @@ fn incoming_id(n: Xml, sref: Option<&str>) -> xml::Result<InId> { /** Parse transaction wire transfer subject */ fn wire_transfer_subject(n: Xml) -> xml::Result<Option<String>> { - Ok(n.opt("RmtInf")? - .map(|n| n.many("Ustrd").map(|n| n.text()).collect::<String>())) + Ok(n.opt("RmtInf")?.map(|n| { + // Concat all lines + let mut s = n.many("Ustrd").map(|n| n.text()).collect::<String>(); + // Merge whitespace + let mut pending_space = false; + s.retain(|c| { + if c.is_ascii_whitespace() { + if pending_space { + false + } else { + pending_space = true; + true + } + } else if pending_space { + pending_space = false; + true + } else { + true + } + }); + s + })) } /** Parse and format transaction return reasons */ @@ -677,7 +697,7 @@ pub mod test { ), tx_in( ( - Some("62e2b511-7313-4ccd-8d40-c9d8e612cd71"), + Some("62e2b511-7313-4ccd-8d40-c9d8e612cd72"), None, Some("231121CH0AZWCVR1"), ), diff --git a/libeufin-ebics/src/ws.rs b/libeufin-ebics/src/ws.rs @@ -91,6 +91,7 @@ pub enum WssNotification { partnerid: String, userid: Option<String>, btf: Vec<WssNotificationBTF>, + #[serde(default)] ordertype: Vec<String>, }, } @@ -171,7 +172,7 @@ pub async fn listen_for_notification( ) { let mut backoff = ExpoBackoffDecorr::new(Duration::from_secs(30), Duration::from_mins(30), 2.5); loop { - let res: Result<(), anyhow::Error> = async { + let res: Result<bool, anyhow::Error> = async { let res = ebics .download( db, @@ -201,7 +202,7 @@ pub async fn listen_for_notification( ) { // Failure is expected if this wss is not supported info!(target: "ws", "Real-time EBICS notifications is not supported"); - return Ok(()); + return Ok(true); } else { return Err(e.into()); } @@ -238,14 +239,19 @@ pub async fn listen_for_notification( } }) .await?; - Ok(()) + Ok(false) } .await; - if let Err(e) = res { - error!(target: "ws", "{e}"); - tokio::time::sleep(backoff.backoff()).await; - } else { - return; + match res { + Ok(stop) => { + if stop { + return; + } + } + Err(e) => { + error!(target: "ws", "{e}"); + tokio::time::sleep(backoff.backoff()).await; + } } } } diff --git a/testbench/src/main.rs b/testbench/src/main.rs @@ -306,8 +306,8 @@ async fn main() -> anyhow::Result<()> { match cmd.component { Component::Nexus => { let mut repl = Repl::new(".nexus_history"); + auto_setup().await; loop { - auto_setup().await; if let Some(cmd) = repl.read_line(&prompt, "") { match cmd { NexusCmd::Setup => { @@ -337,10 +337,12 @@ async fn main() -> anyhow::Result<()> { run(&format!("list {}", raw_args.join(" "))).await; } NexusCmd::ResetKeys => { - if test { + if test && std::fs::exists(&ebics.client).unwrap() { std::fs::remove_file(&ebics.client).unwrap(); } - std::fs::remove_file(&ebics.bank).unwrap(); + if std::fs::exists(&ebics.bank).unwrap() { + std::fs::remove_file(&ebics.bank).unwrap(); + } } NexusCmd::TxCheck => { run(&format!("testing tx-check {ebics_log}")).await; @@ -380,8 +382,8 @@ async fn main() -> anyhow::Result<()> { } Component::Ebisync => { let mut repl = Repl::new(".ebisync_history"); + auto_setup().await; loop { - auto_setup().await; if let Some(cmd) = repl.read_line(&prompt, "") { match cmd { SyncCmd::Compact => {