libeufin

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

commit 181a7ebcb751e05562bdb2cb8d65e316abc62797
parent 39069b2097c0429e4c256736993a4fa38308b029
Author: Antoine A <>
Date:   Fri, 24 Apr 2026 10:38:00 +0200

ebics: HAA & HKD & HEV parsing

Diffstat:
Asrc/ebics/administrative.rs | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/ebics_code.rs -> src/ebics/ebics_code.rs | 0
Asrc/ebics/key_management.rs | 314+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ebics/mod.rs | 23+++++++++++++++++++++++
Asrc/ebics/order.rs | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/iso20022/hac.rs | 9+++------
Msrc/iso20022/mod.rs | 13++++++++-----
Dsrc/key_management.rs | 327-------------------------------------------------------------------------------
Msrc/lib.rs | 62+++++++++++++++-----------------------------------------------
Msrc/xml.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
10 files changed, 863 insertions(+), 423 deletions(-)

diff --git a/src/ebics/administrative.rs b/src/ebics/administrative.rs @@ -0,0 +1,229 @@ +/* +* 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::fmt::Display; + +use compact_str::CompactString; +use taler_common::types::{ + amount::Currency, + iban::{BIC, IBAN}, +}; +use taler_enum_meta::EnumMeta; + +use crate::{ + EbicsResponse, + config::EbicsHostCfg, + ebics::{ebics_code::EbicsReturnCode, order::Order}, + xml::{self, Xml, XmlAccess as _}, + xml_build, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionNumber { + pub number: CompactString, + pub schema: CompactString, +} + +impl Display for VersionNumber { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { number, schema } = self; + write!(f, "{number}:{schema}") + } +} + +pub struct HKD { + pub partner: PartnerInfo, + pub users: Box<[UserInfo]>, +} +pub struct PartnerInfo { + pub name: Option<CompactString>, + pub accounts: Box<[AccountInfo]>, + pub orders: Box<[OrderInfo]>, +} +pub struct OrderInfo { + pub order: Order, + pub description: String, +} +pub struct AccountInfo { + pub currency: Currency, + pub iban: IBAN, + pub bic: BIC, +} +pub struct UserInfo { + pub id: CompactString, + pub status: UserStatus, + pub permissions: Box<[Order]>, +} + +pub struct HAA { + pub orders: Box<[Order]>, +} + +#[derive(Debug, Clone, PartialEq, Eq, EnumMeta)] +#[enum_meta(Description)] +pub enum UserStatus { + /// "Subscriber is permitted access" + Ready, + /// "Subscriber is established, pending access permission" + New, + /// "Subscriber has sent INI file, but no HIA file yet" + INI, + /// "Subscriber has sent HIA order, but no INI file yet" + HIA, + /// "Subscriber has sent both HIA order and INI file" + Initialised, + /// "Suspended after several failed attempts, new initialisation via INI and HIA possible" + SuspendedFailedAttempts, + /// "Suspended after SPR order, new initialisation via INI and HIA possible" + SuspendedSPR, + /// "Suspended by bank, new initialisation via INI and HIA is not possible, suspension can only be revoked by the bank" + SuspendedBank, +} + +pub fn hev_msg(cfg: &EbicsHostCfg) -> String { + xml_build!( + "ebicsHEVRequest" ("xmlns": "http://www.ebics.org/H000") { + "HostId": &cfg.host_id + } + ) +} + +pub fn parse_hev(xml: &str) -> xml::Result<EbicsResponse<Box<[VersionNumber]>>> { + Xml::parse_str(xml, "ebicsHEVResponse", |root| { + Ok(EbicsResponse { + technical_code: root.one("SystemReturnCode").one("ReturnCode").parse()?, + bank_code: EbicsReturnCode::EBICS_OK, + content: root + .many("VersionNumber") + .map(|n| { + Ok(VersionNumber { + number: n.parse()?, + schema: n.attr("ProtocolVersion")?.into(), + }) + }) + .collect::<xml::Result<_>>()?, + }) + }) +} + +fn ebics_order(n: Xml, ty: &str) -> xml::Result<Order> { + let msg = n.opt("MsgName")?; + Ok(Order::V3 { + ty: ty.into(), + service: n.opt("ServiceName").parse()?, + scope: n.opt("Scope").parse()?, + option: n.opt("ServiceOption").parse()?, + container: n.opt("Container").parse_attr("containerType")?, + message: msg.parse()?, + version: msg.parse_opt_attr("version")?, + }) +} + +pub fn parse_hkd(xml: &str) -> xml::Result<HKD> { + fn order(n: Xml) -> xml::Result<Order> { + let ty = n.one("AdminOrderType")?.text(); + Ok(n.opt("Service")? + .map(|s| ebics_order(s, ty)) + .transpose()? + .unwrap_or_else(|| Order::V3 { + ty: ty.into(), + service: None, + scope: None, + message: None, + version: None, + container: None, + option: None, + })) + } + Xml::parse_str(xml, "HKDResponseOrderData", |root| { + let partner = root.one("PartnerInfo")?; + + Ok(HKD { + partner: PartnerInfo { + name: partner.one("AddressInfo").opt("Name").parse()?, + accounts: partner + .many("AccountInfo") + .map(|account| { + let currency = account.parse_attr("Currency")?; + let iban = account + .many("AccountNumber") + .find(|nb| nb.opt_attr("international") == Some("true")) + .unwrap() + .parse()?; + let bic = account + .many("BankCode") + .find(|nb| nb.opt_attr("international") == Some("true")) + .unwrap() + .parse()?; + Ok(AccountInfo { + currency, + iban, + bic, + }) + }) + .collect::<xml::Result<_>>()?, + orders: partner + .many("OrderInfo") + .map(|n| { + Ok(OrderInfo { + order: order(n)?, + description: n.one("Description").parse()?, + }) + }) + .collect::<xml::Result<_>>()?, + }, + users: root + .many("UserInfo") + .map(|n| { + let id = n.one("UserID")?; + Ok(UserInfo { + id: id.parse()?, + status: match id.attr("Status")? { + "1" => UserStatus::Ready, + "2" => UserStatus::New, + "3" => UserStatus::INI, + "4" => UserStatus::HIA, + "5" => UserStatus::Initialised, + "6" => UserStatus::SuspendedFailedAttempts, + // 7 is not applicable per spec + "8" => UserStatus::SuspendedSPR, + "9" => UserStatus::SuspendedBank, + s => return Err(id.parse_err(format_args!("Unknown user status {s}"))), + }, + permissions: n + .many("Permission") + .map(|p| order(p)) + .collect::<xml::Result<_>>()?, + }) + }) + .collect::<xml::Result<_>>()?, + }) + }) +} + +pub fn parse_haa(xml: &str) -> xml::Result<HAA> { + Xml::parse_str(xml, "HAAResponseOrderData", |root| { + Ok(HAA { + orders: root + .many("Service") + .map(|s| ebics_order(s, "BTD")) + .collect::<xml::Result<_>>()?, + }) + }) +} diff --git a/src/ebics_code.rs b/src/ebics/ebics_code.rs diff --git a/src/ebics/key_management.rs b/src/ebics/key_management.rs @@ -0,0 +1,314 @@ +/* +* 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::io::Write as _; + +use anyhow::bail; +use aws_lc_rs::{ + encoding::{AsDer, Pkcs8V1Der}, + rsa::PublicEncryptingKey, +}; +use base64::{Engine as _, prelude::BASE64_STANDARD}; +use flate2::{Compression, write::ZlibEncoder}; +use reqwest::Client; +use taler_enum_meta::EnumMeta; +use tracing::info; + +use crate::{ + EbicsResponse, + common::{DataEncryptionInfo, EbicsLogger, decrypt_and_decompress_payload}, + config::{EbicsHostCfg, EbicsKeysCfg}, + crypto::{rsa_private_from_b64_x509_certificate, x509_certificate_from_rsa_private}, + ebics::ebics_code::EbicsReturnCode, + keys::{self, BankPubKeysFile, ClientPriKeysFile}, + post_to_bank, + xml::{self, Xml, XmlAccess as _, XmlWriter}, + xml_build, xml_el, + xml_sign::sign_ebics, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] +#[enum_meta(Str)] +#[allow(clippy::upper_case_acronyms)] +pub enum Order { + INI, + HIA, + HPB, +} + +/** Perform an EBICS public key management [order] using [client] and update on disk state */ +pub async fn submit_client_keys( + keys_cfg: &EbicsKeysCfg, + host_cfg: &EbicsHostCfg, + client: &mut ClientPriKeysFile, + http: &Client, + ebics_logger: &EbicsLogger, + order: Order, +) -> anyhow::Result<()> { + if order == Order::HPB { + bail!("Only INI & HIA are supported for client keys"); + } + let res = key_management(host_cfg, client, http, ebics_logger, order).await?; + + if res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_STATE + || res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_OR_USER_STATE + { + bail!( + "{order} status code {}: either your IDs are incorrect, or you already have keys registered with this bank", + res.technical_code + ) + } + res.ok_or_fail(order.as_ref())?; + match order { + Order::INI => client.submitted_ini = true, + Order::HIA => client.submitted_hia = true, + Order::HPB => unreachable!("Only INI & HIA are supported for client keys"), + } + keys::persist_client_keys(client, keys_cfg.client_priv_keys_path.as_ref())?; + // TODO better error: Could not update the $order state on disk + Ok(()) +} + +pub async fn hpb( + http: &Client, + cfg: &EbicsHostCfg, + logger: &EbicsLogger, + client: &ClientPriKeysFile, +) -> anyhow::Result<BankPubKeysFile> { + let order = Order::HPB; + let res = key_management(cfg, client, http, logger, order).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", + res.technical_code + ) + } + let order_data = res + .ok_or_fail(order.as_ref())? + .expect("{order}: missing order data"); + + fn rsa_pub_key(xml: Xml) -> xml::Result<PublicEncryptingKey> { + xml.one("X509Data") + .one("X509Certificate") + .decode(rsa_private_from_b64_x509_certificate) + } + + Ok(Xml::parse_str( + &order_data, + "HPBResponseOrderData", + |root| { + let auth_pub = root.one("AuthenticationPubKeyInfo")?; + let version = auth_pub.one("AuthenticationVersion")?.text(); + assert_eq!( + version, "X002", + "Expected authentication version X002 got unsupported {version}" + ); + let auth_pub = rsa_pub_key(auth_pub)?; + + let enc_pub = root.one("EncryptionPubKeyInfo")?; + let version = enc_pub.one("EncryptionVersion")?.text(); + assert_eq!( + version, "E002", + "Expected encryption version E002 got unsupported {version}" + ); + let enc_pub = rsa_pub_key(enc_pub)?; + + Ok(BankPubKeysFile { + bank_authentication_public_key: auth_pub, + bank_encryption_public_key: enc_pub, + accepted: false, + }) + }, + )?) +} + +pub async fn key_management( + cfg: &EbicsHostCfg, + client: &ClientPriKeysFile, + http: &Client, + _ebics_logger: &EbicsLogger, + order: Order, +) -> anyhow::Result<EbicsResponse<Option<String>>> { + let EbicsHostCfg { + host_id, + user_id, + partner_id, + .. + } = cfg; + info!("Doing key request {order}"); + //val txLog = ebicsLogger.tx(order.name) + // TODO is this still necessary ? + + let (name, security_medium) = match order { + Order::INI | Order::HIA => ("ebicsUnsecuredRequest", "0200"), + Order::HPB => ("ebicsNoPubKeyDigestsRequest", "0000"), + }; + + fn xml_order_data( + cfg: &EbicsHostCfg, + name: &str, + schema: &str, + build: impl FnOnce(&mut XmlWriter), + ) -> String { + let xml = xml_build!(name ("xmlns":schema) ("xmlns:ds":"http://www.w3.org/2000/09/xmldsig#") { + @ build, + "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(); + BASE64_STANDARD.encode(&compressed) + } + + fn rsa_key_xml<K>(w: &mut XmlWriter, key: &K) + where + K: AsDer<Pkcs8V1Der<'static>>, + { + let der = key.as_der().unwrap(); + let b64 = BASE64_STANDARD.encode(der.as_ref()); + let lines = b64 + .as_bytes() + .chunks(64) + .map(|c| std::str::from_utf8(c).unwrap()) + .collect::<Vec<_>>() + .join("\n"); + let pem = + format!("-----BEGIN RSA PRIVATE KEY-----\n{lines}\n-----END RSA PRIVATE KEY-----\n"); + let cert = x509_certificate_from_rsa_private(&pem, "LibEuFin EBICS").unwrap(); + let der = cert.der(); + let b64 = BASE64_STANDARD.encode(der.as_ref()); + + xml_el!(w, "ds:X509Data" { + "ds:X509Certificate": b64 + }); + } + let data = match order { + Order::INI => Some(xml_order_data( + cfg, + "SignaturePubKeyOrderData", + "http://www.ebics.org/S002", + |w| { + xml_el!(w, "SignaturePubKeyInfo" { + @ |w| rsa_key_xml(w, &client.signature_private_key), + "SignatureVersion": "A006" + }); + }, + )), + Order::HIA => Some(xml_order_data( + cfg, + "HIARequestOrderData", + "urn:org:ebics:H005", + |w| { + xml_el!(w, "AuthenticationPubKeyInfo" { + @ |w| rsa_key_xml(w, &client.authentication_private_key), + "AuthenticationVersion": "X002" + }, + "EncryptionPubKeyInfo" { + @ |w| rsa_key_xml(w, &client.encryption_private_key), + "EncryptionVersion": "E002" + }); + }, + )), + Order::HPB => None, + }; + let sign = order == Order::HPB; + let msg = xml_build!( + name + ("xmlns": "urn:org:ebics:H005") + ("xmlns:ds": "http://www.w3.org/2000/09/xmldsig#") + ("Version": "H005") + ("Revision": "1") + { + "header" ("authenticate": "true") { + "static" { + "HostID": host_id, + @ |w: &mut XmlWriter| { + if order == Order::HPB { + let nonce: u128 = rand::random(); + xml_el!(w, + "Nonce": format_args!("{:032x}", nonce), + "Timestamp": jiff::Timestamp::now() + ); + } + }, + "PartnerID": partner_id, + "UserID": user_id, + "OrderDetails" { + "AdminOrderType": order + }, + "SecurityMedium": security_medium + }, + "mutable" + }, + @ |w: &mut XmlWriter| { + if sign { + xml_el!(w, "AuthSignature"); + } + }, + "body" { + @ |w: &mut XmlWriter| { + if let Some(data) = data { + xml_el!(w, "DataTransfer" { + "OrderData": data + }); + } + } + } + } + ); + let signed = if sign { + sign_ebics(msg, &client.authentication_private_key) + } else { + msg + }; + let res = post_to_bank(cfg.base_url.as_str(), http, signed).await?; + Ok(Xml::parse_str( + &res, + "ebicsKeyManagementResponse", + |root| { + let body = root.one("body")?; + Ok(EbicsResponse { + technical_code: root + .one_signed("header") + .one("mutable") + .one("ReturnCode") + .parse()?, + bank_code: body.one_signed("ReturnCode").parse()?, + content: if let Some(data) = body.opt("DataTransfer")? { + let info = data.one_signed("DataEncryptionInfo")?; + let info = DataEncryptionInfo { + transaction_key: info.one("TransactionKey").b64()?, + bank_pub_digest: info.one("EncryptionPubKeyDigest").b64()?, + }; + let chunk = data.one("OrderData").b64()?; + let decoded = decrypt_and_decompress_payload( + &client.encryption_private_key, + info, + vec![chunk], + ); + Some(String::from_utf8(decoded).unwrap()) + } else { + None + }, + }) + }, + )?) +} diff --git a/src/ebics/mod.rs b/src/ebics/mod.rs @@ -0,0 +1,23 @@ +/* +* 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/> +*/ + +pub mod administrative; +pub mod ebics_code; +pub mod key_management; +pub mod order; diff --git a/src/ebics/order.rs b/src/ebics/order.rs @@ -0,0 +1,207 @@ +/* +* 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 compact_str::CompactString; +use taler_enum_meta::EnumMeta; + +pub enum Order { + V2 { + ty: CompactString, + attribute: CompactString, + }, + V3 { + ty: CompactString, + service: Option<CompactString>, + scope: Option<CompactString>, + message: Option<CompactString>, + version: Option<CompactString>, + container: Option<CompactString>, + option: Option<CompactString>, + }, +} + +impl Order { + pub const WSS_PARAMS: Self = Self::V3 { + ty: CompactString::const_new("BTD"), + service: Some(CompactString::const_new("OTH")), + scope: Some(CompactString::const_new("DE")), + message: Some(CompactString::const_new("wssparam")), + version: None, + container: None, + option: None, + }; + pub const HAC: Self = Self::V3 { + ty: CompactString::const_new("HAC"), + service: None, + scope: None, + message: None, + version: None, + container: None, + option: None, + }; + pub const HKD: Self = Self::V3 { + ty: CompactString::const_new("HKD"), + service: None, + scope: None, + message: None, + version: None, + container: None, + option: None, + }; + pub const HAA: Self = Self::V3 { + ty: CompactString::const_new("HAA"), + service: None, + scope: None, + message: None, + version: None, + container: None, + option: None, + }; + + pub fn description(&self, mut f: std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::V2 { ty, attribute } => write!(f, "{ty}-{attribute}"), + Self::V3 { + ty, + service, + scope, + message, + version, + container, + option, + } => { + write!(f, "{ty}")?; + for part in [service, scope, container, option].into_iter().flatten() { + write!(f, "-{part}")?; + } + if let Some(message) = message { + write!(f, "-{message}")?; + if let Some(version) = version { + write!(f, ".{version}")?; + } + } + Ok(()) + } + } + } + + pub fn doc(&self) -> Option<OrderDoc> { + match self { + Self::V2 { ty, .. } => match ty.as_str() { + "HAC" => Some(OrderDoc::acknowledgement), + "Z01" => Some(OrderDoc::status), + "Z52" => Some(OrderDoc::report), + "Z53" => Some(OrderDoc::statement), + "Z54" => Some(OrderDoc::notification), + _ => None, + }, + Self::V3 { ty, message, .. } => match ty.as_str() { + "HAC" => Some(OrderDoc::acknowledgement), + "BTD" => match message.as_deref() { + Some("pain.002") => Some(OrderDoc::status), + Some("camt.052") => Some(OrderDoc::report), + Some("camt.053") => Some(OrderDoc::statement), + Some("camt.054") => Some(OrderDoc::notification), + _ => None, + }, + _ => None, + }, + } + } + + /** Check if EBICS order is a downloadable one */ + pub fn is_downloadable(&self) -> bool { + matches!( + self.doc(), + Some(OrderDoc::acknowledgement) + | Some(OrderDoc::status) + | Some(OrderDoc::report) + | Some(OrderDoc::statement) + | Some(OrderDoc::notification) + ) + } + + /** Check if EBICS order is an uploadable one */ + pub fn is_upload(&self) -> bool { + matches!(self, Self::V3 { ty, .. } if ty == "BTU") + } + + /** Check if two EBICS order match ignoring the message version */ + pub fn matches(&self, other: &Self) -> bool { + match (self, other) { + (Self::V2 { ty: ty1, .. }, Self::V2 { ty: ty2, .. }) => ty1 == ty2, + ( + Self::V3 { + ty: ty1, + service: service1, + scope: scope1, + message: message1, + container: container1, + option: option1, + .. + }, + Self::V3 { + ty: ty2, + service: service2, + scope: scope2, + message: message2, + container: container2, + option: option2, + .. + }, + ) => { + ty1 == ty2 + && service1 == service2 + && scope1 == scope2 + && message1 == message2 + && container1 == container2 + && option1 == option2 + } + _ => false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] +#[enum_meta(Str, Description)] +#[allow(non_camel_case_types)] +pub enum OrderDoc { + /// EBICS acknowledgement - CustomerAcknowledgement HAC pain.002 + acknowledgement, + /// Payment status - CustomerPaymentStatusReport pain.002 + status, + /// Account intraday reports - BankToCustomerAccountReport camt.052 + report, + /// Account statements - BankToCustomerStatement camt.053 + statement, + /// Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054 + notification, +} + +impl OrderDoc { + pub fn short_description(&self) -> &'static str { + match self { + Self::acknowledgement => "EBICS acknowledgement", + Self::status => "Payment status", + Self::report => "Account intraday reports", + Self::statement => "Account statements", + Self::notification => "Debit & credit notifications", + } + } +} diff --git a/src/iso20022/hac.rs b/src/iso20022/hac.rs @@ -77,7 +77,6 @@ pub fn parse_hac(xml: &str) -> xml::Result<Vec<CustomerAck>> { root.one("CstmrPmtStsRpt")? .many("OrgnlPmtInfAndSts") .map(|n| { - let action: HacAction = n.one("OrgnlPmtInfId").parse()?; let mut timestamp = None; let mut order_id = None; let info = n.one("StsRsnInf")?; @@ -94,13 +93,11 @@ pub fn parse_hac(xml: &str) -> xml::Result<Vec<CustomerAck>> { _ => {} } } - let code = info.opt("Rsn").one("Cd").parse()?; - let info: String = info.many("AddtlInf").map(|n| n.text()).collect(); Ok(CustomerAck { - action, + action: n.one("OrgnlPmtInfId").parse()?, order_id, - code, - info: info.into_boxed_str(), + code: info.opt("Rsn").one("Cd").parse()?, + info: info.many("AddtlInf").map(|n| n.text()).collect(), timestamp: timestamp.unwrap(), }) }) diff --git a/src/iso20022/mod.rs b/src/iso20022/mod.rs @@ -76,7 +76,10 @@ pub enum ChargeBearer { pub mod test { use tracing::info; - use crate::iso20022::{camt::parse_camt, hac::parse_hac, pain002::parse_pain002}; + use crate::{ + ebics::administrative::{parse_haa, parse_hkd}, + iso20022::{camt::parse_camt, hac::parse_hac, pain002::parse_pain002}, + }; #[test] pub fn sample() { @@ -107,7 +110,7 @@ pub mod test { } else if name.contains("pain002") { parse_pain002(&xml).unwrap(); } else if name.contains("pain001") { - // TODO + // Ignore } else { panic!("Unsupported file type {name}") } @@ -161,15 +164,15 @@ pub mod test { if path.contains("HAC") { parse_hac(&xml).unwrap(); } else if path.contains("HKD") { - // TODO + parse_hkd(&xml).unwrap(); } else if path.contains("HAA") { - // TODO + parse_haa(&xml).unwrap(); } else if path.contains("camt") { parse_camt(&xml).unwrap(); } else if path.contains("pain.002") { parse_pain002(&xml).unwrap(); } else if path.contains("pain.001") { - // TODO + // Ignore } else { panic!("Unsupported file type {path}") } diff --git a/src/key_management.rs b/src/key_management.rs @@ -1,327 +0,0 @@ -/* -* 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::io::Write as _; - -use anyhow::bail; -use aws_lc_rs::{ - encoding::{AsDer, Pkcs8V1Der}, - rsa::PublicEncryptingKey, -}; -use base64::{Engine as _, prelude::BASE64_STANDARD}; -use flate2::{Compression, write::ZlibEncoder}; -use reqwest::Client; -use tracing::info; - -use crate::{ - EbicsResponse, - common::{DataEncryptionInfo, EbicsLogger, decrypt_and_decompress_payload}, - config::{EbicsHostCfg, EbicsKeysCfg}, - crypto::{rsa_private_from_b64_x509_certificate, x509_certificate_from_rsa_private}, - ebics_code::EbicsReturnCode, - keys::{self, BankPubKeysFile, ClientPriKeysFile}, - post_to_bank, - xml::{self, Xml, XmlAccess as _, XmlWriter}, - xml_build, xml_el, - xml_sign::sign_ebics, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(clippy::upper_case_acronyms)] -pub enum Order { - INI, - HIA, - HPB, -} - -impl Order { - pub fn name(&self) -> &'static str { - match self { - Order::INI => "INI", - Order::HIA => "HIA", - Order::HPB => "HPB", - } - } -} - -/** Perform an EBICS public key management [order] using [client] and update on disk state */ -pub async fn submit_client_keys( - keys_cfg: &EbicsKeysCfg, - host_cfg: &EbicsHostCfg, - client: &mut ClientPriKeysFile, - http: &Client, - ebics_logger: &EbicsLogger, - order: Order, -) -> anyhow::Result<()> { - if order == Order::HPB { - bail!("Only INI & HIA are supported for client keys"); - } - let res = key_management(host_cfg, client, http, ebics_logger, order).await?; - - if res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_STATE - || res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_OR_USER_STATE - { - bail!( - "{} status code {}: either your IDs are incorrect, or you already have keys registered with this bank", - order.name(), - res.technical_code - ) - } - res.ok_or_fail(order.name())?; - match order { - Order::INI => client.submitted_ini = true, - Order::HIA => client.submitted_hia = true, - Order::HPB => unreachable!("Only INI & HIA are supported for client keys"), - } - keys::persist_client_keys(client, keys_cfg.client_priv_keys_path.as_ref())?; - // TODO better error: Could not update the $order state on disk - Ok(()) -} - -pub async fn hpb( - http: &Client, - cfg: &EbicsHostCfg, - logger: &EbicsLogger, - client: &ClientPriKeysFile, -) -> anyhow::Result<BankPubKeysFile> { - let order = Order::HPB; - let res = key_management(cfg, client, http, logger, order).await?; - if res.technical_code == EbicsReturnCode::EBICS_AUTHENTICATION_FAILED { - bail!( - "{} status code {}: could not download bank keys, send client keys (and/or related PDF document with --generate-registration-pdf) to the bank", - order.name(), - res.technical_code - ) - } - let order_data = res - .ok_or_fail(order.name())? - .expect("{order}: missing order data"); - - fn rsa_pub_key(xml: Xml) -> xml::Result<PublicEncryptingKey> { - xml.one("X509Data") - .one("X509Certificate") - .decode(rsa_private_from_b64_x509_certificate) - } - - Ok(Xml::parse_str( - &order_data, - "HPBResponseOrderData", - |root| { - let auth_pub = root.one("AuthenticationPubKeyInfo")?; - let version = auth_pub.one("AuthenticationVersion")?.text(); - assert_eq!( - version, "X002", - "Expected authentication version X002 got unsupported {version}" - ); - let auth_pub = rsa_pub_key(auth_pub)?; - - let enc_pub = root.one("EncryptionPubKeyInfo")?; - let version = enc_pub.one("EncryptionVersion")?.text(); - assert_eq!( - version, "E002", - "Expected encryption version E002 got unsupported {version}" - ); - let enc_pub = rsa_pub_key(enc_pub)?; - - Ok(BankPubKeysFile { - bank_authentication_public_key: auth_pub, - bank_encryption_public_key: enc_pub, - accepted: false, - }) - }, - )?) -} - -pub async fn key_management( - cfg: &EbicsHostCfg, - client: &ClientPriKeysFile, - http: &Client, - _ebics_logger: &EbicsLogger, - order: Order, -) -> anyhow::Result<EbicsResponse<Option<String>>> { - let EbicsHostCfg { - host_id, - user_id, - partner_id, - .. - } = cfg; - info!("Doing key request {}", order.name()); - //val txLog = ebicsLogger.tx(order.name) - // TODO is this still necessary ? - - let (name, security_medium) = match order { - Order::INI | Order::HIA => ("ebicsUnsecuredRequest", "0200"), - Order::HPB => ("ebicsNoPubKeyDigestsRequest", "0000"), - }; - - fn xml_order_data( - cfg: &EbicsHostCfg, - name: &str, - schema: &str, - build: impl FnOnce(&mut XmlWriter), - ) -> String { - let xml = xml_build!(name ("xmlns":schema) ("xmlns:ds":"http://www.w3.org/2000/09/xmldsig#") { - @ build, - "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(); - BASE64_STANDARD.encode(&compressed) - } - - fn rsa_key_xml<K>(w: &mut XmlWriter, key: &K) - where - K: AsDer<Pkcs8V1Der<'static>>, - { - let der = key.as_der().unwrap(); - let b64 = BASE64_STANDARD.encode(der.as_ref()); - let lines = b64 - .as_bytes() - .chunks(64) - .map(|c| std::str::from_utf8(c).unwrap()) - .collect::<Vec<_>>() - .join("\n"); - let pem = - format!("-----BEGIN RSA PRIVATE KEY-----\n{lines}\n-----END RSA PRIVATE KEY-----\n"); - let cert = x509_certificate_from_rsa_private(&pem, "LibEuFin EBICS").unwrap(); - let der = cert.der(); - let b64 = BASE64_STANDARD.encode(der.as_ref()); - - xml_el!(w, "ds:X509Data" { - "ds:X509Certificate": b64 - }); - } - let data = match order { - Order::INI => Some(xml_order_data( - cfg, - "SignaturePubKeyOrderData", - "http://www.ebics.org/S002", - |w| { - xml_el!(w, "SignaturePubKeyInfo" { - @ |w| rsa_key_xml(w, &client.signature_private_key), - "SignatureVersion": "A006" - }); - }, - )), - Order::HIA => Some(xml_order_data( - cfg, - "HIARequestOrderData", - "urn:org:ebics:H005", - |w| { - xml_el!(w, "AuthenticationPubKeyInfo" { - @ |w| rsa_key_xml(w, &client.authentication_private_key), - "AuthenticationVersion": "X002" - }, - "EncryptionPubKeyInfo" { - @ |w| rsa_key_xml(w, &client.encryption_private_key), - "EncryptionVersion": "E002" - }); - }, - )), - Order::HPB => None, - }; - let sign = order == Order::HPB; - let msg = xml_build!( - name - ("xmlns": "urn:org:ebics:H005") - ("xmlns:ds": "http://www.w3.org/2000/09/xmldsig#") - ("Version": "H005") - ("Revision": "1") - { - "header" ("authenticate": "true") { - "static" { - "HostID": host_id, - @ |w: &mut XmlWriter| { - if order == Order::HPB { - let nonce: u128 = rand::random(); - xml_el!(w, - "Nonce": format_args!("{:032x}", nonce), - "Timestamp": jiff::Timestamp::now() - ); - } - }, - "PartnerID": partner_id, - "UserID": user_id, - "OrderDetails" { - "AdminOrderType": order.name() - }, - "SecurityMedium": security_medium - }, - "mutable" - }, - @ |w: &mut XmlWriter| { - if sign { - xml_el!(w, "AuthSignature"); - } - }, - "body" { - @ |w: &mut XmlWriter| { - if let Some(data) = data { - xml_el!(w, "DataTransfer" { - "OrderData": data - }); - } - } - } - } - ); - let signed = if sign { - sign_ebics(msg, &client.authentication_private_key) - } else { - msg - }; - let res = post_to_bank(cfg.base_url.as_str(), http, signed).await?; - Ok(Xml::parse_str( - &res, - "ebicsKeyManagementResponse", - |root| { - let technical_code = root - .one_signed("header") - .one("mutable") - .one("ReturnCode") - .parse()?; - let body = root.one("body")?; - let bank_code = body.one_signed("ReturnCode").parse()?; - let content = if let Some(data) = body.opt("DataTransfer")? { - let info = data.one_signed("DataEncryptionInfo")?; - let info = DataEncryptionInfo { - transaction_key: info.one("TransactionKey").b64()?, - bank_pub_digest: info.one("EncryptionPubKeyDigest").b64()?, - }; - let chunk = data.one("OrderData").b64()?; - let decoded = decrypt_and_decompress_payload( - &client.encryption_private_key, - info, - vec![chunk], - ); - Some(String::from_utf8(decoded).unwrap()) - } else { - None - }; - Ok(EbicsResponse { - technical_code, - bank_code, - content, - }) - }, - )?) -} diff --git a/src/lib.rs b/src/lib.rs @@ -17,7 +17,7 @@ * <http://www.gnu.org/licenses/> */ -use std::{fmt::Display, path::Path}; +use std::path::Path; use anyhow::bail; use compact_str::CompactString; @@ -33,12 +33,13 @@ use tracing::{debug, info}; use crate::{ common::EbicsLogger, config::{EbicsHostCfg, NexusCfg}, - ebics_code::EbicsReturnCode, - key_management::{Order, hpb, submit_client_keys}, - keys::{load_bank_keys, load_client_keys, persist_client_keys}, - xml::XmlAccess as _, + ebics::{ + administrative::{VersionNumber, hev_msg, parse_hev}, + ebics_code::EbicsReturnCode, + key_management::{Order, hpb, submit_client_keys}, + }, + keys::{ClientPriKeysFile, load_bank_keys, load_client_keys, persist_client_keys}, }; -use crate::{keys::ClientPriKeysFile, xml::Xml}; pub mod api; pub mod common; @@ -46,9 +47,8 @@ pub mod config; pub mod crypto; pub mod db; pub mod dialect; -pub mod ebics_code; +pub mod ebics; pub mod iso20022; -pub mod key_management; pub mod keys; pub mod model; pub mod testbench; @@ -115,11 +115,11 @@ pub async fn ebics_setup( .join(", ") ); if !versions.contains(&VersionNumber { - number: "03.00".to_owned(), - schema: "H005".to_owned(), + number: "03.00".into(), + schema: "H005".into(), }) && versions.contains(&VersionNumber { - number: "03.02".to_owned(), - schema: "H005".to_owned(), + number: "03.02".into(), + schema: "H005".into(), }) { bail!("EBICS 3 is not supported by your bank"); } @@ -141,31 +141,12 @@ pub async fn ebics_setup( Ok(()) } -pub async fn hev(http: &Client, cfg: &EbicsHostCfg) -> anyhow::Result<Vec<VersionNumber>> { +pub async fn hev(http: &Client, cfg: &EbicsHostCfg) -> anyhow::Result<Box<[VersionNumber]>> { let phase = "HEV"; info!(target: "ebics", "Doing administrative request {phase}"); - let msg = xml_build!( - "ebicsHEVRequest" ("xmlns": "http://www.ebics.org/H000") { - "HostID": &cfg.host_id - } - ); + let msg = hev_msg(cfg); let res = post_to_bank(cfg.base_url.as_str(), http, msg).await?; - Xml::parse_str(&res, "ebicsHEVResponse", |root| { - let technical_code = root.one("SystemReturnCode").one("ReturnCode").parse()?; - let versions: Vec<_> = root - .many("VersionNumber") - .map(|n| VersionNumber { - number: n.text().to_owned(), - schema: n.attr("ProtocolVersion").expect("TODO").to_owned(), - }) - .collect(); - Ok(EbicsResponse { - technical_code, - bank_code: EbicsReturnCode::EBICS_OK, - content: versions, - }) - })? - .ok_or_fail(phase) + parse_hev(&res)?.ok_or_fail(phase) } #[derive(Debug, thiserror::Error)] @@ -207,16 +188,3 @@ impl<T> EbicsResponse<T> { } } } - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct VersionNumber { - pub number: String, - pub schema: String, -} - -impl Display for VersionNumber { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Self { number, schema } = self; - write!(f, "{number}:{schema}") - } -} diff --git a/src/xml.rs b/src/xml.rs @@ -273,104 +273,130 @@ impl<'xml> Xml<'xml> { pub fn text(self) -> &'xml str { self.node.text().unwrap_or_default() } + + pub fn attr(self, name: &str) -> Result<&'xml str> { + self.node + .attribute(name) + .ok_or_else(|| XmlError::MissingAttr(self.path(None), name.into())) + } + + pub fn opt_attr(self, name: &str) -> Option<&'xml str> { + self.node.attribute(name) + } } pub trait XmlAccess<'xml>: Sized { - type TryOut<T>; - type OptOut<T>; + type Out<T>; + type Opt<T>; - fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::TryOut<T>>; - fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) - -> Result<Self::OptOut<T>>; + fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>>; + fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>>; - fn one(self, tag: &'xml str) -> Result<Self::TryOut<Xml<'xml>>> { + fn one(self, tag: &'xml str) -> Result<Self::Out<Xml<'xml>>> { self.lift(|n| n.one_inner(tag, false)) } - fn one_signed(self, tag: &'xml str) -> Result<Self::TryOut<Xml<'xml>>> { + fn one_signed(self, tag: &'xml str) -> Result<Self::Out<Xml<'xml>>> { self.lift(|n| n.one_inner(tag, true)) } - fn opt(self, tag: &'xml str) -> Result<Self::OptOut<Xml<'xml>>> { + fn opt(self, tag: &'xml str) -> Result<Self::Opt<Xml<'xml>>> { self.opt_lift(|n| n.opt_inner(tag, false)) } - fn opt_signed(self, tag: &'xml str) -> Result<Self::OptOut<Xml<'xml>>> { + fn opt_signed(self, tag: &'xml str) -> Result<Self::Opt<Xml<'xml>>> { self.opt_lift(|n| n.opt_inner(tag, true)) } - fn attr(self, name: &str) -> Result<Self::TryOut<&'xml str>> { - self.lift(|n| { - n.node - .attribute(name) - .ok_or_else(|| XmlError::MissingAttr(n.path(None), name.into())) + fn parse_attr<T: FromStr>(self, name: &str) -> Result<Self::Out<T>> + where + T::Err: Display, + { + self.lift(|n| n.attr(name)?.parse().map_err(|e| n.parse_err(e))) + } + + fn parse_opt_attr<T: FromStr>(self, name: &str) -> Result<Self::Opt<T>> + where + T::Err: Display, + { + self.opt_lift(|n| { + n.opt_attr(name) + .map(|it| it.parse().map_err(|e| n.parse_err(e))) + .transpose() }) } fn decode<T, E: Display>( self, lambda: impl FnOnce(&str) -> std::result::Result<T, E>, - ) -> Result<Self::TryOut<T>> { + ) -> Result<Self::Out<T>> { // TODO error not a node text ? self.lift(|n| lambda(n.text()).map_err(|e| n.parse_err(e))) } - fn parse<T: FromStr>(self) -> Result<Self::TryOut<T>> + fn parse<T: FromStr>(self) -> Result<Self::Out<T>> where T::Err: Display, { self.decode(T::from_str) } - fn b64(self) -> Result<Self::TryOut<Vec<u8>>> { + fn b64(self) -> Result<Self::Out<Vec<u8>>> { self.decode(|it| BASE64_STANDARD.decode(it)) } } impl<'xml> XmlAccess<'xml> for Xml<'xml> { - type TryOut<T> = T; - type OptOut<T> = Option<T>; + type Out<T> = T; + type Opt<T> = Option<T>; - fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::TryOut<T>> { + fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>> { f(self) } - fn opt_lift<T>( - self, - f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>, - ) -> Result<Self::OptOut<T>> { + fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>> { self.lift(f) } } +impl<'xml> XmlAccess<'xml> for Option<Xml<'xml>> { + type Out<T> = Option<T>; + type Opt<T> = Option<T>; + + fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>> { + self.map(|it| it.lift(f)).transpose() + } + + fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>> { + match self { + Some(xml) => xml.opt_lift(f), + None => Ok(None), + } + } +} + impl<'xml> XmlAccess<'xml> for Result<Xml<'xml>> { - type TryOut<T> = T; - type OptOut<T> = Option<T>; + type Out<T> = T; + type Opt<T> = Option<T>; - fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::TryOut<T>> { + fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>> { self?.lift(f) } - fn opt_lift<T>( - self, - f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>, - ) -> Result<Self::OptOut<T>> { + fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>> { self.lift(f) } } impl<'xml> XmlAccess<'xml> for Result<Option<Xml<'xml>>> { - type TryOut<T> = Option<T>; - type OptOut<T> = Option<T>; + type Out<T> = Option<T>; + type Opt<T> = Option<T>; - fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::TryOut<T>> { + fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>> { self?.map(|it| it.lift(f)).transpose() } - fn opt_lift<T>( - self, - f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>, - ) -> Result<Self::OptOut<T>> { + fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>> { match self? { Some(xml) => xml.opt_lift(f), None => Ok(None),