libeufin

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

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

ebics: fetch & submit part 1

Diffstat:
MCargo.lock | 147++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
MCargo.toml | 5+++++
Asrc/bin/testbench.rs | 304+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/common.rs | 51---------------------------------------------------
Msrc/config.rs | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/crypto.rs | 188++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/db.rs | 31++++++++++++++++++++++++++-----
Msrc/db/initiated.rs | 49++++++++++++++++++++++++++++---------------------
Msrc/db/payment.rs | 34+++++++++++++++-------------------
Msrc/dialect.rs | 132++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/ebics/administrative.rs | 56++++++++++++++++++++++++--------------------------------
Asrc/ebics/bts.rs | 335+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ebics/ebics_code.rs | 5++---
Msrc/ebics/key_management.rs | 242+++++++++++++++++++++++++++++++++++--------------------------------------------
Asrc/ebics/logger.rs | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ebics/mod.rs | 512+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ebics/order.rs | 312+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Msrc/iso20022/camt.rs | 15++++++++++++---
Msrc/iso20022/hac.rs | 7+++----
Msrc/iso20022/mod.rs | 4++--
Msrc/iso20022/pain001.rs | 159++++++++++++++++++++++++++++++++++++++-----------------------------------------
Msrc/iso20022/pain002.rs | 51++++++++++++++++++++++++++++++++++++++-------------
Msrc/keys.rs | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Msrc/lib.rs | 586+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/main.rs | 7++-----
Msrc/model.rs | 6+++---
Dsrc/testbench.rs | 101-------------------------------------------------------------------------------
Asrc/utils.rs | 42++++++++++++++++++++++++++++++++++++++++++
Msrc/worker.rs | 6+++---
Msrc/xml.rs | 87++++++++++++++++++++++++++++++++++++++-----------------------------------------
30 files changed, 2997 insertions(+), 826 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -159,9 +159,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -170,9 +170,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" dependencies = [ "cc", "cmake", @@ -182,9 +182,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", @@ -246,9 +246,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] @@ -344,7 +344,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -361,9 +361,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -383,9 +383,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -459,6 +459,18 @@ dependencies = [ ] [[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -791,6 +803,12 @@ dependencies = [ ] [[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] name = "encoding_rs" version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1031,7 +1049,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -1201,15 +1219,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1392,6 +1409,19 @@ dependencies = [ ] [[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1537,9 +1567,9 @@ dependencies = [ [[package]] name = "konst" -version = "0.2.19" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330f0e13e6483b8c34885f7e6c9f19b1a7bd449c673fbb948a51c99d66ef74f4" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" dependencies = [ "konst_macro_rules", ] @@ -1567,9 +1597,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libeufin" @@ -1584,7 +1614,10 @@ dependencies = [ "const_format", "flate2", "getrandom 0.4.2", + "hex", + "indicatif", "jiff", + "owo-colors", "pem", "pretty_assertions", "rand 0.10.1", @@ -1604,6 +1637,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "tracing-subscriber", "url", "uuid", "x509-parser", @@ -1853,6 +1887,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] name = "parking" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1935,9 +1975,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plain" @@ -2053,7 +2093,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.3", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -2113,9 +2153,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -2129,7 +2169,7 @@ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -2172,9 +2212,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rcgen" @@ -2210,9 +2250,9 @@ dependencies = [ [[package]] name = "reedline" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe9e7c532bfc2759bc8a28902c04e8b993fc13ebd085ee4292eb1b230fa9beef" +checksum = "2066729dce9fecd28d1c6850a159ee68719130f149b22467c362353e16994e90" dependencies = [ "chrono", "crossterm", @@ -2222,7 +2262,6 @@ dependencies = [ "serde", "strip-ansi-escapes", "strum", - "strum_macros", "thiserror 2.0.18", "unicase", "unicode-segmentation", @@ -2378,9 +2417,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "aws-lc-rs", "once_cell", @@ -2441,9 +2480,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.11" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "aws-lc-rs", "ring", @@ -2966,20 +3005,22 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", "syn", ] @@ -3251,9 +3292,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.1" +version = "1.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" dependencies = [ "bytes", "libc", @@ -3484,6 +3525,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] name = "untrusted" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3522,9 +3569,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -3719,9 +3766,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -3732,14 +3779,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.6", + "webpki-roots 1.0.7", ] [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] diff --git a/Cargo.toml b/Cargo.toml @@ -31,6 +31,7 @@ taler-enum-meta = { path = "../taler-rust/common/taler-enum-meta" } #taler-api = { git = "git://git.taler.net/taler-rust.git/" } #taler-build = { git = "git://git.taler.net/taler-rust.git/" } #taler-test-utils = { git = "git://git.taler.net/taler-rust.git/" } +hex = "*" url = "*" clap = { version = "4.5", features = ["derive"] } pretty_assertions = "*" @@ -51,3 +52,6 @@ zip = { version = "*", default-features = false, features = [ "deflate-flate2-zlib-rs", ] } calamine = "*" +indicatif = "0.18.0" +tracing-subscriber = "*" +owo-colors = "*" +\ No newline at end of file diff --git a/src/bin/testbench.rs b/src/bin/testbench.rs @@ -0,0 +1,304 @@ +/* +* 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, fmt::Display, str::FromStr}; + +use anyhow::bail; +use clap::{Parser, ValueEnum}; +use compact_str::format_compact; +use jiff::Timestamp; +use libeufin::{ + CONFIG_SOURCE, + Cmd::{self}, + config::NexusCfg, + ebics::logger::EbicsLogger, + ebics_setup, + keys::{load_bank_keys, load_client_keys}, + run, +}; +use owo_colors::OwoColorize as _; +use reedline::{Prompt, Reedline, Signal}; +use reqwest::Client; +use taler_common::{ + config::Config, + log::taler_logger, + types::{amount::amount, payto::TransferIbanPayto}, +}; +use tracing::Level; +use tracing_subscriber::util::SubscriberInitExt as _; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +enum Component { + Nexus, + Ebisync, +} + +#[derive(Parser)] +/// Run integration tests on banks provider +pub struct TestbenchCmd { + #[arg(value_enum)] + component: Component, + platform: String, +} + +#[derive(Parser)] +#[command(name = "shell", no_binary_name = true)] +/// Run integration tests on banks provider +pub enum NexusCmd { + /// Reset EBICS keys + ResetKeys, + /// Reset DB + ResetDb, + // Initiate a new transaction + Tx, + /// Fetch all documents + Fetch, + Submit, + Exit, +} + +fn step(name: impl Display) { + println!("{}", name.magenta()) +} + +fn msg(msg: impl Display) { + println!("{}", msg.yellow()) +} + +fn err(msg: impl Display) { + println!("{}", msg.red()) +} + +fn check<R, E: Display>(res: Result<R, E>) -> bool { + match &res { + Ok(_) => println!("{}", "OK".green()), + Err(e) => { + tracing::error!(target: "testbench", "{e}"); + err("ERROR") + } + }; + res.is_ok() +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + taler_logger(Some(Level::DEBUG)).init(); + let cmd = TestbenchCmd::parse(); + // List available platform + let platforms: Vec<_> = std::fs::read_dir("testbench/test/platform") + .unwrap() + .filter_map(|entry| { + let e = entry.unwrap(); + let filename = e.file_name(); + if filename == "config.json" { + None + } else { + Some( + filename + .to_string_lossy() + .strip_suffix(".conf") + .unwrap() + .to_owned(), + ) + } + }) + .collect(); + if !platforms.contains(&cmd.platform) { + bail!( + "Unknown platform '{}', expected one of {}", + cmd.platform, + platforms.join(", ") + ); + } + + // Augment config + let simple_cfg = + std::fs::read_to_string(format!("testbench/test/platform/{}.conf", cmd.platform)).unwrap(); + let conf = format!("testbench/test/{}/ebics.conf", cmd.platform); + std::fs::write(&conf, format!(r#" + {simple_cfg} + {} + [paths] + LIBEUFIN_NEXUS_HOME = testbench/test/{} + EBISYNC_HOME = testbench/test/{} + + [nexus-fetch] + FREQUENCY = 1h + CHECKPOINT_TIME_OF_DAY = 16:52 + + [ebisync-fetch] + FREQUENCY = 1h + CHECKPOINT_TIME_OF_DAY = 16:52 + DESTINATION = azure-blob-storage + AZURE_API_URL = http://localhost:10000/devstoreaccount1/ + AZURE_ACCOUNT_NAME = devstoreaccount1 + AZURE_ACCOUNT_KEY = Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== + AZURE_CONTAINER = test + + [ebisync-submit] + SOURCE = ebisync-api + AUTH_METHOD = none + + [libeufin-nexusdb-postgres] + CONFIG = postgres:///libeufintestbench + + [ebisyncdb-postgres] + CONFIG = postgres:///libeufintestbench + "#, simple_cfg.replace("[nexus-ebics]", "[ebisync]").replace("[nexus-setup]", "[ebisync-setup]"), cmd.platform, cmd.platform)).unwrap(); + + let mut line_editor = Reedline::create(); + let prompt = BenchPrompt { + prompt: format!("{:?} {}", cmd.component, cmd.platform), + }; + let cfg = Config::from_file(CONFIG_SOURCE, Some(&conf)).unwrap(); + let cfg = NexusCfg::parse(cfg).unwrap(); + let ebics = cfg.keys().unwrap(); + let (name, settings) = match cfg.host().unwrap().base_url.as_str() { + "https://isotest.postfinance.ch/ebicsweb/ebicsweb" => ( + "PostFinance IsoTest", + Some("https://isotest.postfinance.ch/corporates/user/settings/ebics"), + ), + "https://iso20022test.credit-suisse.com/ebicsweb/ebicsweb" => ( + "Credit Suisse isoTest", + Some("https://iso20022test.credit-suisse.com/user/settings/ebics"), + ), + "https://ebics.postfinance.ch/ebics/ebics.aspx" => ("PostFinance", None), + _ => ("Unknown", None), + }; + let http = &Client::new(); + let test = settings.is_some(); + let ebics_log = + EbicsLogger::new(Some(format!("testbench/test/{}", cmd.platform).into())).unwrap(); + let payto = match cfg.currency.as_ref() { + "CHF" => { + "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff" + } + "EUR" => { + "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff" + } + _ => todo!("{}", cfg.currency), + }; + let payto = TransferIbanPayto::from_str(payto).unwrap(); + loop { + // Automatic setup + { + let client = load_client_keys(ebics.client_priv_keys_path.as_ref()).unwrap(); + let bank = load_bank_keys(ebics.bank_pub_keys_path.as_ref()).unwrap(); + if settings.is_none() && client.is_none() { + msg("Manual setup is required for non test environment") + } else if client + .map(|it| !it.submitted_ini || !it.submitted_hia) + .unwrap_or(true) + || bank.map(|it| !it.accepted).unwrap_or(true) + { + step("Run EBICS setup"); + if !check(ebics_setup(http, &cfg, &ebics_log, false, true).await) { + if let Some(settings) = settings { + let client = + load_client_keys(ebics.client_priv_keys_path.as_ref()).unwrap(); + if client + .map(|it| !it.submitted_ini || !it.submitted_hia) + .unwrap_or(true) + { + msg(format_args!( + "Got to {settings} and click on 'Reset EBICS user'" + )) + } else { + msg(format_args!( + "Got to {settings} and click on 'Activate EBICS user'" + )) + } + } else { + msg("Activate your keys at your bank") + } + } + } + } + let Signal::Success(buf) = line_editor.read_line(&prompt).unwrap() else { + err("^C"); + break; + }; + match NexusCmd::try_parse_from(buf.split_whitespace()) { + Ok(cmd) => match cmd { + NexusCmd::Fetch => { + check(run(cfg.cfg.clone(), &Cmd::EbicsFetch {}, &ebics_log).await); + } + NexusCmd::Submit => { + check(run(cfg.cfg.clone(), &Cmd::EbicsSubmit {}, &ebics_log).await); + } + NexusCmd::Tx => { + check( + run( + cfg.cfg.clone(), + &Cmd::InitiatePayment { + amount: Some(amount(format!("{}:0.1", cfg.currency))), + subject: Some(format_compact!("single {}", Timestamp::now())), + end_to_end_id: None, + payto: payto.clone(), + }, + &ebics_log, + ) + .await, + ); + } + NexusCmd::ResetDb => {} + NexusCmd::ResetKeys => { + if test { + std::fs::remove_file(format!("testbench/{}", ebics.client_priv_keys_path))?; + } + std::fs::remove_file(format!("testbench/{}", ebics.bank_pub_keys_path))?; + } + NexusCmd::Exit => return Ok(()), + }, + Err(e) => { + println!("{e}"); + } + } + } + Ok(()) +} + +struct BenchPrompt { + prompt: String, +} + +impl Prompt for BenchPrompt { + fn render_prompt_left(&self) -> Cow<'_, str> { + Cow::Borrowed(&self.prompt) + } + + fn render_prompt_right(&self) -> Cow<'_, str> { + Cow::Borrowed("") + } + + fn render_prompt_indicator(&self, _: reedline::PromptEditMode) -> Cow<'_, str> { + Cow::Borrowed(">") + } + + fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { + Cow::Borrowed(":") + } + + fn render_prompt_history_search_indicator( + &self, + _: reedline::PromptHistorySearch, + ) -> Cow<'_, str> { + Cow::Borrowed(">") + } +} diff --git a/src/common.rs b/src/common.rs @@ -1,51 +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 aws_lc_rs::rsa::PrivateDecryptingKey; -use flate2::write::ZlibDecoder; - -use crate::crypto::{decrypt_ebics_e002, decrypt_ebics_e002_key}; - -pub struct EbicsLogger {} - -pub struct DataEncryptionInfo { - pub transaction_key: Vec<u8>, - pub bank_pub_digest: Vec<u8>, -} - -/** Decrypts and decompresses EBICS BTS payload */ -pub fn decrypt_and_decompress_payload( - client_encryption_key: &PrivateDecryptingKey, - encryption_info: DataEncryptionInfo, - segments: Vec<Vec<u8>>, -) -> Vec<u8> { - // TODO check bank_pub_digest - let tx_key = decrypt_ebics_e002_key( - client_encryption_key.clone(), - &encryption_info.transaction_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() -} diff --git a/src/config.rs b/src/config.rs @@ -30,10 +30,13 @@ use taler_common::{ map_config, types::{ amount::{Amount, Currency}, + payto::{BankID, FullIbanPayto}, utils::date_to_utc_ts, }, }; +use crate::dialect::Dialect; + pub fn parse_db_cfg(cfg: &Config) -> Result<DbCfg, ValueErr> { DbCfg::parse(cfg.section("libeufin-nexusdb-postgres")) } @@ -78,7 +81,7 @@ pub enum AccountType { Normal, } -pub struct NexusIngestConfig { +pub struct NexusIngestCfg { pub account_type: AccountType, pub ignore_txs_before: Timestamp, pub ignore_bounces_before: Timestamp, @@ -88,7 +91,7 @@ pub struct NexusIngestConfig { pub currency: Currency, } -impl NexusIngestConfig { +impl NexusIngestCfg { pub fn simple(account_type: AccountType, currency: &Currency) -> Self { Self { account_type, @@ -102,7 +105,7 @@ impl NexusIngestConfig { } } -pub struct NexusFetchConfig { +pub struct NexusFetchCfg { pub frequency: Duration, pub frequency_raw: String, pub checkpoint_time: Time, @@ -113,7 +116,7 @@ pub struct NexusFetchConfig { pub bounce_fee: Amount, } -impl NexusFetchConfig { +impl NexusFetchCfg { pub fn parse(cfg: &Config, currency: &Currency) -> Result<Self, ValueErr> { let s = cfg.section("nexus-fetch"); @@ -122,10 +125,10 @@ impl NexusFetchConfig { frequency_raw: s.str("frequency").require()?, checkpoint_time: s.time("checkpoint_time_of_day").require()?, ignore_txs_before: date_to_utc_ts( - &s.date("ignore_transactions_before").default(Date::MIN)?, + &s.date("ignore_transactions_before").default(Date::ZERO)?, ), ignore_bounces_before: date_to_utc_ts( - &s.date("ignore_bounces_before").default(Date::MIN)?, + &s.date("ignore_bounces_before").default(Date::ZERO)?, ), restriction_payto_regex: s.regex("restriction_payto_regex").opt()?, bounce_deduce_fee: s.boolean("bounce_deduce_fee").default(false)?, @@ -136,13 +139,54 @@ impl NexusFetchConfig { } } +pub struct NexusSubmitCfg { + pub frequency: Duration, + pub frequency_raw: String, + pub require_ack: bool, +} + +impl NexusSubmitCfg { + pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let s = cfg.section("nexus-submit"); + + Ok(Self { + frequency: s.duration("frequency").require()?, + frequency_raw: s.str("frequency").require()?, + require_ack: s.boolean("manual_ack").default(false)?, + }) + } +} + +pub struct NexusEbicsConfig { + pub account: FullIbanPayto, + pub dialect: Dialect, +} + +impl NexusEbicsConfig { + pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let s = cfg.section("nexus-ebics"); + Ok(Self { + account: FullIbanPayto::new( + BankID { + iban: s.parse("IBAN", "iban").require()?, + bic: Some(s.parse("BIC", "bic").require()?), + }, + &s.str("name").require()?, + ), + dialect: s.parse("bank dialect", "bank_dialect").require()?, + }) + } +} + pub struct NexusCfg { pub cfg: Config, pub currency: Currency, pub account_type: AccountType, pub keys: OnceCell<EbicsKeysCfg>, pub host: OnceCell<EbicsHostCfg>, - pub fetch: OnceCell<NexusFetchConfig>, + pub fetch: OnceCell<NexusFetchCfg>, + pub submit: OnceCell<NexusSubmitCfg>, + pub ebics: OnceCell<NexusEbicsConfig>, } impl NexusCfg { @@ -159,6 +203,8 @@ impl NexusCfg { keys: OnceCell::new(), host: OnceCell::new(), fetch: OnceCell::new(), + submit: OnceCell::new(), + ebics: OnceCell::new(), }) } @@ -182,19 +228,39 @@ impl NexusCfg { Ok(self.host.get().unwrap()) } - pub fn fetch(&self) -> Result<&NexusFetchConfig, ValueErr> { + pub fn fetch(&self) -> Result<&NexusFetchCfg, ValueErr> { // TODO use get_or_try_init when stable if let Some(fetch) = self.fetch.get() { return Ok(fetch); } - let fetch = NexusFetchConfig::parse(&self.cfg, &self.currency)?; + let fetch = NexusFetchCfg::parse(&self.cfg, &self.currency)?; self.fetch.set(fetch).ok(); Ok(self.fetch.get().unwrap()) } - pub fn ingest(&self) -> Result<NexusIngestConfig, ValueErr> { + pub fn submit(&self) -> Result<&NexusSubmitCfg, ValueErr> { + // TODO use get_or_try_init when stable + if let Some(submit) = self.submit.get() { + return Ok(submit); + } + let submit = NexusSubmitCfg::parse(&self.cfg)?; + self.submit.set(submit).ok(); + Ok(self.submit.get().unwrap()) + } + + pub fn ebics(&self) -> Result<&NexusEbicsConfig, ValueErr> { + // TODO use get_or_try_init when stable + if let Some(ebics) = self.ebics.get() { + return Ok(ebics); + } + let ebics = NexusEbicsConfig::parse(&self.cfg)?; + self.ebics.set(ebics).ok(); + Ok(self.ebics.get().unwrap()) + } + + pub fn ingest(&self) -> Result<NexusIngestCfg, ValueErr> { let fetch = self.fetch()?; - Ok(NexusIngestConfig { + Ok(NexusIngestCfg { account_type: self.account_type, ignore_txs_before: fetch.ignore_txs_before, ignore_bounces_before: fetch.ignore_bounces_before, diff --git a/src/crypto.rs b/src/crypto.rs @@ -18,15 +18,27 @@ */ use aws_lc_rs::{ - cipher::{DecryptingKey, DecryptionContext, UnboundCipherKey}, + cipher::{ + AES_128, DecryptingKey, DecryptionContext, EncryptingKey, EncryptionContext, + UnboundCipherKey, + }, + digest::{Context, Digest, SHA256}, + encoding::AsDer, iv::FixedLength, - rsa::{Pkcs1PrivateDecryptingKey, PrivateDecryptingKey, PublicEncryptingKey}, + rand::SystemRandom, + rsa::{ + KeyPair, Pkcs1PrivateDecryptingKey, Pkcs1PublicEncryptingKey, PrivateDecryptingKey, + PublicEncryptingKey, PublicKey, + }, + signature::{RSA_PSS_2048_8192_SHA256, RSA_PSS_SHA256, UnparsedPublicKey}, }; use base64::{Engine as _, prelude::BASE64_STANDARD}; use jiff::{Timestamp, Zoned, tz::TimeZone}; use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyUsagePurpose}; use x509_parser::prelude::{FromDer as _, X509Certificate}; +use crate::keys::RsaPub; + /// Generate a self-signed X.509 certificate from an RSA private key (PEM or DER) pub fn x509_certificate_from_rsa_private( pem: &str, @@ -65,24 +77,87 @@ pub fn x509_certificate_from_rsa_private( Ok(cert) } +/** Create an RSA public key from its components: [modulus] and [exponent] */ +pub fn rsa_pub_from_component(modulus: &[u8], exponent: &[u8]) -> anyhow::Result<RsaPub> { + let key: PublicEncryptingKey = aws_lc_rs::rsa::PublicKeyComponents { + n: modulus, + e: exponent, + } + .try_into()?; + Ok(RsaPub::from_der(key.as_der()?.as_ref())?) +} + /// Extract an RSA public key from a X.509 certificate -pub fn rsa_private_from_b64_x509_certificate(encoded: &str) -> anyhow::Result<PublicEncryptingKey> { +pub fn rsa_private_from_b64_x509_certificate(encoded: &str) -> anyhow::Result<RsaPub> { let der = BASE64_STANDARD.decode(encoded)?; let (_, cert) = X509Certificate::from_der(&der)?; let issuer_public_key = cert.public_key(); cert.verify_signature(Some(issuer_public_key))?; - Ok(PublicEncryptingKey::from_der(issuer_public_key.raw)?) + Ok(RsaPub::from_der(issuer_public_key.raw)?) +} + +/// Hash an RSA public key according to the EBICS standard (EBICS 2.5: 4.4.1.2.3). +pub fn ebics_pub_key_hash(public_key: &PublicKey) -> Digest { + let mut ctx = Context::new(&SHA256); + let hex_encoded = |input: &[u8], ctx: &mut Context| { + let encoded = hex::encode(input); + if encoded.starts_with('0') { + ctx.update(&encoded.as_bytes()[1..]); + } else { + ctx.update(encoded.as_bytes()); + } + }; + + hex_encoded( + public_key.exponent().big_endian_without_leading_zero(), + &mut ctx, + ); + ctx.update(b" "); + hex_encoded( + public_key.modulus().big_endian_without_leading_zero(), + &mut ctx, + ); + ctx.finish() +} + +pub fn gen_ebics_e002_key(pub_key: PublicEncryptingKey) -> ([u8; 16], Vec<u8>) { + let mut transaction_key = [0u8; 16]; + getrandom::fill(&mut transaction_key).unwrap(); + + let key = Pkcs1PublicEncryptingKey::new(pub_key).unwrap(); + let mut encrypted_key = vec![0; key.ciphertext_size()]; + key.encrypt(&transaction_key, &mut encrypted_key).unwrap(); + + (transaction_key, encrypted_key) +} + +pub fn encrypt_ebics_e002(transaction_key: &[u8; 16], data: &[u8]) -> Vec<u8> { + let block_size = 16; + let padding_len = block_size - (data.len() % block_size); + let mut padded_data = data.to_vec(); + for i in 0..padding_len { + if i == padding_len - 1 { + padded_data.push(padding_len as u8); + } else { + padded_data.push(0); + } + } + + let iv = FixedLength::from([0u8; 16]); + let enc_key = + EncryptingKey::cbc(UnboundCipherKey::new(&AES_128, transaction_key).unwrap()).unwrap(); + enc_key + .less_safe_encrypt(&mut padded_data, EncryptionContext::Iv128(iv)) + .unwrap(); + + padded_data } pub fn decrypt_ebics_e002(transaction_key: &DecryptingKey, mut encrypted_data: Vec<u8>) -> Vec<u8> { - // AES-CBC with a zero IV, as in the Kotlin original. - let iv = [0u8; 16]; + let iv = FixedLength::from([0u8; 16]); let plaintext = transaction_key - .decrypt( - &mut encrypted_data, - DecryptionContext::Iv128(FixedLength::from(iv)), - ) + .decrypt(&mut encrypted_data, DecryptionContext::Iv128(iv)) .unwrap(); // Strip X9.23 / ANSI X9.23 padding: @@ -105,6 +180,97 @@ pub fn decrypt_ebics_e002_key( let cipher = private_key .decrypt(encrypted_transaction_key, &mut plaintext) .unwrap(); - let cipher_key = UnboundCipherKey::new(&aws_lc_rs::cipher::AES_128, cipher).unwrap(); + let cipher_key = UnboundCipherKey::new(&AES_128, cipher).unwrap(); DecryptingKey::cbc(cipher_key).unwrap() } + +pub fn digest_ebics_order_a006(order_data: &[u8]) -> Digest { + let mut digest = Context::new(&SHA256); + for chunk in order_data.split(|b| matches!(b, b'\r' | b'\n' | b'\x1a')) { + digest.update(chunk); + } + digest.finish() +} + +pub fn sign_ebics_a006(data: &[u8], key_pair: &KeyPair) -> Vec<u8> { + let mut sig = vec![0; key_pair.public_modulus_len()]; + key_pair + .sign(&RSA_PSS_SHA256, &SystemRandom::new(), data, &mut sig) + .unwrap(); + sig +} + +pub fn verify_ebics_a006(sig: &[u8], data: &[u8], public_key_der: &PublicKey) -> bool { + UnparsedPublicKey::new(&RSA_PSS_2048_8192_SHA256, public_key_der.as_ref()) + .verify(data, sig) + .is_ok() +} + +#[cfg(test)] +mod test { + use aws_lc_rs::{ + rsa::{KeyPair, KeySize, PrivateDecryptingKey}, + signature::KeyPair as _, + }; + + use crate::crypto::{ + decrypt_ebics_e002, decrypt_ebics_e002_key, ebics_pub_key_hash, encrypt_ebics_e002, + gen_ebics_e002_key, rsa_pub_from_component, sign_ebics_a006, verify_ebics_a006, + }; + + #[test] + fn e002() { + let data = b"Hello, World!"; + let key = PrivateDecryptingKey::generate(KeySize::Rsa2048).unwrap(); + + let (tx_key, encrypted_key) = gen_ebics_e002_key(key.public_key()); + let enc = encrypt_ebics_e002(&tx_key, data); + let key = decrypt_ebics_e002_key(key, &encrypted_key); + let dec = decrypt_ebics_e002(&key, enc); + assert_eq!(&data, &dec.as_slice()); + } + + #[test] + fn a006() { + let data = b"Hello, World!"; + let key_pair = KeyPair::generate(KeySize::Rsa2048).unwrap(); + let sig = sign_ebics_a006(data, &key_pair); + assert!(verify_ebics_a006(&sig, data, key_pair.public_key())); + } + + #[test] + fn public_key_hash() { + let exponent = "01 00 01".replace(|it: char| it.is_whitespace(), ""); + let modulus = " + EB BD B8 E3 73 45 60 06 44 A1 AD 6A 25 33 65 F5 + 9C EB E5 93 E0 51 72 77 90 6B F0 58 A8 89 EB 00 + C6 0B 37 38 F3 3C 55 F2 4D 83 D0 33 C3 A8 F0 3C + 82 4E AF 78 51 D6 F4 71 6A CC 9C 10 2A 58 C9 5F + 3D 30 B4 31 D7 1B 79 6D 43 AA F9 75 B5 7E 0B 4A + 55 52 1D 7C AC 8F 92 B0 AE 9F CF 5F 16 5C 6A D1 + 88 DB E2 48 E7 78 43 F9 18 63 29 45 ED 6C 08 6C + 16 1C DE F3 02 01 23 8A 58 35 43 2B 2E C5 3F 6F + 33 B7 A3 46 E1 75 BD 98 7C 6D 55 DE 71 11 56 3D + 7A 2C 85 42 98 42 DF 94 BF E8 8B 76 84 13 3E CA + 0E 8D 12 57 D6 8A CF 82 DE B7 D7 BB BC 45 AE 25 + 95 76 00 19 08 AA D2 C8 A7 D8 10 37 88 96 B9 98 + 14 B4 B0 65 F3 36 CE 93 F7 46 12 58 9F E7 79 33 + D5 BE 0D 0E F8 E7 E0 A9 C3 10 51 A1 3E A4 4F 67 + 5E 75 8C 9D E6 FE 27 B6 3C CF 61 9B 31 D4 D0 22 + B9 2E 4C AF 5F D6 4B 1F F0 4D 06 5F 68 EB 0B 71 + " + .replace(|it: char| it.is_whitespace(), ""); + let expected = " + 72 71 D5 83 B4 24 A6 DA 0B 7B 22 24 3B E2 B8 8C + 6E A6 0F 9F 76 11 FD 18 BE 2C E8 8B 21 03 A9 41 + " + .replace(|it: char| it.is_whitespace(), ""); + let key = rsa_pub_from_component( + &hex::decode(modulus).unwrap(), + &hex::decode(exponent).unwrap(), + ) + .unwrap(); + let hash = ebics_pub_key_hash(&key.key); + assert_eq!(&hex::decode(expected).unwrap(), hash.as_ref()); + } +} diff --git a/src/db.rs b/src/db.rs @@ -110,8 +110,11 @@ pub mod test { use crate::{ CONFIG_SOURCE, - db::{ebics_first, ebics_register, ebics_remove}, - model::{InId, InTx, InitiatedPayment, OutId, OutTx}, + db::{ + ebics_first, ebics_register, ebics_remove, + initiated::{PaymentInitiationResult, initiate}, + }, + model::{InId, InTx, Initiated, OutId, OutTx}, rand_ebics_id, }; @@ -145,8 +148,8 @@ pub mod test { pub fn gen_init_pay( end_to_end_id: impl Into<CompactString>, subject: impl Into<String>, - ) -> InitiatedPayment { - InitiatedPayment { + ) -> Initiated { + Initiated { id: 0, amount: Amount::new(&CURRENCY, 44, 0), creditor: IbanPayto::from_str("payto://iban/CH4189144589712575493?receiver-name=Test") @@ -154,7 +157,7 @@ pub mod test { .as_payto(), subject: subject.into(), initiation_time: Timestamp::now(), - end_to_end_id: end_to_end_id.into(), + e2e_id: end_to_end_id.into(), } } @@ -174,6 +177,24 @@ pub mod test { } } + pub async fn gen_initiate( + db: &PgPool, + end_to_end_id: impl Into<CompactString>, + subject: impl Into<String>, + ) -> PaymentInitiationResult { + let init = gen_init_pay(end_to_end_id, subject); + initiate( + &db, + &init.amount, + &init.subject, + &init.creditor, + &init.initiation_time, + &init.e2e_id, + ) + .await + .unwrap() + } + pub async fn check_count(db: &PgPool, nb_tx: usize, nb_bounce: usize) { sqlx::query( " diff --git a/src/db/initiated.rs b/src/db/initiated.rs @@ -20,11 +20,14 @@ use const_format::formatcp; use jiff::Timestamp; use sqlx::{PgPool, Row as _, postgres::PgRow}; use taler_api::db::{BindHelper as _, TypeHelper as _}; -use taler_common::types::amount::{Amount, Currency}; +use taler_common::types::{ + amount::{Amount, Currency}, + payto::PaytoURI, +}; use crate::{ db::{PENDING, UNSETTLED}, - model::{InitiatedPayment, OutId, OutTx, PaymentBatch, SubmissionState}, + model::{Initiated, OutId, OutTx, PaymentBatch, SubmissionState}, }; /// Outgoing payments initiation result @@ -37,7 +40,11 @@ pub enum PaymentInitiationResult { /// Initiate a new payment pub async fn initiate( pool: &PgPool, - payment: &InitiatedPayment, + amount: &Amount, + subject: &str, + creditor: &PaytoURI, + initiation_time: &Timestamp, + e2e_id: &str, ) -> sqlx::Result<PaymentInitiationResult> { let res = sqlx::query( " @@ -51,11 +58,11 @@ pub async fn initiate( RETURNING initiated_outgoing_transaction_id ", ) - .bind(payment.amount) - .bind(&payment.subject) - .bind(payment.creditor.as_ref().as_str()) - .bind_timestamp(&payment.initiation_time) - .bind(&payment.end_to_end_id) + .bind(amount) + .bind(subject) + .bind(creditor.as_ref().as_str()) + .bind_timestamp(initiation_time) + .bind(e2e_id) .try_map(|r: PgRow| Ok(PaymentInitiationResult::Success(r.try_get_u64(0)?))) .fetch_one(pool) .await; @@ -146,13 +153,13 @@ pub async fn initiated_submittable( ", ) .try_map(|r: PgRow| { - let payment = InitiatedPayment { + let payment = Initiated { id: r.try_get_u64("initiated_outgoing_transaction_id")?, amount: r.try_get_amount("amount", currency)?, creditor: r.try_get_parse("credit_payto")?, subject: r.try_get("subject")?, initiation_time: r.try_get_timestamp("initiation_time")?, - end_to_end_id: r.try_get("end_to_end_id")?, + e2e_id: r.try_get("end_to_end_id")?, }; let batch_id = r.try_get_u64("initiated_outgoing_batch_id")?; batch_map.get_mut(&batch_id).unwrap().payments.push(payment); @@ -445,14 +452,14 @@ mod test { use crate::{ CONFIG_SOURCE, - config::{NexusCfg, NexusIngestConfig}, + config::{NexusCfg, NexusIngestCfg}, db::{ initiated::{ PaymentInitiationResult, batch_initiated, batch_status_update, batch_sub_failure, - batch_sub_success, initiate, initiated_submittable, order_failure, order_step, - order_success, tx_status_update, + batch_sub_success, initiated_submittable, order_failure, order_step, order_success, + tx_status_update, }, - test::{CURRENCY, check_count, gen_in_pay, gen_init_pay, gen_out_pay, setup}, + test::{CURRENCY, check_count, gen_in_pay, gen_initiate, gen_out_pay, setup}, }, model::{SubmissionState, Tx}, rand_ebics_id, @@ -467,7 +474,7 @@ mod test { let cfg = cfg.ingest().unwrap(); let millis = Span::new().milliseconds(10); - async fn ingest(db: &PgPool, cfg: &NexusIngestConfig, execution_time: Timestamp) { + async fn ingest(db: &PgPool, cfg: &NexusIngestCfg, execution_time: Timestamp) { for tx in [ Tx::In( gen_in_pay(format!("test at {execution_time}")) @@ -595,7 +602,7 @@ mod test { // Create a test batch with three transactions for id in ["TX", "TX_SETTLED"] { assert!(matches!( - initiate(db, &gen_init_pay(id, "lol")).await.unwrap(), + gen_initiate(db, id, "lol").await, PaymentInitiationResult::Success(_) )); } @@ -606,7 +613,7 @@ mod test { // Create witness transactions and batch for id in ["WITNESS_1", "WITNESS_2"] { assert!(matches!( - initiate(db, &gen_init_pay(id, "lol")).await.unwrap(), + gen_initiate(db, id, "lol").await, PaymentInitiationResult::Success(_) )); } @@ -615,7 +622,7 @@ mod test { .unwrap(); for id in ["WITNESS_3", "WITNESS_4"] { assert!(matches!( - initiate(db, &gen_init_pay(id, "lol")).await.unwrap(), + gen_initiate(db, id, "lol").await, PaymentInitiationResult::Success(_) )); } @@ -832,8 +839,8 @@ mod test { let now = Timestamp::now(); for i in 0..6 { assert!(matches!( - initiate(&db, &gen_init_pay(format!("PAY{i}"), "")).await, - Ok(PaymentInitiationResult::Success(_)) + gen_initiate(&db, format!("PAY{i}"), "").await, + PaymentInitiationResult::Success(_) )); batch_initiated(&db, &now, &rand_ebics_id(), false) .await @@ -847,7 +854,7 @@ mod test { .await .unwrap() .iter() - .flat_map(|it| it.payments.iter().map(|it| it.end_to_end_id.as_str())) + .flat_map(|it| it.payments.iter().map(|it| it.e2e_id.as_str())) .collect::<Vec<_>>() ); }; diff --git a/src/db/payment.rs b/src/db/payment.rs @@ -314,16 +314,16 @@ mod test { use uuid::Uuid; use crate::{ - config::{AccountType, NexusIngestConfig}, + config::{AccountType, NexusIngestCfg}, db::{ - initiated::{PaymentInitiationResult, batch_initiated, initiate, initiated_ack}, + initiated::{PaymentInitiationResult, batch_initiated, initiated_ack}, payment::{ InResult, IncomingBounceRegistrationResult, OutgoingRegistrationResult, register_in_malformed, }, test::{ CURRENCY, check_in_count, check_in_state, check_out_count, gen_in_pay, - gen_init_pay, gen_out_pay, setup, + gen_initiate, gen_out_pay, setup, }, transfer::{RegistrationResult, transfer_register}, }, @@ -342,12 +342,8 @@ mod test { ] { let payment = gen_out_pay(subject.clone()); assert!(matches!( - initiate( - &db, - &gen_init_pay(payment.id.e2e_id.clone().unwrap(), subject), - ) - .await, - Ok(PaymentInitiationResult::Success(_)) + gen_initiate(&db, payment.id.e2e_id.clone().unwrap(), subject).await, + PaymentInitiationResult::Success(_) )); let first = register_outgoing(&db, &payment).await.unwrap(); assert_eq!( @@ -460,8 +456,8 @@ mod test { format!("{wtid} https://exchange.com/"), ] { assert!(matches!( - initiate(&db, &gen_init_pay(rand_ebics_id(), subject),).await, - Ok(PaymentInitiationResult::Success(_)) + gen_initiate(&db, rand_ebics_id(), subject).await, + PaymentInitiationResult::Success(_) )); } batch_initiated(&db, &Timestamp::now(), "BATCH", false) @@ -484,8 +480,8 @@ mod test { // Test manual ack let mut txs = Vec::new(); for nb in 0..3 { - let res = initiate(&db, &gen_init_pay(rand_ebics_id(), format!("tx {nb}"))).await; - if let Ok(PaymentInitiationResult::Success(id)) = &res { + let res = gen_initiate(&db, rand_ebics_id(), format!("tx {nb}")).await; + if let PaymentInitiationResult::Success(id) = &res { txs.push(*id); } else { panic!("Expected success got {res:?}"); @@ -610,7 +606,7 @@ mod test { async fn in_simple() { let (_, db) = setup().await; - let cfg = NexusIngestConfig::simple(AccountType::Exchange, &CURRENCY); + let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURRENCY); // Register let incoming = gen_in_pay("test".to_owned()); @@ -656,7 +652,7 @@ mod test { async fn in_talerable() { let (_, db) = setup().await; - let cfg = NexusIngestConfig::simple(AccountType::Exchange, &CURRENCY); + let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURRENCY); let key = EddsaPublicKey::rand(); let subject = format!("test with {key} reserve pub"); @@ -718,7 +714,7 @@ mod test { #[tokio::test] async fn in_mapping() { let (_, db) = setup().await; - let cfg = NexusIngestConfig::simple(AccountType::Exchange, &CURRENCY); + let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURRENCY); let first = EddsaPublicKey::rand(); let auth_pub = EddsaPublicKey::rand(); let auth_sig = EddsaSignature::rand(); @@ -843,7 +839,7 @@ mod test { #[tokio::test] async fn in_reference() { let (_, db) = setup().await; - let cfg = NexusIngestConfig::simple(AccountType::Exchange, &CURRENCY); + let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURRENCY); let first = EddsaPublicKey::rand(); let auth_pub = EddsaPublicKey::rand(); let auth_sig = EddsaSignature::rand(); @@ -967,7 +963,7 @@ mod test { #[tokio::test] async fn in_recover_info() { let (_, db) = setup().await; - let cfg = NexusIngestConfig::simple(AccountType::Exchange, &CURRENCY); + let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURRENCY); async fn check_content(db: &PgPool, p: &InTx) { sqlx::query( @@ -1092,7 +1088,7 @@ mod test { #[tokio::test] pub async fn in_horror() { let (_, db) = setup().await; - let cfg = NexusIngestConfig::simple(AccountType::Exchange, &CURRENCY); + let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURRENCY); // Check we do not bounce already registered talerable transaction let key = EddsaPublicKey::rand(); diff --git a/src/dialect.rs b/src/dialect.rs @@ -19,6 +19,8 @@ use taler_enum_meta::EnumMeta; +use crate::ebics::order::{Order, OrderDoc, Service}; + /** Supported EBICS standard */ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Standard { @@ -29,7 +31,135 @@ pub enum Standard { } impl Standard { - // TODO + pub fn downloads(&self, doc: &OrderDoc) -> Vec<Order> { + match self { + Standard::SIX => match doc { + OrderDoc::acknowledgement => vec![Order::HAC], + OrderDoc::status => vec![Order::BTD(Service { + name: "PSR".into(), + scope: Some("CH".into()), + option: None, + container: Some("ZIP".into()), + msg: "pain.002".into(), + version: Some("10".into()), + })], + OrderDoc::report => vec![Order::BTD(Service { + name: "STM".into(), + scope: Some("CH".into()), + option: None, + container: Some("ZIP".into()), + msg: "camt.052".into(), + version: Some("08".into()), + })], + OrderDoc::statement => vec![Order::BTD(Service { + name: "EOP".into(), + scope: Some("CH".into()), + option: None, + container: Some("ZIP".into()), + msg: "camt.053".into(), + version: Some("08".into()), + })], + OrderDoc::notification => vec![Order::BTD(Service { + name: "REP".into(), + scope: Some("CH".into()), + option: None, + container: Some("ZIP".into()), + msg: "camt.054".into(), + version: Some("08".into()), + })], + }, + Standard::GBIC => match doc { + OrderDoc::acknowledgement => vec![Order::HAC], + OrderDoc::status => vec![ + Order::BTD(Service { + name: "REP".into(), + scope: Some("DE".into()), + option: Some("SCI".into()), + container: Some("ZIP".into()), + msg: "pain.002".into(), + version: None, + }), + Order::BTD(Service { + name: "REP".into(), + scope: Some("DE".into()), + option: Some("SCT".into()), + container: Some("ZIP".into()), + msg: "pain.002".into(), + version: None, + }), + ], + OrderDoc::report => vec![Order::BTD(Service { + name: "STM".into(), + scope: Some("DE".into()), + option: None, + container: Some("ZIP".into()), + msg: "camt.052".into(), + version: None, + })], + OrderDoc::statement => vec![Order::BTD(Service { + name: "EOP".into(), + scope: Some("DE".into()), + option: None, + container: Some("ZIP".into()), + msg: "camt.053".into(), + version: None, + })], + OrderDoc::notification => vec![ + Order::BTD(Service { + name: "STM".into(), + scope: Some("DE".into()), + option: None, + container: Some("ZIP".into()), + msg: "camt.054".into(), + version: None, + }), + Order::BTD(Service { + name: "STM".into(), + scope: Some("DE".into()), + option: Some("SCI".into()), + container: Some("ZIP".into()), + msg: "camt.054".into(), + version: None, + }), + ], + }, + } + } + + pub fn direct_debit(&self) -> Order { + match self { + Standard::SIX => Order::BTU(Service { + name: "MCT".into(), + scope: Some("CH".into()), + option: None, + container: None, + msg: "pain.001".into(), + version: Some("09".into()), + }), + Standard::GBIC => Order::BTU(Service { + name: "SCT".into(), + scope: None, + option: None, + container: None, + msg: "pain.001".into(), + version: None, + }), + } + } + + pub fn instant_direct_debit(&self) -> Option<Order> { + match self { + Standard::SIX => None, + Standard::GBIC => Some(Order::BTU(Service { + name: "SCI".into(), + scope: Some("DE".into()), + option: None, + container: None, + msg: "pain.001".into(), + version: None, + })), + } + } } /** Supported bank dialects */ diff --git a/src/ebics/administrative.rs b/src/ebics/administrative.rs @@ -27,11 +27,14 @@ use taler_common::types::{ use taler_enum_meta::EnumMeta; use crate::{ - EbicsResponse, config::EbicsHostCfg, - ebics::{ebics_code::EbicsReturnCode, order::Order}, - xml::{self, Xml, XmlAccess as _}, - xml_build, + ebics::{ + EbicsResponse, + ebics_code::EbicsReturnCode, + order::{Order, Service}, + }, + xml, + xml::{Xml, XmlAccess as _}, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -72,7 +75,7 @@ pub struct UserInfo { } pub struct HAA { - pub orders: Box<[Order]>, + pub orders: Vec<Order>, } #[derive(Debug, Clone, PartialEq, Eq, EnumMeta)] @@ -97,15 +100,15 @@ pub enum UserStatus { } pub fn hev_msg(cfg: &EbicsHostCfg) -> String { - xml_build!( + xml!( "ebicsHEVRequest" ("xmlns": "http://www.ebics.org/H000") { - "HostId": &cfg.host_id + "HostID": &cfg.host_id } ) } -pub fn parse_hev(xml: &str) -> xml::Result<EbicsResponse<Box<[VersionNumber]>>> { - Xml::parse_str(xml, "ebicsHEVResponse", |root| { +pub fn parse_hev(xml: &[u8]) -> xml::Result<EbicsResponse<Box<[VersionNumber]>>> { + Xml::parse(xml, "ebicsHEVResponse", |root| { Ok(EbicsResponse { technical_code: root.one("SystemReturnCode").one("ReturnCode").parse()?, bank_code: EbicsReturnCode::EBICS_OK, @@ -122,36 +125,25 @@ pub fn parse_hev(xml: &str) -> xml::Result<EbicsResponse<Box<[VersionNumber]>>> }) } -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()?, +fn service(n: Xml) -> xml::Result<Service> { + let msg = n.one("MsgName")?; + Ok(Service { + name: n.one("ServiceName").parse()?, scope: n.opt("Scope").parse()?, option: n.opt("ServiceOption").parse()?, container: n.opt("Container").parse_attr("containerType")?, - message: msg.parse()?, + msg: msg.parse()?, version: msg.parse_opt_attr("version")?, }) } -pub fn parse_hkd(xml: &str) -> xml::Result<HKD> { +pub fn parse_hkd(xml: &[u8]) -> 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, - })) + Order::from_parts(ty, n.opt("Service")?.map(service).transpose()?) + .ok_or_else(|| n.parse_err(format_args!("Unknown order type {ty}"))) } - Xml::parse_str(xml, "HKDResponseOrderData", |root| { + Xml::parse(xml, "HKDResponseOrderData", |root| { let partner = root.one("PartnerInfo")?; Ok(HKD { @@ -217,12 +209,12 @@ pub fn parse_hkd(xml: &str) -> xml::Result<HKD> { }) } -pub fn parse_haa(xml: &str) -> xml::Result<HAA> { - Xml::parse_str(xml, "HAAResponseOrderData", |root| { +pub fn parse_haa(xml: &[u8]) -> xml::Result<HAA> { + Xml::parse(xml, "HAAResponseOrderData", |root| { Ok(HAA { orders: root .many("Service") - .map(|s| ebics_order(s, "BTD")) + .map(|n| Ok(Order::BTD(service(n)?))) .collect::<xml::Result<_>>()?, }) }) diff --git a/src/ebics/bts.rs b/src/ebics/bts.rs @@ -0,0 +1,335 @@ +/* +* 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/> +*/ + +/*! EBICS protocol for business transactions */ + +use compact_str::CompactString; +use jiff::{Timestamp, Zoned, tz::TimeZone}; + +use crate::{ + config::EbicsHostCfg, + crypto::ebics_pub_key_hash, + ebics::{ + EbicsResponse, PreparedUploadData, + order::{Order, Service}, + }, + keys::{BankPubKeysFile, ClientPriKeysFile}, + utils::b64, + xml, + xml::{Xml, XmlAccess, XmlWriter}, + xml_sign::sign_ebics, +}; + +fn signed_request( + order: &Order, + client: &ClientPriKeysFile, + lambda: impl FnOnce(&mut XmlWriter), +) -> String { + let schema = order.schema(); + let doc = xml!( + "ebicsRequest" + ("xmlns": (format_args!("urn:org:ebics:{schema}"))) + ("xmlns:ds": "http://www.w3.org/2000/09/xmldsig#") + ("Version": schema) + ("Revision": "1") + { + @ lambda + } + ); + sign_ebics(doc, &client.auth) +} + +fn bank_digest(w: &mut XmlWriter, bank: &BankPubKeysFile) { + xml!(w, + "BankPubKeyDigests" { + "Authentication" + ("Version": "X002") + ("Algorithm": "http://www.w3.org/2001/04/xmlenc#sha256") + : b64(ebics_pub_key_hash(&bank.auth.key)), + "Encryption" + ("Version": "E002") + ("Algorithm": "http://www.w3.org/2001/04/xmlenc#sha256") + : b64(ebics_pub_key_hash(&bank.enc.key)) + }, + "SecurityMedium": "0000" + ) +} + +fn service(w: &mut XmlWriter, service: &Service) { + let Service { + name, + scope, + msg, + version, + container, + option, + } = service; + xml!(w, + "Service" { + "ServiceName": name, + @ |w: &mut XmlWriter| { + if let Some(scope) = scope { + xml!(w, "Scope": scope) + } + if let Some(option) = option { + xml!(w, "ServiceOption": option) + } + if let Some(container) = container { + xml!(w, "Container" ("containerType": container)) + } + + if let Some(version) = version { + xml!(w, "MsgName" ("version": version): msg) + } else { + xml!(w, "MsgName": msg) + } + } + } + ) +} + +pub fn download_init( + cfg: &EbicsHostCfg, + bank: &BankPubKeysFile, + client: &ClientPriKeysFile, + order: &Order, + range: &Option<(Timestamp, Timestamp)>, +) -> String { + let nonce: u128 = rand::random(); + signed_request(order, client, |w| { + xml!(w, + "header" ("authenticate": "true") { + "static" { + "HostID": cfg.host_id, + "Nonce": format_args!("{:032x}", nonce), + "Timestamp": jiff::Timestamp::now(), + "PartnerID": cfg.partner_id, + "UserID": cfg.user_id, + "OrderDetails" { + "AdminOrderType": order.ty(), + @ |w: &mut XmlWriter| if let Order::BTD(s) = order { + xml!(w, "BTDOrderParams" { + @ |w: &mut XmlWriter| { + service(w, s); + if let Some((start, end)) = range { + xml!(w, + "DateRange" { + "Start": Zoned::new(*start, TimeZone::UTC).date(), + "End": Zoned::new(*end, TimeZone::UTC).date() + } + ) + } + } + }) + } else { + xml!(w, "StandardOrderParams") + } + }, + @ |w: &mut XmlWriter| bank_digest(w, bank) + }, + "mutable" { + "TransactionPhase": "Initialisation" + } + }, + "AuthSignature", + "body" + ) + }) +} + +pub fn download_transfer( + cfg: &EbicsHostCfg, + client: &ClientPriKeysFile, + order: &Order, + nb_segment: usize, + segment_nb: usize, + tx_id: &str, +) -> String { + signed_request(order, client, |w| { + xml!(w, + "header" ("authenticate": "true") { + "static" { + "HostID": cfg.host_id, + "TransactionID": tx_id + }, + "mutable" { + "TransactionPhase": "Transfer", + "SegmentNumber" ("lastSegment": (nb_segment == segment_nb)): segment_nb + } + }, + "AuthSignature", + "body" + ) + }) +} + +pub fn download_receipt( + cfg: &EbicsHostCfg, + client: &ClientPriKeysFile, + order: &Order, + tx_id: &str, + success: bool, +) -> String { + signed_request(order, client, |w| { + xml!(w, + "header" ("authenticate": "true") { + "static" { + "HostID": cfg.host_id, + "TransactionID": tx_id + }, + "mutable" { + "TransactionPhase": "Receipt" + } + }, + "AuthSignature", + "body" { + "TransferReceipt" ("authenticate": "true") { + "ReceiptCode": (if success { "0" } else { "1"}) + } + } + ) + }) +} + +pub fn upload_init( + cfg: &EbicsHostCfg, + bank: &BankPubKeysFile, + client: &ClientPriKeysFile, + order: &Order, + data: &PreparedUploadData, +) -> String { + let nonce: u128 = rand::random(); + signed_request(order, client, |w| { + xml!(w, + "header" ("authenticate": "true") { + "static" { + "HostID": cfg.host_id, + "Nonce": format_args!("{:032x}", nonce), + "Timestamp": jiff::Timestamp::now(), + "PartnerID": cfg.partner_id, + "UserID": cfg.user_id, + "OrderDetails" { + "AdminOrderType": order.ty(), + @ |w: &mut XmlWriter| if let Order::BTU(s) = order { + xml!(w, "BTUOrderParams" { + @ |w: &mut XmlWriter| service(w, s), + "SignatureFlag" + }) + } else { + xml!(w, "StandardOrderParams") + } + }, + @ |w: &mut XmlWriter| bank_digest(w, bank), + "NumSegments": data.nb_segments() + }, + "mutable" { + "TransactionPhase": "Initialisation" + } + }, + "AuthSignature", + "body" { + "DataTransfer" { + "DataEncryptionInfo" ("authenticate": "true") { + "EncryptionPubKeyDigest" + ("Version": "E002") + ("Algorithm": "http://www.w3.org/2001/04/xmlenc#sha256") + : b64(ebics_pub_key_hash(&bank.enc.key)), + "TransactionKey": b64(&data.encrypted_key) + }, + "SignatureData" ("authenticate": "true"): data.signature_data, + "DataDigest" ("SignatureVersion": "A006"): b64(data.digest) + } + } + ) + }) +} + +pub fn upload_transfer( + cfg: &EbicsHostCfg, + client: &ClientPriKeysFile, + order: &Order, + tx_id: &str, + data: &PreparedUploadData, + segment_nb: usize, +) -> String { + signed_request(order, client, |w| { + xml!(w, + "header" ("authenticate": "true") { + "static" { + "HostID": cfg.host_id, + "TransactionID": tx_id + }, + "mutable" { + "TransactionPhase": "Transfer", + "SegmentNumber" ("lastSegment": (data.nb_segments() == segment_nb)): segment_nb + } + }, + "AuthSignature", + "body" { + "DataTransfer" { + "OrderData": data.segment(segment_nb) + } + } + ) + }) +} + +pub struct DataEncryptionInfo { + pub tx_key: Vec<u8>, + pub bank_pub_digest: Vec<u8>, +} + +pub struct BTSResponse { + pub tx_id: Option<CompactString>, + pub order_id: Option<CompactString>, + pub data_encryption_info: Option<DataEncryptionInfo>, + pub segment: Option<Vec<u8>>, + pub segment_number: Option<usize>, + pub nb_segments: Option<usize>, +} + +pub fn parse_bts(xml: &[u8]) -> xml::Result<EbicsResponse<BTSResponse>> { + Xml::parse(xml, "ebicsResponse", |root| { + let header = root.one_signed("header")?; + let st = header.one("static")?; + let mutable = header.one("mutable")?; + let body = root.one("body")?; + let data = body.opt("DataTransfer")?; + Ok(EbicsResponse { + technical_code: mutable.one("ReturnCode").parse()?, + bank_code: body.one_signed("ReturnCode").parse()?, + content: BTSResponse { + tx_id: st.opt("TransactionID").parse()?, + order_id: mutable.opt("OrderID").parse()?, + data_encryption_info: data + .opt_signed("DataEncryptionInfo")? + .map(|n| { + Ok(DataEncryptionInfo { + tx_key: n.one("TransactionKey").b64()?, + bank_pub_digest: n.one("EncryptionPubKeyDigest").b64()?, + }) + }) + .transpose()?, + segment: data.map(|it| it.one("OrderData").b64()).transpose()?, + segment_number: mutable.opt("SegmentNumber").parse()?, + nb_segments: st.opt("NumSegments").parse()?, + }, + }) + }) +} diff --git a/src/ebics/ebics_code.rs b/src/ebics/ebics_code.rs @@ -33,7 +33,6 @@ pub enum EbicsKind { /// 09 - Non-recoverable Error NonRecoverableError, } - #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] #[enum_meta(DomainCode, Str)] #[allow(non_camel_case_types)] @@ -192,13 +191,13 @@ pub enum EbicsReturnCode { impl EbicsReturnCode { /// Automatically classifies the severity/kind based on standard EBICS prefixes. pub fn kind(&self) -> EbicsKind { - match &self.as_ref()[0..2] { + match &self.code()[..2] { "00" => EbicsKind::Information, "01" => EbicsKind::Note, "03" => EbicsKind::Warning, "06" => EbicsKind::RecoverableError, "09" => EbicsKind::NonRecoverableError, - _ => unreachable!("Internal parser mapping error"), + prefix => unreachable!("Internal parser mapping error {prefix}"), } } diff --git a/src/ebics/key_management.rs b/src/ebics/key_management.rs @@ -17,41 +17,29 @@ * <http://www.gnu.org/licenses/> */ -use std::io::Write as _; +use std::{borrow::Cow, io::Write as _}; use anyhow::bail; -use aws_lc_rs::{ - encoding::{AsDer, Pkcs8V1Der}, - rsa::PublicEncryptingKey, -}; +use aws_lc_rs::encoding::{AsDer, Pkcs8V1Der}; 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, + ebics::{ + EbicsCtx, EbicsErrKind, EbicsError, EbicsErrorHelper, EbicsResponse, + bts::DataEncryptionInfo, decrypt_and_decompress_payload, ebics_code::EbicsReturnCode, + logger::EbicsLogger, order::Order, post_to_bank, + }, + keys::{self, BankPubKeysFile, ClientPriKeysFile, RsaPub}, + xml, + xml::{Xml, XmlAccess as _, XmlWriter}, 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, @@ -60,31 +48,33 @@ pub async fn submit_client_keys( http: &Client, ebics_logger: &EbicsLogger, order: Order, -) -> anyhow::Result<()> { - if order == Order::HPB { - bail!("Only INI & HIA are supported for client keys"); +) -> Result<(), EbicsError> { + let ctx = EbicsCtx::new(&order); + if !matches!(order, Order::INI | Order::HIA) { + unreachable!("Only INI & HIA are supported for client keys"); } - let res = key_management(host_cfg, client, http, ebics_logger, order).await?; + 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", + return Err(EbicsErrKind::Custom(Cow::Owned(format!( + "status code {}: either your IDs are incorrect, or you already have keys registered with this bank", res.technical_code - ) + ))).ctx(&ctx)); } - res.ok_or_fail(order.as_ref())?; + res.ok_or_fail().ctx(&ctx)?; 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"), + _ => unreachable!("Only INI & HIA are supported for client keys"), } - keys::persist_client_keys(client, keys_cfg.client_priv_keys_path.as_ref())?; + keys::persist_client_keys(client, keys_cfg.client_priv_keys_path.as_ref()).ctx(&ctx)?; // TODO better error: Could not update the $order state on disk Ok(()) } +/** Perform an EBICS private key management HPB using [client] */ pub async fn hpb( http: &Client, cfg: &EbicsHostCfg, @@ -92,72 +82,66 @@ pub async fn hpb( client: &ClientPriKeysFile, ) -> anyhow::Result<BankPubKeysFile> { let order = Order::HPB; - let res = key_management(cfg, client, http, logger, order).await?; + 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"); + let order_data = res.ok_or_fail()?.expect("{order}: missing order data"); - fn rsa_pub_key(xml: Xml) -> xml::Result<PublicEncryptingKey> { + fn rsa_pub_key(xml: Xml) -> xml::Result<RsaPub> { 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)?; + Ok(Xml::parse(&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)?; + 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, - }) - }, - )?) + Ok(BankPubKeysFile { + auth: auth_pub, + enc: enc_pub, + accepted: false, + }) + })?) } -pub async fn key_management( +async fn key_management( cfg: &EbicsHostCfg, client: &ClientPriKeysFile, http: &Client, - _ebics_logger: &EbicsLogger, - order: Order, -) -> anyhow::Result<EbicsResponse<Option<String>>> { + ebics_logger: &EbicsLogger, + order: &Order, +) -> Result<EbicsResponse<Option<Vec<u8>>>, EbicsError> { let EbicsHostCfg { host_id, user_id, partner_id, .. } = cfg; + let ctx = EbicsCtx::new(order); 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"), + _ => unreachable!(), }; fn xml_order_data( @@ -166,7 +150,7 @@ pub async fn key_management( schema: &str, build: impl FnOnce(&mut XmlWriter), ) -> String { - let xml = xml_build!(name ("xmlns":schema) ("xmlns:ds":"http://www.w3.org/2000/09/xmldsig#") { + let xml = xml!(name ("xmlns":schema) ("xmlns:ds":"http://www.w3.org/2000/09/xmldsig#") { @ build, "PartnerID": &cfg.partner_id, "UserID": &cfg.user_id @@ -196,9 +180,9 @@ pub async fn key_management( let der = cert.der(); let b64 = BASE64_STANDARD.encode(der.as_ref()); - xml_el!(w, "ds:X509Data" { + xml!(w, "ds:X509Data" { "ds:X509Certificate": b64 - }); + }) } let data = match order { Order::INI => Some(xml_order_data( @@ -206,10 +190,10 @@ pub async fn key_management( "SignaturePubKeyOrderData", "http://www.ebics.org/S002", |w| { - xml_el!(w, "SignaturePubKeyInfo" { - @ |w| rsa_key_xml(w, &client.signature_private_key), + xml!(w, "SignaturePubKeyInfo" { + @ |w| rsa_key_xml(w, &client.sign), "SignatureVersion": "A006" - }); + }) }, )), Order::HIA => Some(xml_order_data( @@ -217,20 +201,23 @@ pub async fn key_management( "HIARequestOrderData", "urn:org:ebics:H005", |w| { - xml_el!(w, "AuthenticationPubKeyInfo" { - @ |w| rsa_key_xml(w, &client.authentication_private_key), - "AuthenticationVersion": "X002" - }, + xml!(w, + "AuthenticationPubKeyInfo" { + @ |w| rsa_key_xml(w, &client.auth), + "AuthenticationVersion": "X002" + }, "EncryptionPubKeyInfo" { - @ |w| rsa_key_xml(w, &client.encryption_private_key), - "EncryptionVersion": "E002" - }); + @ |w| rsa_key_xml(w, &client.enc), + "EncryptionVersion": "E002" + } + ) }, )), Order::HPB => None, + _ => unreachable!(), }; - let sign = order == Order::HPB; - let msg = xml_build!( + let sign = matches!(order, Order::HPB); + let msg = xml!( name ("xmlns": "urn:org:ebics:H005") ("xmlns:ds": "http://www.w3.org/2000/09/xmldsig#") @@ -240,14 +227,12 @@ pub async fn key_management( "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() - ); - } + @ |w: &mut XmlWriter| if *order == Order::HPB { + let nonce: u128 = rand::random(); + xml!(w, + "Nonce": format_args!("{:032x}", nonce), + "Timestamp": jiff::Timestamp::now() + ) }, "PartnerID": partner_id, "UserID": user_id, @@ -258,57 +243,46 @@ pub async fn key_management( }, "mutable" }, - @ |w: &mut XmlWriter| { - if sign { - xml_el!(w, "AuthSignature"); - } + @ |w: &mut XmlWriter| if sign { + xml!(w, "AuthSignature") }, "body" { - @ |w: &mut XmlWriter| { - if let Some(data) = data { - xml_el!(w, "DataTransfer" { - "OrderData": data - }); - } + @ |w: &mut XmlWriter| if let Some(data) = data { + xml!(w, "DataTransfer" { + "OrderData": data + }) } } } ); let signed = if sign { - sign_ebics(msg, &client.authentication_private_key) + sign_ebics(msg, &client.auth) } 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 - }, - }) - }, - )?) + let res = post_to_bank(http, cfg.base_url.as_str(), signed, &ctx, ebics_logger).await?; + Xml::parse(&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 { + tx_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.enc, info, vec![chunk]); + Some(decoded) + } else { + None + }, + }) + }) + .ctx(&ctx) } diff --git a/src/ebics/logger.rs b/src/ebics/logger.rs @@ -0,0 +1,135 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2024, 2025, 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, + io::{BufWriter, Cursor, Write}, + path::{Path, PathBuf}, +}; + +use jiff::{Zoned, tz::TimeZone}; +use reqwest::Response; +use tracing::info; + +use crate::ebics::{EbicsCtx, EbicsError, EbicsErrorHelper}; + +/** Log EBICS transactions steps and payload if dir is not null */ +pub struct EbicsLogger { + dir: Option<PathBuf>, +} + +impl EbicsLogger { + pub fn new(dir: Option<PathBuf>) -> std::io::Result<Self> { + if let Some(dir) = &dir { + // Create logging directory if missing + std::fs::create_dir_all(dir)?; + info!(target: "ebics", "Logging EBICS to {dir:?}"); + } + Ok(Self { dir }) + } + + fn log_dir(root: &Path, ctx: &EbicsCtx) -> Result<PathBuf, EbicsError> { + let now_utc = Zoned::new(ctx.now, TimeZone::UTC).datetime(); + // yyyy-MM-dd per day directory & HH:mm:ss.SSS-name per transaction directory + let path = root.join(format!( + "{}/{}-{}", + now_utc.date(), + now_utc.strftime("%H:%M:%S.%3f"), + ctx.order, + )); + std::fs::create_dir_all(&path).ctx(ctx)?; + Ok(path) + } + + fn prefix(ctx: &EbicsCtx) -> impl Display { + std::fmt::from_fn(|f| match &ctx.phase { + Some(phase) => write!(f, "{phase}-"), + None => Ok(()), + }) + } + + /** Log a [content] EBICS transaction payload of [type] */ + pub fn log_payload(&self, ctx: &EbicsCtx, content: &[u8], ty: &str) -> Result<(), EbicsError> { + if let Some(root) = &self.dir { + let dir = Self::log_dir(root, ctx)?; + let ty = ty.to_lowercase(); + if ty == "zip" { + let dir = dir.join("payload"); + std::fs::create_dir_all(&dir).ctx(ctx)?; + let mut z = zip::ZipArchive::new(Cursor::new(content)).unwrap(); + for i in 0..z.len() { + let mut from = z.by_index(i).unwrap(); + let mut to = std::fs::File::create_new(dir.join(from.name())).ctx(ctx)?; + std::io::copy(&mut from, &mut to).ctx(ctx)?; + } + } else { + std::fs::File::create_new(dir.join(format!("payload.{ty}"))) + .ctx(ctx)? + .write_all(content) + .ctx(ctx)?; + } + } + Ok(()) + } + + /** Log a protocol step [request] */ + pub fn log_request(&self, ctx: &EbicsCtx, xml: &str) -> Result<(), EbicsError> { + if let Some(root) = &self.dir { + let dir = Self::log_dir(root, ctx)?; + let path = dir.join(format!("{}request.xml", Self::prefix(ctx))); + std::fs::File::create_new(path) + .ctx(ctx)? + .write_all(xml.as_bytes()) + .ctx(ctx)?; + } + Ok(()) + } + + /** Log a protocol step failure */ + pub async fn log_failure(&self, ctx: &EbicsCtx<'_>, res: Response) -> Result<(), EbicsError> { + if let Some(root) = &self.dir { + let dir = Self::log_dir(root, ctx)?; + let path = dir.join(format!("{}failure.xml", Self::prefix(ctx))); + let fs = std::fs::File::create_new(path).ctx(ctx)?; + let mut w = BufWriter::new(fs); + writeln!(w, "{:?} {}", res.version(), res.status()).ctx(ctx)?; + for (k, v) in res.headers() { + writeln!(w, "{k}:{}", String::from_utf8_lossy(v.as_bytes())).ctx(ctx)?; + } + writeln!(w).ctx(ctx)?; + if let Ok(body) = res.bytes().await { + w.write_all(&body).ctx(ctx)?; + } + } + Ok(()) + } + + /** Log a protocol step [response] */ + pub fn log_response(&self, ctx: &EbicsCtx, xml: &[u8]) -> Result<(), EbicsError> { + if let Some(root) = &self.dir { + let dir = Self::log_dir(root, ctx)?; + let path = dir.join(format!("{}response.xml", Self::prefix(ctx))); + std::fs::File::create_new(path) + .ctx(ctx)? + .write_all(xml) + .ctx(ctx)?; + } + Ok(()) + } +} diff --git a/src/ebics/mod.rs b/src/ebics/mod.rs @@ -17,7 +17,519 @@ * <http://www.gnu.org/licenses/> */ +use std::{borrow::Cow, io::Write as _}; + +use aws_lc_rs::{digest::Digest, rsa::PrivateDecryptingKey}; +use base64::{Engine, prelude::BASE64_STANDARD}; +use compact_str::CompactString; +use flate2::write::ZlibDecoder; +use jiff::Timestamp; +use reqwest::{ + Client, StatusCode, + header::{CONTENT_TYPE, HeaderValue}, +}; +use sqlx::PgPool; +use tracing::{debug, info, trace}; + +use crate::{ + config::EbicsHostCfg, + crypto::{ + decrypt_ebics_e002, decrypt_ebics_e002_key, digest_ebics_order_a006, encrypt_ebics_e002, + gen_ebics_e002_key, sign_ebics_a006, + }, + db::{ebics_first, ebics_register, ebics_remove}, + ebics::{ + administrative::{HAA, VersionNumber, hev_msg, parse_haa, parse_hev}, + bts::{ + BTSResponse, DataEncryptionInfo, download_init, download_receipt, download_transfer, + parse_bts, upload_init, upload_transfer, + }, + ebics_code::EbicsReturnCode, + logger::EbicsLogger, + order::Order, + }, + keys::{BankPubKeysFile, ClientPriKeysFile}, + utils::{b64, deflate}, + xml, +}; + pub mod administrative; +pub mod bts; pub mod ebics_code; pub mod key_management; +pub mod logger; pub mod order; + +#[derive(Debug, Clone, Copy)] +pub enum Phase { + Interrupt, + Init, + Transfer(usize), + Process, + Receipt, +} + +impl std::fmt::Display for Phase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Phase::Interrupt => f.write_str("interrupt"), + Phase::Init => f.write_str("init"), + Phase::Transfer(i) => write!(f, "transfer{i}"), + Phase::Process => f.write_str("process"), + Phase::Receipt => f.write_str("receipt"), + } + } +} + +#[derive(Debug)] +pub struct EbicsCtx<'a> { + pub now: Timestamp, + pub order: Cow<'a, Order>, + pub phase: Option<Phase>, +} + +impl<'a> EbicsCtx<'a> { + pub fn new(order: &'a Order) -> Self { + Self { + now: Timestamp::now(), + order: Cow::Borrowed(order), + phase: None, + } + } + + pub fn with_phase(self, phase: Phase) -> Self { + Self { + phase: Some(phase), + ..self + } + } +} + +impl std::fmt::Display for EbicsCtx<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { order, phase, .. } = self; + write!(f, "{order}")?; + if let Some(phase) = phase { + write!(f, " {phase}")?; + } + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub struct EbicsError { + pub ctx: Box<EbicsCtx<'static>>, + pub kind: EbicsErrKind, +} + +impl std::fmt::Display for EbicsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { ctx, kind } = self; + write!(f, "{ctx} {kind}") + } +} + +fn fmt_code( + f: &mut std::fmt::Formatter<'_>, + technical: &EbicsReturnCode, + bank: &EbicsReturnCode, +) -> std::fmt::Result { + if technical.is_error() { + write!(f, "technical error: {technical}") + } else { + write!(f, "technical error: {bank}") + } +} + +pub trait EbicsErrorHelper<T> { + fn ctx(self, ctx: &EbicsCtx<'_>) -> Result<T, EbicsError>; +} + +impl<T, E: Into<EbicsErrKind>> EbicsErrorHelper<T> for Result<T, E> { + fn ctx(self, ctx: &EbicsCtx<'_>) -> Result<T, EbicsError> { + self.map_err(|e| e.into().ctx(ctx)) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum EbicsErrKind { + #[error(transparent)] + Network(#[from] reqwest::Error), + + #[error(transparent)] + IO(#[from] std::io::Error), + + #[error("ebics HTTP error {0}")] + HTTP(StatusCode), + + #[error(transparent)] + XML(#[from] xml::Error), + + #[error("{}", std::fmt::from_fn(|f| fmt_code(f, technical, bank)))] + Code { + technical: EbicsReturnCode, + bank: EbicsReturnCode, + }, + + #[error(transparent)] + Db(#[from] sqlx::Error), + + #[error(transparent)] + Zip(#[from] zip::result::ZipError), + + #[error("{0}")] + Custom(Cow<'static, str>), +} + +impl EbicsErrKind { + pub fn ctx(self, ctx: &EbicsCtx<'_>) -> EbicsError { + EbicsError { + ctx: Box::new(EbicsCtx { + now: ctx.now, + phase: ctx.phase, + order: Cow::Owned(ctx.order.as_ref().clone()), + }), + kind: self, + } + } +} + +async fn post_to_bank( + http: &Client, + url: &str, + xml: String, + ctx: &EbicsCtx<'_>, + logger: &EbicsLogger, +) -> Result<Vec<u8>, EbicsError> { + logger.log_request(ctx, &xml)?; + let res = http + .post(url) + .header(CONTENT_TYPE, HeaderValue::from_static("application/xml")) + .body(xml) + .send() + .await + .ctx(ctx)?; + let status = res.status(); + if status != StatusCode::OK { + logger.log_failure(ctx, res).await?; + return Err(EbicsErrKind::HTTP(status).ctx(ctx)); + } + let xml = res.bytes().await.ctx(ctx)?; + logger.log_response(ctx, &xml)?; + Ok(xml.into()) +} + +pub struct EbicsResponse<T> { + pub technical_code: EbicsReturnCode, + pub bank_code: EbicsReturnCode, + pub content: T, +} + +impl<T> EbicsResponse<T> { + fn ok_or_fail(self) -> Result<T, EbicsErrKind> { + if self.technical_code.is_error() || self.bank_code.is_error() { + Err(EbicsErrKind::Code { + technical: self.technical_code, + bank: self.bank_code, + }) + } else { + Ok(self.content) + } + } +} + +/** POST an EBICS BTS request [xmlReq] using [client] returning a validated and parsed XML response */ +async fn post_bts( + cfg: &EbicsHostCfg, + http: &Client, + xml: String, + ctx: &EbicsCtx<'_>, + logger: &EbicsLogger, +) -> Result<BTSResponse, EbicsError> { + let xml = post_to_bank(http, cfg.base_url.as_str(), xml, ctx, logger).await?; + // TODO verify ebics + let res = parse_bts(&xml).ctx(ctx)?; + // TODO phase in logs ? + trace!(target: "ebics", + "{ctx}{}: {} {}", + std::fmt::from_fn(|f| { + if let Some(tx_id) = &res.content.tx_id { + write!(f, " {tx_id}") + } else { + Ok(()) + } + }), + res.technical_code, + res.bank_code + ); + res.ok_or_fail().ctx(ctx) +} + +pub async fn hev( + http: &Client, + cfg: &EbicsHostCfg, + ebics_log: &EbicsLogger, +) -> Result<Box<[VersionNumber]>, EbicsError> { + let order = Order::HEV; + info!(target: "ebics", "Doing administrative request {order}"); + let msg = hev_msg(cfg); + let ctx = EbicsCtx::new(&order); + let res = post_to_bank(http, cfg.base_url.as_str(), msg, &ctx, ebics_log).await?; + parse_hev(&res).ctx(&ctx)?.ok_or_fail().ctx(&ctx) +} + +pub struct PreparedUploadData { + pub encrypted_key: Vec<u8>, + pub signature_data: String, + pub digest: Digest, + pub payload: String, +} + +impl PreparedUploadData { + const CHUNK_SIZE: usize = 1000000; + + pub fn nb_segments(&self) -> usize { + self.payload.len().div_ceil(Self::CHUNK_SIZE) + } + + pub fn segment(&self, nb: usize) -> &str { + let start = (nb - 1) * Self::CHUNK_SIZE; + let end = (start + Self::CHUNK_SIZE).min(self.payload.len()); + &self.payload[start..end] + } +} + +/** Decrypts and decompresses EBICS BTS payload */ +pub fn decrypt_and_decompress_payload( + client_encryption_key: &PrivateDecryptingKey, + encryption_info: DataEncryptionInfo, + segments: Vec<Vec<u8>>, +) -> Vec<u8> { + // TODO check bank_pub_digest + let tx_key = decrypt_ebics_e002_key(client_encryption_key.clone(), &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() +} + +/** + * Performs an EBICS download transaction of [order] between [startDate] and [endDate]. + * Download content is passed to [processing] + * + * It conducts init -> transfer -> processing -> receipt phases. + * + * Cancellations and failures are handled. + */ +pub async fn download<T>( + cfg: &EbicsHostCfg, + http: &Client, + db: &PgPool, + ebics_log: &EbicsLogger, + client: &ClientPriKeysFile, + bank: &BankPubKeysFile, + order: &Order, + range: &Option<(Timestamp, Timestamp)>, + peek: bool, + processing: impl AsyncFnOnce(Vec<u8>) -> Result<T, EbicsErrKind>, +) -> Result<T, EbicsError> { + let mut ctx = EbicsCtx::new(order); + debug!(target: "ebics", "Downloading order {order} {}", std::fmt::from_fn(|f| { + if let Some((start, end)) = range { + write!(f, " from {start} to {end}")? + } + Ok(()) + })); + + // Close interrupted + ctx = ctx.with_phase(Phase::Interrupt); + while let Some(tx_id) = ebics_first(db).await.ctx(&ctx)? { + let xml = download_receipt(cfg, client, order, &tx_id, false); + if let Err(e) = post_bts(cfg, http, xml, &ctx, ebics_log).await { + if !matches!( + e.kind, + // Transaction already closed or expired - EBICS protocol error + EbicsErrKind::Code { + technical: EbicsReturnCode::EBICS_TX_UNKNOWN_TXID, + .. + } | + // Transaction already closed or expired - HTTP protocol error for non compliant banks + EbicsErrKind::HTTP(StatusCode::BAD_REQUEST) + ) { + return Err(e); + } else { + debug!(target: "ebics", "{e}") + } + } + ebics_remove(db, &tx_id).await.ctx(&ctx)?; + } + + // Init phase + ctx = ctx.with_phase(Phase::Init); + let xml = download_init(cfg, bank, client, order, range); + let BTSResponse { + tx_id, + nb_segments, + segment, + data_encryption_info, + .. + } = post_bts(cfg, http, xml, &ctx, ebics_log).await?; + // TODO DAO add + let (tx_id, nb_segments, segment, encr_info) = ( + tx_id + .ok_or_else(|| EbicsErrKind::Custom("missing transaction ID".into())) + .ctx(&ctx)?, + nb_segments + .ok_or_else(|| EbicsErrKind::Custom("missing num segments".into())) + .ctx(&ctx)?, + segment + .ok_or_else(|| EbicsErrKind::Custom("missing OrderData".into())) + .ctx(&ctx)?, + data_encryption_info + .ok_or_else(|| EbicsErrKind::Custom("missing EncryptionInfo".into())) + .ctx(&ctx)?, + ); + ebics_register(db, &tx_id).await.ctx(&ctx)?; + + // Transfer phase + let mut segments = vec![segment]; + for segment_nb in 2..=nb_segments { + ctx = ctx.with_phase(Phase::Transfer(segment_nb)); + let xml = download_transfer(cfg, client, order, nb_segments, segment_nb, &tx_id); + let BTSResponse { segment, .. } = post_bts(cfg, http, xml, &ctx, ebics_log).await?; + segments.push(segment.unwrap()); // TODO error + } + + // Processing phase + ctx = ctx.with_phase(Phase::Process); + let payload = decrypt_and_decompress_payload(&client.enc, encr_info, segments); + ebics_log.log_payload(&ctx, &payload, order.file_type())?; + let res = processing(payload).await.ctx(&ctx); + + // Receipt phase + ctx = ctx.with_phase(Phase::Receipt); + let xml = download_receipt(cfg, client, order, &tx_id, res.is_ok() && !peek); + if post_bts(cfg, http, xml, &ctx, ebics_log).await.is_ok() { + ebics_remove(db, &tx_id).await.ok(); + } + + res +} + +/** Signs, encrypts and format EBICS BTS payload */ +fn prepare_upload_payload( + cfg: &EbicsHostCfg, + client: &ClientPriKeysFile, + bank: &BankPubKeysFile, + payload: &str, +) -> PreparedUploadData { + let digest = digest_ebics_order_a006(payload.as_bytes()); + + // Generate ephemeral transaction key + let (tx_key, encrypted_key) = gen_ebics_e002_key(bank.enc.enc.clone()); + + // Compress and encrypt order signature + let signature_data = { + let signed = sign_ebics_a006(digest.as_ref(), &client.sign); + let inner_signed_xml = xml!( + "UserSignatureData" ("xmlns": "http://www.ebics.org/S002") { + "OrderSignatureData" { + "SignatureVersion": "A006", + "SignatureValue": b64(&signed), + "PartnerID": cfg.partner_id, + "UserID": cfg.user_id + } + } + ); + let deflated = deflate(inner_signed_xml.as_bytes()); + let encrypted = encrypt_ebics_e002(&tx_key, &deflated); + BASE64_STANDARD.encode(encrypted) + }; + + // Compress and encrypt payload + let payload = { + let deflated = deflate(payload.as_bytes()); + let encrypted = encrypt_ebics_e002(&tx_key, &deflated); + BASE64_STANDARD.encode(encrypted) + }; + PreparedUploadData { + encrypted_key, + signature_data, + digest, + payload, + } +} + +/** + * Performs an EBICS upload transaction of [order] using [payload]. + * + * It conducts init -> upload phases. + * + * Returns upload orderID + */ +pub async fn upload( + cfg: &EbicsHostCfg, + http: &Client, + ebics_log: &EbicsLogger, + client: &ClientPriKeysFile, + bank: &BankPubKeysFile, + order: &Order, + payload: &str, +) -> Result<CompactString, EbicsError> { + debug!(target: "ebics", "Uploading order {order}"); + let mut ctx = EbicsCtx::new(order); + + ebics_log.log_payload(&ctx, payload.as_bytes(), "xml")?; + let payload = prepare_upload_payload(cfg, client, bank, payload); + + // Init phase + ctx = ctx.with_phase(Phase::Init); + let xml = upload_init(cfg, bank, client, order, &payload); + let BTSResponse { + tx_id, order_id, .. + } = post_bts(cfg, http, xml, &ctx, ebics_log).await?; + + let (tx_id, order_id) = ( + tx_id + .ok_or_else(|| EbicsErrKind::Custom("missing transaction ID".into())) + .ctx(&ctx)?, + order_id + .ok_or_else(|| EbicsErrKind::Custom("missing order ID".into())) + .ctx(&ctx)?, + ); + + // Transfer phase + for segment_nb in 1..=payload.nb_segments() { + ctx = ctx.with_phase(Phase::Transfer(segment_nb)); + let xml = upload_transfer(cfg, client, order, &tx_id, &payload, segment_nb); + post_bts(cfg, http, xml, &ctx, ebics_log).await?; + } + + Ok(order_id) +} + +pub async fn haa( + cfg: &EbicsHostCfg, + http: &Client, + db: &PgPool, + ebics_log: &EbicsLogger, + client: &ClientPriKeysFile, + bank: &BankPubKeysFile, + peek: bool, +) -> Result<HAA, EbicsError> { + download( + cfg, + http, + db, + ebics_log, + client, + bank, + &Order::HAA, + &None, + peek, + async |content| Ok(parse_haa(&content)?), + ) + .await +} diff --git a/src/ebics/order.rs b/src/ebics/order.rs @@ -20,108 +20,104 @@ use compact_str::CompactString; use taler_enum_meta::EnumMeta; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Download, + Upload, +} + +#[derive(Debug, Clone)] +pub struct Service { + pub name: CompactString, + pub scope: Option<CompactString>, + pub option: Option<CompactString>, + pub container: Option<CompactString>, + pub msg: CompactString, + pub version: Option<CompactString>, +} + +impl PartialEq for Service { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + && self.scope == other.scope + && self.option == other.option + && self.container == other.container + && self.msg == other.msg + // Ignore msg version + } +} + +#[derive(Debug, Clone, PartialEq)] 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>, - }, + /// Download of a file identified by a BTF structure (Mandatory) + BTD(Service), + /// Upload of a file identified by a BTF structure (Mandatory) + BTU(Service), + /// Download retrievable order types (Optional) + HAA, + /// Download customer acknowledgment (Mandatory) + HAC, + /// Send amendment of the subscriber key for identification and authentication and encryption (Mandatory) + HCA, + /// Transmission of the subscriber key for ES identification and authentication and encryption (Mandatory) + HCS, + /// Download supported EBICS versions (Mandatory) + HEV, + /// Transmission of the subscriber key for identification and authentication and encryption within the framework of subscriber initialization (Mandatory) + HIA, + /// Download customer’s customer and subscriber data (Optional) + HKD, + /// Transfer the public bank key (Mandatory) + HPB, + /// Download bank parameters (Mandatory) + HPD, + /// Download subscriber’s customer and subscriber data (Mandatory) + HTD, + /// Download subscriber’s customer and subscriber data (Optional) + HVD, + /// Add EDSsignature (Mandatory) + HVE, + /// Cancellation of orders in the EDS (Mandatory) + HVS, + /// Retrieve EDS transaction details (Mandatory) + HVT, + /// Download EDS overview (Mandatory) + HVU, + /// Download EDS overview with additional informations (Mandatory) + HVZ, + /// Transmission of all public keys (subscriber key, key for identification and authentication and key for encryption) for initialisation in case of CA-issued certificates (Optional) + H3K, + /// Send password initialization + INI, + /// Send public key for signature verification + PUB, + /// Suspension of access authorisation + SPR, + /// deprecated + PTK, } impl Order { - pub const WSS_PARAMS: Self = Self::V3 { - ty: CompactString::const_new("BTD"), - service: Some(CompactString::const_new("OTH")), + pub const WSS_PARAMS: Self = Self::BTD(Service { + name: CompactString::const_new("OTH"), scope: Some(CompactString::const_new("DE")), - message: Some(CompactString::const_new("wssparam")), + msg: 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, - }, + Self::HAC => Some(OrderDoc::acknowledgement), + Self::BTD(Service { msg, .. }) => match msg.as_str() { + "pain.002" => Some(OrderDoc::status), + "camt.052" => Some(OrderDoc::report), + "camt.053" => Some(OrderDoc::statement), + "camt.054" => Some(OrderDoc::notification), _ => None, }, + _ => None, } } @@ -139,46 +135,110 @@ impl Order { /** Check if EBICS order is an uploadable one */ pub fn is_upload(&self) -> bool { - matches!(self, Self::V3 { ty, .. } if ty == "BTU") + matches!(self, Self::BTU { .. }) + } + + pub fn schema(&self) -> &'static str { + "H005" } - /** 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 + pub fn file_type(&self) -> &str { + match self { + Order::BTD(Service { container, .. }) | Order::BTU(Service { container, .. }) => { + container.as_deref().unwrap_or("xml") } - _ => false, + _ => "xml", + } + } + + pub fn ty(&self) -> &'static str { + match self { + Order::BTD { .. } => "BTD", + Order::BTU { .. } => "BTU", + Order::HAA => "HAA", + Order::HAC => "HAC", + Order::HCA => "HCA", + Order::HCS => "HCS", + Order::HEV => "HEV", + Order::HIA => "HIA", + Order::HKD => "HKD", + Order::HPB => "HPB", + Order::HPD => "HPD", + Order::HTD => "HTD", + Order::HVD => "HVD", + Order::HVE => "HVE", + Order::HVS => "HVS", + Order::HVT => "HVT", + Order::HVU => "HVU", + Order::HVZ => "HVZ", + Order::H3K => "H3K", + Order::INI => "INI", + Order::PUB => "PUB", + Order::SPR => "SPR", + Order::PTK => "PTK", + } + } + + pub fn from_parts(ty: &str, service: Option<Service>) -> Option<Self> { + match (ty, service) { + ("BTU", Some(service)) => Some(Self::BTU(service)), + ("BTD", Some(service)) => Some(Self::BTD(service)), + ("HAA", None) => Some(Self::HAA), + ("HAC", None) => Some(Self::HAC), + ("HCA", None) => Some(Self::HCA), + ("HCS", None) => Some(Self::HCS), + ("HEV", None) => Some(Self::HEV), + ("HIA", None) => Some(Self::HIA), + ("HKD", None) => Some(Self::HKD), + ("HPB", None) => Some(Self::HPB), + ("HPD", None) => Some(Self::HPD), + ("HTD", None) => Some(Self::HTD), + ("HVD", None) => Some(Self::HVD), + ("HVE", None) => Some(Self::HVE), + ("HVS", None) => Some(Self::HVS), + ("HVT", None) => Some(Self::HVT), + ("HVU", None) => Some(Self::HVU), + ("HVZ", None) => Some(Self::HVZ), + ("H3K", None) => Some(Self::H3K), + ("INI", None) => Some(Self::INI), + ("PUB", None) => Some(Self::PUB), + ("SPR", None) => Some(Self::SPR), + ("PTK", None) => Some(Self::PTK), + _ => None, } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] +impl std::fmt::Display for Order { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.ty())?; + match self { + Order::BTD(service) | Order::BTU(service) => { + let Service { + name, + scope, + option, + container, + msg, + version, + } = service; + write!(f, "-{name}")?; + for part in [scope, container, option].into_iter().flatten() { + write!(f, "-{part}")?; + } + write!(f, "-{msg}")?; + if let Some(version) = version { + write!(f, ".{version}")?; + } + } + _ => {} + } + + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta, PartialOrd, Ord)] #[enum_meta(Str, Description)] #[allow(non_camel_case_types)] pub enum OrderDoc { @@ -186,12 +246,12 @@ pub enum OrderDoc { 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, + /// Account statements - BankToCustomerStatement camt.053 + statement, + /// Account intraday reports - BankToCustomerAccountReport camt.052 + report, } impl OrderDoc { diff --git a/src/iso20022/camt.rs b/src/iso20022/camt.rs @@ -54,6 +54,15 @@ pub enum AccountId { Other(CompactString), } +impl std::fmt::Display for AccountId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AccountId::Iban(iban) => iban.fmt(f), + AccountId::Other(id) => id.fmt(f), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct AccountTransactions { pub id: AccountId, @@ -339,7 +348,7 @@ fn bank_tx_code(n: Xml) -> xml::Result<BankTxCode> { } /** Parse camt files */ -pub fn parse_camt(xml: &str) -> xml::Result<Vec<AccountTransactions>> { +pub fn parse_camt(xml: &[u8]) -> xml::Result<Vec<AccountTransactions>> { /* In ISO 20022 specifications, most fields are optional and the same information can be written several times in different places. For libeufin, we're only @@ -510,7 +519,7 @@ pub fn parse_camt(xml: &str) -> xml::Result<Vec<AccountTransactions>> { Ok(AccountTransactions { id, currency, txs }) } - Xml::parse_str(xml, "Document", |root| { + Xml::parse(xml, "Document", |root| { if let Some(camt053) = root.opt("BkToCstmrStmt")? { camt053.many("Stmt").map(parse_inner).collect() } else if let Some(camt052) = root.opt("BkToCstmrAcctRpt")? { @@ -553,7 +562,7 @@ pub mod test { } pub fn check_tx(path: &str, iban: &str, currency: Option<&str>, txs: &[Tx]) { - let content = std::fs::read_to_string(path).unwrap(); + let content = std::fs::read(path).unwrap(); let res = parse_camt(&content).unwrap(); assert_eq!(res.len(), 1); diff --git a/src/iso20022/hac.rs b/src/iso20022/hac.rs @@ -72,8 +72,8 @@ impl Display for CustomerAck { } } /** Parse HAC pain.002 XML file */ -pub fn parse_hac(xml: &str) -> xml::Result<Vec<CustomerAck>> { - Xml::parse_str(xml, "Document", |root| { +pub fn parse_hac(xml: &[u8]) -> xml::Result<Vec<CustomerAck>> { + Xml::parse(xml, "Document", |root| { root.one("CstmrPmtStsRpt")? .many("OrgnlPmtInfAndSts") .map(|n| { @@ -133,8 +133,7 @@ mod test { } } pretty_assertions::assert_eq!( - parse_hac(&std::fs::read_to_string("libeufin-nexus/sample/platform/hac.xml").unwrap()) - .unwrap(), + parse_hac(&std::fs::read("libeufin-nexus/sample/platform/hac.xml").unwrap()).unwrap(), [ ack( HacAction::FILE_DOWNLOAD, diff --git a/src/iso20022/mod.rs b/src/iso20022/mod.rs @@ -98,7 +98,7 @@ pub mod test { } } for (path, name) in samples { - let xml = std::fs::read_to_string(&path).unwrap(); + let xml = std::fs::read(&path).unwrap(); let name = name.to_string_lossy(); info!("Parse sample {path:?}"); @@ -156,7 +156,7 @@ pub mod test { } } for path in logs { - let xml = std::fs::read_to_string(&path).unwrap(); + let xml = std::fs::read(&path).unwrap(); let path = path.to_string_lossy(); info!("Parse sample {path:?}"); diff --git a/src/iso20022/pain001.rs b/src/iso20022/pain001.rs @@ -17,8 +17,6 @@ * <http://www.gnu.org/licenses/> */ -use anyhow::bail; -use compact_str::CompactString; use jiff::{Timestamp, Zoned, tz::TimeZone}; use taler_common::types::{ amount::{Amount, Decimal}, @@ -27,31 +25,34 @@ use taler_common::types::{ use crate::{ dialect::{Dialect, Standard}, + ebics::EbicsErrKind, + xml, xml::XmlWriter, - xml_build, xml_el, }; /** pain.001 transaction metadata */ -pub struct Pain001Tx { +pub struct Pain001Tx<'a> { pub creditor: FullIbanPayto, pub amount: Amount, - pub subject: Box<str>, - pub e2e_id: CompactString, + pub subject: &'a str, + pub e2e_id: &'a str, } /** pain.001 message metadata */ -pub struct Pain001Msg { - pub msg_id: CompactString, - pub timestamp: Timestamp, - pub debtor: FullIbanPayto, +pub struct Pain001Msg<'a> { + pub msg_id: &'a str, + pub timestamp: &'a Timestamp, + pub debtor: &'a FullIbanPayto, pub sum: Amount, - pub txs: Box<[Pain001Tx]>, + pub txs: Vec<Pain001Tx<'a>>, } /** Check EBICS compability of an amount */ -fn ebics_amount(amount: Amount) -> anyhow::Result<Decimal> { +fn ebics_amount(amount: &Amount) -> Result<Decimal, EbicsErrKind> { if amount.is_sub_cent() { - bail!("Sub-cent amounts not supported") + return Err(EbicsErrKind::Custom( + "Sub-cent amounts not supported".into(), + )); } Ok(amount.decimal()) } @@ -61,14 +62,14 @@ pub fn create_pain001( msg: &Pain001Msg, dialect: &Dialect, instant: bool, -) -> anyhow::Result<String> { +) -> Result<String, EbicsErrKind> { let version = "09"; let suffix = match dialect.standard() { Standard::SIX => ".ch.03", Standard::GBIC => "", }; - let total = ebics_amount(msg.sum)?; - Ok(xml_build!( + let total = ebics_amount(&msg.sum)?; + Ok(xml!( "Document" ("xmlns": (format_args!("urn:iso:std:iso:20022:tech:xsd:pain.001.001.{version}"))) ("xmlns:xsi": (format_args!("http://www.w3.org/2001/XMLSchema-instance"))) @@ -98,24 +99,20 @@ pub fn create_pain001( "BtchBookg": "false", "NbOfTxs": msg.txs.len(), "CtrlSum": total, - @ |w: &mut XmlWriter| { - if dialect.standard() == Standard::GBIC { - xml_el!(w, "PmtTpInf" { - "SvcLvl" { - "Cd": "SEPA" - }, - @ |w: &mut XmlWriter| { - if instant { - xml_el!(w, "LclInstrm" { - "Cd": "INST" - }); - } - } - }); - } + @ |w: &mut XmlWriter| if dialect.standard() == Standard::GBIC { + xml!(w, "PmtTpInf" { + "SvcLvl" { + "Cd": "SEPA" + }, + @ |w: &mut XmlWriter| if instant { + xml!(w, "LclInstrm" { + "Cd": "INST" + }) + } + }) }, "ReqdExctnDt" { - "Dt": Zoned::new(msg.timestamp, TimeZone::UTC).date().to_string() + "Z" + "Dt": Zoned::new(*msg.timestamp, TimeZone::UTC).date().to_string() + "Z" }, "Dbtr" { "Nm": msg.debtor.name @@ -127,55 +124,51 @@ pub fn create_pain001( }, "DbtrAgt" { "FinInstnId" { - @ |w: &mut XmlWriter| { - if let Some(bic) = &msg.debtor.bic { - xml_el!(w, "BICFI": bic); - } else { - xml_el!(w, "Othr" {"Id": "NOTPROVIDED"}); - } + @ |w: &mut XmlWriter| if let Some(bic) = &msg.debtor.bic { + xml!(w, "BICFI": bic) + } else { + xml!(w, "Othr" { + "Id": "NOTPROVIDED" + }) } } }, "ChrgBr": "SLEV", - @ |w: &mut XmlWriter| { - for tx in &msg.txs { - xml_el!(w, "CdtTrfTxInf" { - "PmtId" { - "InstrId": tx.e2e_id, - // Used to uniquely identify transactions in other files - "EndToEndId": tx.e2e_id - }, - "Amt" { - "InstdAmt" ("Ccy": (tx.amount.currency)): ebics_amount(tx.amount).unwrap() - }, - @ |w: &mut XmlWriter| { - if let Some(bic) = &tx.creditor.bic { - xml_el!(w, "CdtrAgt" { - "FinInstnId" { - "BICFI": bic - } - }); - } - }, - "Cdtr" { - "Nm": tx.creditor.name - // Addr might become a requirement in the future - /*"PstlAdr" { - "TwnNm": "Bochum", - "Ctry": "DE" - }*/ - }, - "CdtrAcct" { - "Id" { - "IBAN": tx.creditor.iban + @ |w: &mut XmlWriter| for tx in &msg.txs { + xml!(w, "CdtTrfTxInf" { + "PmtId" { + "InstrId": tx.e2e_id, + // Used to uniquely identify transactions in other files + "EndToEndId": tx.e2e_id + }, + "Amt" { + "InstdAmt" ("Ccy": (tx.amount.currency)): ebics_amount(&tx.amount).unwrap() + }, + @ |w: &mut XmlWriter| if let Some(bic) = &tx.creditor.bic { + xml!(w, "CdtrAgt" { + "FinInstnId" { + "BICFI": bic } - }, - "RmtInf" { - "Ustrd": tx.subject + }) + }, + "Cdtr" { + "Nm": tx.creditor.name + // Addr might become a requirement in the future + /*"PstlAdr" { + "TwnNm": "Bochum", + "Ctry": "DE" + }*/ + }, + "CdtrAcct" { + "Id" { + "IBAN": tx.creditor.iban } - }); - } + }, + "RmtInf" { + "Ustrd": tx.subject + } + }) } } } @@ -210,8 +203,8 @@ mod test { let msg = Pain001Msg { msg_id: "MESSAGE_ID".into(), - timestamp: date_to_timestamp("2024-09-09"), - debtor: FullIbanPayto::new( + timestamp: &date_to_timestamp("2024-09-09"), + debtor: &FullIbanPayto::new( BankID { iban: "CH7789144474425692816".parse().expect("invalid IBAN"), bic: Some("AAAABBCC123".parse().expect("invalid BIC")), @@ -219,26 +212,26 @@ mod test { "myname", ), sum: amount("CHF:47.32"), - txs: Box::new([ + txs: vec![ Pain001Tx { creditor: creditor.clone(), amount: amount("CHF:42"), - subject: "Test 42".into(), - e2e_id: "TX_FIRST".into(), + subject: "Test 42", + e2e_id: "TX_FIRST", }, Pain001Tx { creditor: creditor.clone(), amount: amount("CHF:5.11"), subject: "Test 5.11".into(), - e2e_id: "TX_SECOND".into(), + e2e_id: "TX_SECOND", }, Pain001Tx { creditor: creditor, amount: amount("CHF:0.21"), - subject: "Test 0.21".into(), - e2e_id: "TX_THIRD".into(), + subject: "Test 0.21", + e2e_id: "TX_THIRD", }, - ]), + ], }; for dialect in Dialect::entries { pretty_assertions::assert_eq!( diff --git a/src/iso20022/pain002.rs b/src/iso20022/pain002.rs @@ -17,7 +17,7 @@ * <http://www.gnu.org/licenses/> */ -use std::fmt::{Display, Write}; +use std::fmt::{Display, Formatter, Write, from_fn}; use compact_str::CompactString; @@ -27,7 +27,7 @@ use crate::{ }; fn fmt_msg( - f: &mut std::fmt::Formatter<'_>, + f: &mut Formatter<'_>, code: Option<&str>, description: Option<&str>, reasons: &[Reason], @@ -74,8 +74,8 @@ pub struct TxStatus { pub reasons: Box<[Reason]>, } -impl Display for TxStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl TxStatus { + fn fmt_msg(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fmt_msg( f, Some(self.status.code()), @@ -83,6 +83,10 @@ impl Display for TxStatus { &self.reasons, ) } + + pub fn msg(&self) -> String { + format!("{}", from_fn(|f| self.fmt_msg(f))) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -93,8 +97,8 @@ pub struct PmtStatus { pub txs: Box<[TxStatus]>, } -impl Display for PmtStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl PmtStatus { + fn fmt_msg(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fmt_msg( f, self.status.map(|it| it.code()), @@ -102,6 +106,9 @@ impl Display for PmtStatus { &self.reasons, ) } + pub fn msg(&self) -> String { + format!("{}", from_fn(|f| self.fmt_msg(f))) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -112,8 +119,8 @@ pub struct MsgStatus { pub payments: Box<[PmtStatus]>, } -impl Display for MsgStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl MsgStatus { + fn fmt_msg(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fmt_msg( f, self.status.map(|it| it.code()), @@ -121,10 +128,29 @@ impl Display for MsgStatus { &self.reasons, ) } + pub fn msg(&self) -> String { + format!("{}", from_fn(|f| self.fmt_msg(f))) + } +} + +impl Display for MsgStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{} {}", self.id, from_fn(|f| self.fmt_msg(f)))?; + for p in &self.payments { + write!(f, "\n>{} {}", p.id, from_fn(|f| p.fmt_msg(f)))?; + for tx in &p.txs { + if tx.id != tx.e2e_id { + write!(f, "{} ", tx.id)?; + } + write!(f, "\n>>{} {}", tx.e2e_id, from_fn(|f| tx.fmt_msg(f)))?; + } + } + Ok(()) + } } /** Parse pain.002 XML file */ -pub fn parse_pain002(xml: &str) -> xml::Result<MsgStatus> { +pub fn parse_pain002(xml: &[u8]) -> xml::Result<MsgStatus> { fn reasons(x: Xml) -> xml::Result<Box<[Reason]>> { x.many("StsRsnInf") .map(|n| { @@ -135,7 +161,7 @@ pub fn parse_pain002(xml: &str) -> xml::Result<MsgStatus> { .collect() } - Xml::parse_str(xml, "Document", |root| { + Xml::parse(xml, "Document", |root| { let n = root.one("CstmrPmtStsRpt")?; let status = n.one("OrgnlGrpInfAndSts")?; Ok(MsgStatus { @@ -177,7 +203,7 @@ mod test { #[test] fn pain002() { pretty_assertions::assert_eq!( - parse_pain002(&std::fs::read_to_string("libeufin-nexus/sample/platform/pain002_part.xml").unwrap()).unwrap(), + parse_pain002(&std::fs::read("libeufin-nexus/sample/platform/pain002_part.xml").unwrap()).unwrap(), MsgStatus { id: "05BD4C5B4A2649B5B08F6EF6A31F197A".into(), status: Some(PaymentGroupStatus::PartiallyAccepted), @@ -231,8 +257,7 @@ mod test { ); pretty_assertions::assert_eq!( parse_pain002( - &std::fs::read_to_string("libeufin-nexus/sample/platform/pain002_accp.xml") - .unwrap() + &std::fs::read("libeufin-nexus/sample/platform/pain002_accp.xml").unwrap() ) .unwrap(), MsgStatus { diff --git a/src/keys.rs b/src/keys.rs @@ -21,8 +21,9 @@ use std::{borrow::Cow, io::ErrorKind, path::Path}; use anyhow::bail; use aws_lc_rs::{ - encoding::{AsDer, Pkcs8V1Der, PublicKeyX509Der}, - rsa::{KeySize, PrivateDecryptingKey, PublicEncryptingKey}, + encoding::{AsDer, Pkcs8V1Der}, + error::KeyRejected, + rsa::{KeySize, PrivateDecryptingKey, PublicEncryptingKey, PublicKey, PublicKeyComponents}, signature::RsaKeyPair, }; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -35,12 +36,24 @@ use crate::config::EbicsKeysCfg; #[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct ClientPriKeysFile { - #[serde(serialize_with = "ser_pkcs8", deserialize_with = "de_ras_sign_base32")] - pub signature_private_key: RsaKeyPair, - #[serde(serialize_with = "ser_pkcs8", deserialize_with = "de_ras_priv_base32")] - pub encryption_private_key: PrivateDecryptingKey, - #[serde(serialize_with = "ser_pkcs8", deserialize_with = "de_ras_sign_base32")] - pub authentication_private_key: RsaKeyPair, + #[serde( + rename = "signature_private_key", + serialize_with = "ser_pkcs8", + deserialize_with = "de_ras_sign_base32" + )] + pub sign: RsaKeyPair, + #[serde( + rename = "encryption_private_key", + serialize_with = "ser_pkcs8", + deserialize_with = "de_ras_priv_base32" + )] + pub enc: PrivateDecryptingKey, + #[serde( + rename = "authentication_private_key", + serialize_with = "ser_pkcs8", + deserialize_with = "de_ras_sign_base32" + )] + pub auth: RsaKeyPair, pub submitted_ini: bool, pub submitted_hia: bool, } @@ -48,21 +61,76 @@ pub struct ClientPriKeysFile { impl ClientPriKeysFile { pub fn generate() -> anyhow::Result<Self> { Ok(Self { - signature_private_key: RsaKeyPair::generate(KeySize::Rsa2048)?, - encryption_private_key: PrivateDecryptingKey::generate(KeySize::Rsa2048)?, - authentication_private_key: RsaKeyPair::generate(KeySize::Rsa2048)?, + sign: RsaKeyPair::generate(KeySize::Rsa2048)?, + enc: PrivateDecryptingKey::generate(KeySize::Rsa2048)?, + auth: RsaKeyPair::generate(KeySize::Rsa2048)?, submitted_ini: false, submitted_hia: false, }) } } +#[derive(Debug)] +pub struct RsaPub { + pub enc: PublicEncryptingKey, + pub key: PublicKey, +} + +impl RsaPub { + pub fn from_der(der: &[u8]) -> Result<Self, KeyRejected> { + let key = PublicKey::from_der(der)?; + let component = PublicKeyComponents { + n: key.modulus().big_endian_without_leading_zero(), + e: key.exponent().big_endian_without_leading_zero(), + }; + let enc = component.try_into().map_err(|_| KeyRejected::from(()))?; + Ok(Self { enc, key }) + } +} + +impl serde::Serialize for RsaPub { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + let der = self + .key + .as_der() + .map_err(|e| serde::ser::Error::custom(e.to_string()))?; + let base32 = base32::encode(der.as_ref()); + base32.serialize(serializer) + } +} + +impl<'de> serde::Deserialize<'de> for RsaPub { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let base32 = Cow::<str>::deserialize(deserializer)?; + let der = base32::decode(base32.as_bytes()) + .map_err(|e| serde::de::Error::custom(e.to_string()))?; + Self::from_der(&der).map_err(|e| serde::de::Error::custom(e.to_string())) + } +} + +impl PartialEq for RsaPub { + fn eq(&self, other: &Self) -> bool { + self.key.exponent().big_endian_without_leading_zero() + == other.key.exponent().big_endian_without_leading_zero() + && self.key.modulus().big_endian_without_leading_zero() + == other.key.modulus().big_endian_without_leading_zero() + } +} + +impl Eq for RsaPub {} + #[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct BankPubKeysFile { - #[serde(serialize_with = "ser_x509", deserialize_with = "de_ras_pub_base32")] - pub bank_encryption_public_key: PublicEncryptingKey, - #[serde(serialize_with = "ser_x509", deserialize_with = "de_ras_pub_base32")] - pub bank_authentication_public_key: PublicEncryptingKey, + #[serde(rename = "bank_encryption_public_key")] + pub enc: RsaPub, + #[serde(rename = "bank_authentication_public_key")] + pub auth: RsaPub, pub accepted: bool, } @@ -78,18 +146,6 @@ where base32.serialize(serializer) } -fn ser_x509<S, K>(key: &K, serializer: S) -> Result<S::Ok, S::Error> -where - K: AsDer<PublicKeyX509Der<'static>>, - S: Serializer, -{ - let der = key - .as_der() - .map_err(|e| serde::ser::Error::custom(e.to_string()))?; - let base32 = base32::encode(der.as_ref()); - base32.serialize(serializer) -} - fn de_ras_priv_base32<'de, D>(deserializer: D) -> Result<PrivateDecryptingKey, D::Error> where D: Deserializer<'de>, @@ -102,18 +158,6 @@ where Ok(key) } -fn de_ras_pub_base32<'de, D>(deserializer: D) -> Result<PublicEncryptingKey, D::Error> -where - D: Deserializer<'de>, -{ - let base32 = Cow::<str>::deserialize(deserializer)?; - let der = - base32::decode(base32.as_bytes()).map_err(|e| serde::de::Error::custom(e.to_string()))?; - let key = - PublicEncryptingKey::from_der(&der).map_err(|e| serde::de::Error::custom(e.to_string()))?; - Ok(key) -} - fn de_ras_sign_base32<'de, D>(deserializer: D) -> Result<RsaKeyPair, D::Error> where D: Deserializer<'de>, @@ -126,13 +170,13 @@ where } /// Persist the bank keys file to disk -pub fn persist_bank_keys(keys: &BankPubKeysFile, location: &Path) -> anyhow::Result<()> { +pub fn persist_bank_keys(keys: &BankPubKeysFile, location: &Path) -> std::io::Result<()> { json_file::persist(location, keys)?; // TODO better error message "bank public keys" Ok(()) } -pub fn persist_client_keys(keys: &ClientPriKeysFile, location: &Path) -> anyhow::Result<()> { +pub fn persist_client_keys(keys: &ClientPriKeysFile, location: &Path) -> std::io::Result<()> { json_file::persist(location, keys)?; // TODO better error message "client private keys" Ok(()) diff --git a/src/lib.rs b/src/lib.rs @@ -17,32 +17,69 @@ * <http://www.gnu.org/licenses/> */ -use std::path::Path; +use std::{ + collections::BTreeMap, + io::{Cursor, Read}, + path::Path, + str::FromStr, +}; -use anyhow::bail; -use compact_str::CompactString; +use anyhow::{anyhow, bail}; +use compact_str::{CompactString, CompactStringExt}; +use jiff::Timestamp; use rand::prelude::IndexedRandom; -use reqwest::{ - Client, StatusCode, - header::{CONTENT_TYPE, HeaderValue}, -}; +use reqwest::Client; +use sqlx::PgPool; use taler_build::long_version; -use taler_common::{CommonArgs, config::parser::ConfigSource}; -use tracing::{debug, info}; +use taler_common::{ + CommonArgs, + config::{Config, parser::ConfigSource}, + types::{ + amount::Amount, + payto::{FullIbanPayto, TransferIbanPayto}, + }, +}; +use tracing::{debug, error, info, trace, warn}; use crate::{ - common::EbicsLogger, - config::{EbicsHostCfg, NexusCfg}, + config::NexusCfg, + crypto::ebics_pub_key_hash, + db::{ + initiated::{ + batch_initiated, batch_status_update, batch_sub_failure, batch_sub_success, initiate, + initiated_submittable, order_failure, order_step, order_success, tx_status_update, + }, + pool, + }, ebics::{ - administrative::{VersionNumber, hev_msg, parse_hev}, + EbicsCtx, EbicsErrKind, EbicsError, EbicsErrorHelper, + administrative::VersionNumber, + download, ebics_code::EbicsReturnCode, - key_management::{Order, hpb, submit_client_keys}, + haa, hev, + key_management::{hpb, submit_client_keys}, + logger::EbicsLogger, + order::{Order, OrderDoc}, + upload, + }, + iso20022::{ + HacAction, + camt::{AccountId, parse_camt}, + hac::parse_hac, + pain001::{Pain001Msg, Pain001Tx, create_pain001}, + pain002::parse_pain002, + status_code::{PaymentGroupStatus, PaymentTransactionStatus}, }, - keys::{ClientPriKeysFile, load_bank_keys, load_client_keys, persist_client_keys}, + keys::{ + BankPubKeysFile, ClientPriKeysFile, expect_full_keys, load_bank_keys, load_client_keys, + persist_bank_keys, persist_client_keys, + }, + model::{InTx, OutTx, PaymentBatch, SubmissionState, Tx}, + utils::hex_chunk_by_two, + worker::register_tx, }; pub mod api; -pub mod common; pub mod config; pub mod crypto; pub mod db; @@ -51,7 +88,7 @@ pub mod ebics; pub mod iso20022; pub mod keys; pub mod model; -pub mod testbench; +pub mod utils; pub mod worker; pub mod xml; pub mod xml_sign; @@ -68,11 +105,32 @@ pub fn rand_ebics_id() -> CompactString { .collect() } +#[derive(clap::Subcommand, Debug)] +pub enum Cmd { + Setup {}, + EbicsFetch {}, + EbicsSubmit {}, + /// Initiate an outgoing payment + InitiatePayment { + /// The amount to transfer, payto 'amount' parameter takes the precedence + amount: Option<Amount>, + /// The payment subject, payto 'message' parameter takes the precedence + subject: Option<CompactString>, + /// The payment end-to-end UID + end_to_end_id: Option<CompactString>, + /// The credited account IBAN payto UR + payto: TransferIbanPayto, + }, +} + #[derive(clap::Parser, Debug)] #[command(long_version = long_version(), about, long_about = None)] pub struct Args { #[clap(flatten)] pub common: CommonArgs, + + #[command(subcommand)] + pub cmd: Cmd, } /** Load client private keys at or create new ones if missing */ @@ -95,9 +153,10 @@ pub fn load_or_generate_client_keys(path: &Path) -> anyhow::Result<ClientPriKeys pub async fn ebics_setup( http: &Client, cfg: &NexusCfg, + ebics_log: &EbicsLogger, force_keys_submissions: bool, + auto_accept_keys: bool, ) -> anyhow::Result<()> { - let logger = EbicsLogger {}; let keys_cfg = cfg.keys()?; let host_cfg = cfg.host()?; @@ -105,7 +164,7 @@ pub async fn ebics_setup( let bank = load_bank_keys(keys_cfg.bank_pub_keys_path.as_ref())?; // Check EBICS 3 support - let versions = hev(http, cfg.host()?).await?; + let versions = hev(http, cfg.host()?, ebics_log).await?; debug!(target: "setup", "HEV: {}", versions @@ -127,64 +186,473 @@ pub async fn ebics_setup( // Privs exist. Upload their pubs let keys_not_sub = !client.submitted_ini; if !client.submitted_ini || force_keys_submissions { - submit_client_keys(keys_cfg, host_cfg, &mut client, http, &logger, Order::INI).await?; + submit_client_keys(keys_cfg, host_cfg, &mut client, http, ebics_log, Order::INI).await?; } // Eject PDF if the keys were submitted for the first time, or the user asked. // TODO if (keysNotSub || generateRegistrationPdf) makePdf(clientKeys, hostCfg) if !client.submitted_hia || force_keys_submissions { - submit_client_keys(keys_cfg, host_cfg, &mut client, http, &logger, Order::HIA).await?; + submit_client_keys(keys_cfg, host_cfg, &mut client, http, ebics_log, Order::HIA).await?; + } + + let new = hpb(http, host_cfg, ebics_log, &client).await?; + if let Some(current) = bank { + // Check current bank keys + if current.enc != new.enc { + bail!( + "On disk bank encryption key stored at {} doesn't match server key\nDisk: {}\nServer: {}", + keys_cfg.bank_pub_keys_path, + hex_chunk_by_two(ebics_pub_key_hash(&current.enc.key)), + hex_chunk_by_two(ebics_pub_key_hash(&new.enc.key)) + ) + } else if current.auth != new.auth { + bail!( + "On disk bank authentication key stored at {} doesn't match server key\nDisk: {}\nServer: {}", + keys_cfg.bank_pub_keys_path, + hex_chunk_by_two(ebics_pub_key_hash(&current.auth.key)), + hex_chunk_by_two(ebics_pub_key_hash(&new.auth.key)) + ) + } + } else { + // Accept bank keys + info!("Bank keys stored at {}", keys_cfg.bank_pub_keys_path); + persist_bank_keys(&new, keys_cfg.bank_pub_keys_path.as_ref())?; + }; + let mut bank = new; + if !bank.accepted { + // Finishing the setup by accepting the bank keys. + if !auto_accept_keys { + panic!("Cannot successfully finish the setup without accepting the bank keys"); + } + bank.accepted = true; + persist_bank_keys(&bank, keys_cfg.bank_pub_keys_path.as_ref())?; } - let res = hpb(http, host_cfg, &logger, &client).await?; - dbg!(res); - // Fetch bank keys + // Check account information + info!("Doing administrative request HKD"); + // TODO HKD + + eprintln!("setup ready"); Ok(()) } -pub async fn hev(http: &Client, cfg: &EbicsHostCfg) -> anyhow::Result<Box<[VersionNumber]>> { - let phase = "HEV"; - info!(target: "ebics", "Doing administrative request {phase}"); - let msg = hev_msg(cfg); - let res = post_to_bank(cfg.base_url.as_str(), http, msg).await?; - parse_hev(&res)?.ok_or_fail(phase) -} +pub async fn ebics_submit( + cfg: &NexusCfg, + http: &Client, + client: &ClientPriKeysFile, + bank: &BankPubKeysFile, + ebics_log: &EbicsLogger, + db: &PgPool, + transient: bool, +) -> anyhow::Result<()> { + let ebics_cfg = cfg.ebics()?; + let host_cfg = cfg.host()?; + let submit_cfg = cfg.submit()?; -#[derive(Debug, thiserror::Error)] -pub enum EbicsError { - #[error(transparent)] - Network(#[from] reqwest::Error), -} + let submit_batch = async |order: &Order, + batch: &PaymentBatch, + instant: bool| + -> Result<CompactString, EbicsError> { + let ctx = EbicsCtx::new(order); + let msg = Pain001Msg { + msg_id: &batch.msg_id, + timestamp: &Timestamp::now(), + debtor: &ebics_cfg.account, + sum: batch.sum, + txs: batch + .payments + .iter() + .map(|tx| { + let creditor = FullIbanPayto::from_str(tx.creditor.as_ref().as_str()).unwrap(); + Pain001Tx { + creditor, + amount: tx.amount, + subject: &tx.subject, + e2e_id: &tx.e2e_id, + } + }) + .collect(), + }; + let xml = create_pain001(&msg, &ebics_cfg.dialect, instant).ctx(&ctx)?; + upload(host_cfg, http, ebics_log, client, bank, order, &xml).await + }; -async fn post_to_bank(url: &str, client: &Client, msg: String) -> anyhow::Result<String> { - let res = client - .post(url) - .header(CONTENT_TYPE, HeaderValue::from_static("application/xml")) - .body(msg) - .send() + let submit_all = async || -> anyhow::Result<()> { + let standard = cfg.ebics()?.dialect.standard(); + let mut instant_order = standard.instant_direct_debit(); + let debit_order = standard.direct_debit(); + + // Create batch if nescessary + batch_initiated( + db, + &Timestamp::now(), + &rand_ebics_id(), + submit_cfg.require_ack, + ) .await?; - let status = res.status(); - if status != StatusCode::OK { - bail!("bank http error {status}"); + for batch in initiated_submittable(db, &cfg.currency).await? { + debug!(target: "ebics-submit", "Submitting batch {}", batch.msg_id); + let res = async { + if let Some(instant) = standard.instant_direct_debit() { + match submit_batch(&instant, &batch, true).await { + Ok(id) => return Ok(id), + Err(e) => if let EbicsErrKind::Code { .. } = e.kind { + // No longer try to submit using the instant method for now + debug!(target: "ebics-submit", "Failed to submit using instant credit order {e}"); + instant_order = None; + } else { + return Err(e) + }, + } + } + submit_batch(&debit_order, &batch, false).await + }.await; + match res { + Ok(order_id) => { + batch_sub_success(db, batch.id, &Timestamp::now(), &order_id).await?; + let txs = batch + .payments + .iter() + .map(|it| &it.e2e_id) + .collect::<Vec<_>>() + .join_compact(","); + if instant_order.is_some() { + info!(target: "ebics-submit", "Instant batch {} submitted as order {order_id}: {txs}", batch.msg_id); + } else { + info!(target: "ebics-submit", "Batch {} submitted as order {order_id}: {txs}", batch.msg_id); + } + } + Err(e) => { + batch_sub_failure(db, batch.id, &Timestamp::now(), &e.to_string()).await?; + error!(target: "ebics-submit", "Batch {} submission failure: {e}", batch.msg_id); + return Err(e.into()); + } + } + } + + Ok(()) + }; + if transient { + debug!(target: "ebics-submit", "Transient mode: submitting what found and returning."); + submit_all().await + } else { + todo!() } - // Should parse xml here - let body = res.text().await?; - Ok(body) } -pub struct EbicsResponse<T> { - pub technical_code: EbicsReturnCode, - pub bank_code: EbicsReturnCode, - pub content: T, +async fn register_camt(db: &PgPool, cfg: &NexusCfg, xml: &[u8]) -> anyhow::Result<usize> { + let account = &cfg.ebics()?.account; + let ingest_cfg = cfg.ingest()?; + let mut nb_tx = 0; + for actx in parse_camt(xml)? { + if let AccountId::Iban(iban) = &actx.id + && iban == &account.iban + { + if let Some(currency) = actx.currency + && currency != cfg.currency + { + bail!( + "Expected transactions of currency {} got {currency}", + cfg.currency + ) + } + for tx in actx.txs { + match tx { + Tx::In(InTx { amount, .. }) | Tx::Out(OutTx { amount, .. }) => { + if amount.currency != cfg.currency { + bail!( + "Expected transactions of currency {} got {}", + cfg.currency, + amount.currency + ) + } + } + Tx::Batch(_) | Tx::Reversal(_) => {} + } + register_tx(db, &ingest_cfg, &tx).await?; + nb_tx += 1; + } + } else { + warn!(target: "ebics-fetch", "Skip transaction for unknown account {}", actx.id); + } + } + Ok(nb_tx) } -impl<T> EbicsResponse<T> { - fn ok_or_fail(self, phase: &str) -> anyhow::Result<T> { - if self.technical_code.is_error() { - bail!("{phase} has technical error: {:?}", self.technical_code) - } else if self.bank_code.is_error() { - bail!("{phase} has bank error: {:?}", self.bank_code) - } else { - Ok(self.content) +pub async fn ebics_fetch( + cfg: &NexusCfg, + http: &Client, + client: &ClientPriKeysFile, + bank: &BankPubKeysFile, + ebics_log: &EbicsLogger, + db: &PgPool, + documents: Option<&[OrderDoc]>, +) -> anyhow::Result<()> { + let ebics_cfg = cfg.ebics()?; + let host_cfg = cfg.host()?; + + let register_file = async |doc: &OrderDoc, xml: Vec<u8>| -> anyhow::Result<()> { + match doc { + OrderDoc::acknowledgement => { + for ack in parse_hac(&xml)? { + debug!(target: "ebics-fetch", "{ack}"); + if let Some(order_id) = &ack.order_id { + match ack.action { + HacAction::ORDER_HAC_FINAL_POS => { + if let Some(msg_id) = order_success(db, order_id).await? { + info!(target: "ebics-fetch", "Batch {msg_id} order {order_id} accepted at {}", ack.timestamp); + } + } + HacAction::ORDER_HAC_FINAL_NEG => { + if let Some((msg_id, msg)) = order_failure(db, order_id).await? { + info!(target: "ebics-fetch", "Batch {msg_id} order {order_id} refused at {}{}", ack.timestamp, std::fmt::from_fn( |f| if let Some(msg) = &msg { + write!(f, ": {msg}") + } else { + Ok(()) + })); + } + } + _ => { + order_step(db, order_id, &ack.to_string()).await?; + } + } + } + } + } + OrderDoc::status => { + let msg_status = parse_pain002(&xml)?; + debug!(target: "ebics-fetch", "{msg_status}"); + if let Some(code) = msg_status.status { + let msg = msg_status.msg(); + batch_status_update( + db, + &msg_status.id, + match code { + PaymentGroupStatus::AcceptedSettlementCompletedDebitorAccount => { + SubmissionState::success + } + PaymentGroupStatus::Rejected => { + error!(target: "ebics-fetch", "Batch {} failed: {msg}", msg_status.id); + SubmissionState::success + } + _ => SubmissionState::pending + }, + &msg, + ) + .await?; + } + for p_status in msg_status.payments { + if p_status.id != "NOTPROVIDED" { + warn!(target: "ebics-fetch", "Unexpected payment status for {}.{}", msg_status.id, p_status.id); + } else if let Some(code) = p_status.status { + let msg = p_status.msg(); + batch_status_update( + db, + &msg_status.id, + match code { + PaymentGroupStatus::AcceptedSettlementCompletedDebitorAccount => { + SubmissionState::success + } + PaymentGroupStatus::Rejected => { + error!(target: "ebics-fetch", "Batch {} failed: {msg}", msg_status.id); + SubmissionState::success + } + _ => SubmissionState::pending + }, + &msg, + ) + .await?; + } + for tx_status in p_status.txs { + let msg = tx_status.msg(); + tx_status_update( + db, + &tx_status.e2e_id, + &msg_status.id, + match tx_status.status { + PaymentTransactionStatus::Rejected | PaymentTransactionStatus::Blocked => { + error!(target: "ebics-fetch", "Transaction {} failed: {msg}", tx_status.e2e_id); + SubmissionState::permanent_failure + } + _ => SubmissionState::pending + }, + &msg, + ) + .await?; + } + } + } + OrderDoc::report | OrderDoc::statement | OrderDoc::notification => { + register_camt(db, cfg, &xml).await?; + } + } + Ok(()) + }; + let register_payload = async |doc: &OrderDoc, content: Vec<u8>| -> anyhow::Result<()> { + // Unzip payload if necessary + match doc { + OrderDoc::acknowledgement => register_file(doc, content).await, + OrderDoc::status | OrderDoc::report | OrderDoc::statement | OrderDoc::notification => { + let mut z = zip::ZipArchive::new(Cursor::new(content))?; + for i in 0..z.len() { + let mut file = z.by_index(i)?; + trace!(target: "ebics-fetch", "parse {}", file.name()); + let mut buf = Vec::new(); + file.read_to_end(&mut buf)?; + register_file(doc, buf).await?; + } + Ok(()) + } + } + }; + let fetch = async |orders: &[Order]| -> anyhow::Result<bool> { + let mut grouped_orders = BTreeMap::new(); + + for order in orders { + grouped_orders + .entry(order.doc()) + .or_insert_with(Vec::new) + .push(order); + } + + let mut success = true; + for (doc, orders) in grouped_orders { + if let Some(doc) = doc { + for order in orders { + if let Err(e) = download( + host_cfg, + http, + db, + ebics_log, + client, + bank, + order, + &None, + false, + async |content| { + register_payload(&doc, content) + .await + .map_err(|e| EbicsErrKind::Custom(e.to_string().into())) + }, + ) + .await + { + if let EbicsErrKind::Code { bank, .. } = e.kind { + match bank { + EbicsReturnCode::EBICS_NO_DOWNLOAD_DATA_AVAILABLE => continue, + EbicsReturnCode::EBICS_AUTHORISATION_ORDER_IDENTIFIER_FAILED => { + error!(target: "ebics-fetch", "{e}"); + success = false; + continue; + } + _ => {} + } + } + return Err(e.into()); + } + } + } else { + debug!(target: "ebics-fetch", "Skip unsupported orders {orders:?}") + } + } + Ok(success) + }; + + // EBICS order than should be fetched + let orders: Vec<_> = documents + .unwrap_or(OrderDoc::entries) + .iter() + .flat_map(|it| ebics_cfg.dialect.standard().downloads(it)) + .collect(); + + let last_fetch = Timestamp::UNIX_EPOCH; + + // TODO loop + + let now = Timestamp::now(); + + info!(target: "ebics-fetch", "Running at frequency"); + + let mut haa = haa(host_cfg, http, db, ebics_log, client, bank, false).await?; + debug!( + "HAA: {}", + std::fmt::from_fn(|f| f.write_str( + &haa.orders + .iter() + .map(|it| it.to_string()) + .collect::<Vec<_>>() + .join(",") + )) + ); + + haa.orders + .retain(|order| orders.iter().find(|it| order.eq(it)).is_some()); + fetch(&haa.orders).await?; + // TODO notification + Ok(()) +} + +pub async fn run(cfg: Config, cmd: &Cmd, ebics_log: &EbicsLogger) -> anyhow::Result<()> { + match cmd { + Cmd::Setup {} => { + let cfg = NexusCfg::parse(cfg)?; + ebics_setup(&Client::new(), &cfg, &EbicsLogger::new(None)?, false, false).await?; + } + Cmd::EbicsFetch {} => { + let pool = pool(&cfg).await?; + let http = Client::new(); + let cfg = NexusCfg::parse(cfg)?; + let key_cfg = cfg.keys()?; + let (client, bank) = expect_full_keys(key_cfg)?; + ebics_fetch(&cfg, &http, &client, &bank, ebics_log, &pool, None).await? + } + Cmd::EbicsSubmit {} => { + let pool = pool(&cfg).await?; + let http = Client::new(); + let cfg = NexusCfg::parse(cfg)?; + let key_cfg = cfg.keys()?; + let (client, bank) = expect_full_keys(key_cfg)?; + ebics_submit(&cfg, &http, &client, &bank, ebics_log, &pool, true).await? + } + Cmd::InitiatePayment { + amount, + subject, + end_to_end_id, + payto, + } => { + let pool = pool(&cfg).await?; + let cfg = NexusCfg::parse(cfg)?; + + let subject = payto + .subject + .as_ref() + .or(subject.as_ref()) + .ok_or(anyhow!("Mising subject"))?; + let amount = payto + .amount + .as_ref() + .or(amount.as_ref()) + .ok_or(anyhow!("Mising amount"))?; + + if cfg.currency != amount.currency { + bail!( + "Wrong currency: expected {} got {}", + cfg.currency, + amount.currency + ); + } + initiate( + &pool, + amount, + subject, + &payto.as_payto(), + &Timestamp::now(), + &end_to_end_id + .as_ref() + .cloned() + .unwrap_or_else(rand_ebics_id), + ) + .await?; } } + Ok(()) } diff --git a/src/main.rs b/src/main.rs @@ -18,15 +18,12 @@ */ use clap::Parser as _; -use libeufin::{Args, CONFIG_SOURCE, config::NexusCfg, ebics_setup}; -use reqwest::Client; +use libeufin::{Args, CONFIG_SOURCE, ebics::logger::EbicsLogger, run}; use taler_common::taler_main; fn main() { let args = Args::parse(); taler_main(CONFIG_SOURCE, args.common, async |cfg| { - let cfg = NexusCfg::parse(cfg)?; - ebics_setup(&Client::new(), &cfg, false).await?; - Ok(()) + run(cfg, &args.cmd, &EbicsLogger::new(None)?).await }) } diff --git a/src/model.rs b/src/model.rs @@ -434,18 +434,18 @@ pub struct PaymentBatch { pub msg_id: CompactString, pub creation_date: Timestamp, pub sum: Amount, - pub payments: Vec<InitiatedPayment>, + pub payments: Vec<Initiated>, } /** Initiated outgoing transaction */ #[derive(Debug, Clone, PartialEq, Eq)] -pub struct InitiatedPayment { +pub struct Initiated { pub id: u64, pub amount: Amount, pub subject: String, pub creditor: PaytoURI, pub initiation_time: Timestamp, - pub end_to_end_id: CompactString, + pub e2e_id: CompactString, } #[derive(Clone, PartialEq, Eq)] diff --git a/src/testbench.rs b/src/testbench.rs @@ -1,101 +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 anyhow::bail; -use clap::{Parser, ValueEnum}; - -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] -enum Component { - Nexus, - Ebisync, -} - -#[derive(Parser)] -/// Run integration tests on banks provider -pub struct TestbenchCmd { - #[arg(value_enum)] - component: Component, - platform: String, -} - -pub async fn testbench(cmd: &TestbenchCmd) -> anyhow::Result<()> { - // List available platform - let platforms: Vec<_> = std::fs::read_dir("testbench/test/platform") - .unwrap() - .filter_map(|entry| { - let e = entry.unwrap(); - let filename = e.file_name(); - if filename == "config.json" { - None - } else { - Some( - filename - .to_string_lossy() - .strip_suffix(".conf") - .unwrap() - .to_owned(), - ) - } - }) - .collect(); - if !platforms.contains(&cmd.platform) { - bail!( - "Unknown platform '{}', expected one of {}", - cmd.platform, - platforms.join(", ") - ); - } - - // Augment config - let simple_cfg = - std::fs::read_to_string(format!("testbench/test/platform/{}.conf", cmd.platform)).unwrap(); - let conf = format!("test/{}/ebics.conf", cmd.platform); - std::fs::write(&conf, format!(r#" - {simple_cfg} - {} - [paths] - LIBEUFIN_NEXUS_HOME = test/{} - EBISYNC_HOME = test/{} - - [nexus-fetch] - FREQUENCY = 1h - CHECKPOINT_TIME_OF_DAY = 16:52 - - [ebisync-fetch] - FREQUENCY = 1h - CHECKPOINT_TIME_OF_DAY = 16:52 - DESTINATION = azure-blob-storage - AZURE_API_URL = http://localhost:10000/devstoreaccount1/ - AZURE_ACCOUNT_NAME = devstoreaccount1 - AZURE_ACCOUNT_KEY = Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== - AZURE_CONTAINER = test - - [ebisync-submit] - SOURCE = ebisync-api - AUTH_METHOD = none - - [libeufin-nexusdb-postgres] - CONFIG = postgres:///libeufintestbench - - [ebisyncdb-postgres] - CONFIG = postgres:///libeufintestbench - "#, simple_cfg.replace("[nexus-ebics]", "[ebisync]").replace("[nexus-setup]", "[ebisync-setup]"), cmd.platform, cmd.platform)).unwrap(); - - Ok(()) -} diff --git a/src/utils.rs b/src/utils.rs @@ -0,0 +1,42 @@ +/* +* 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, io::Write as _}; + +use base64::{display::Base64Display, prelude::BASE64_STANDARD}; +use flate2::{Compression, write::ZlibEncoder}; + +pub fn deflate(bytes: &[u8]) -> Vec<u8> { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() +} + +pub fn hex_chunk_by_two<'a>(bytes: impl AsRef<[u8]> + 'a) -> impl Display + 'a { + std::fmt::from_fn(move |f| { + for b in bytes.as_ref() { + write!(f, "{b:X} ")?; + } + Ok(()) + }) +} + +pub fn b64<'a>(bytes: impl AsRef<[u8]> + 'a) -> impl Display + 'a { + std::fmt::from_fn(move |f| Base64Display::new(bytes.as_ref(), &BASE64_STANDARD).fmt(f)) +} diff --git a/src/worker.rs b/src/worker.rs @@ -26,7 +26,7 @@ use taler_common::types::amount::Currency; use tracing::{debug, info, warn}; use crate::{ - config::{AccountType, NexusIngestConfig}, + config::{AccountType, NexusIngestCfg}, db::{ initiated::unsettled_tx_in_batch, payment::{ @@ -41,7 +41,7 @@ use crate::{ pub async fn register_incoming( db: &PgPool, - cfg: &NexusIngestConfig, + cfg: &NexusIngestCfg, payment: &InTx, ) -> sqlx::Result<()> { let log_res = |res: InResult, kind: &str, suffix: &str| { @@ -222,7 +222,7 @@ pub async fn register_outgoing_batch( Ok(()) } -pub async fn register_tx(db: &PgPool, cfg: &NexusIngestConfig, tx: &Tx) -> sqlx::Result<()> { +pub async fn register_tx(db: &PgPool, cfg: &NexusIngestCfg, tx: &Tx) -> sqlx::Result<()> { if tx.execution_time() < &cfg.ignore_txs_before { debug!("IGNORE {tx}"); } else { diff --git a/src/xml.rs b/src/xml.rs @@ -19,46 +19,42 @@ use std::{ fmt::{Display, Write}, - str::FromStr, + str::{FromStr, Utf8Error}, }; use base64::{Engine, prelude::BASE64_STANDARD}; use roxmltree::{Document, Node}; #[macro_export] -macro_rules! xml_el { +macro_rules! xml { // Logic escape - ($w:ident, @ $logic:expr$(, $($rest:tt)*)?) => { + ($w:ident, @ $logic:expr$(, $($rest:tt)*)?) => {{ ($logic)($w); - $($crate::xml_el!($w, $($rest)*);)* - }; + $($crate::xml!($w, $($rest)*);)* + }}; // Text element - ($w:ident, $name:tt $(($k:tt: $v:tt))* : $content:expr $(, $($rest:tt)*)?) => { + ($w:ident, $name:tt $(($k:tt: $v:tt))* : $content:expr $(, $($rest:tt)*)?) => {{ $w.text(&$name, &[$((&$k, &$v)),*], &$content); - $($crate::xml_el!($w, $($rest)*);)* - }; + $($crate::xml!($w, $($rest)*);)* + }}; // Nested block - ($w:ident, $name:tt $(($k:tt: $v:tt))* { $($body:tt)* }$(, $($rest:tt)*)?) => { + ($w:ident, $name:tt $(($k:tt: $v:tt))* { $($body:tt)* }$(, $($rest:tt)*)?) => {{ let name = &$name; $w.open(&name, &[$((&$k, &$v)),*]); - $crate::xml_el!($w, $($body)*); + $crate::xml!($w, $($body)*); $w.close(&name); - $($crate::xml_el!($w, $($rest)*);)* - }; + $($crate::xml!($w, $($rest)*);)* + }}; // Empty element - ($w:ident, $name:tt $(($k:tt: $v:tt))* $(, $($rest:tt)*)?) => { + ($w:ident, $name:tt $(($k:tt: $v:tt))* $(, $($rest:tt)*)?) => {{ $w.empty(&$name, &[$((&$k, &$v)),*]); - $($crate::xml_el!($w, $($rest)*);)* - }; -} - -#[macro_export] -macro_rules! xml_build { + $($crate::xml!($w, $($rest)*);)* + }}; ($($xml:tt)*) => { { let mut writer = $crate::xml::XmlWriter::init(); let w = &mut writer; - $crate::xml_el!(w, $($xml)*); + $crate::xml!(w, $($xml)*); writer.finish() } }; @@ -145,7 +141,8 @@ impl std::fmt::Write for XmlWriter { } #[derive(Debug)] -pub enum XmlError { +pub enum Error { + Str(Utf8Error), Xml(roxmltree::Error), Root(Box<str>, Box<str>), Parent(Box<str>), @@ -155,9 +152,10 @@ pub enum XmlError { Parse(Box<str>, Box<str>), } -impl Display for XmlError { +impl Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Self::Str(e) => e.fmt(f), Self::Xml(e) => e.fmt(f), Self::Root(expected, got) => write!(f, "expected root '{expected}' got '{got}'"), Self::Parent(path) => write!(f, "not parent for element '{path}'"), @@ -169,9 +167,9 @@ impl Display for XmlError { } } -impl std::error::Error for XmlError {} +impl std::error::Error for Error {} -pub type Result<T> = std::result::Result<T, XmlError>; +pub type Result<T> = std::result::Result<T, Error>; #[derive(Debug, Clone, Copy)] pub struct Xml<'xml> { @@ -179,23 +177,24 @@ pub struct Xml<'xml> { } impl<'xml> Xml<'xml> { - pub fn parse_str<F, R>(raw: &str, tag: &str, f: F) -> Result<R> + pub fn parse<F, R>(raw: &[u8], tag: &str, f: F) -> Result<R> where R: 'static, F: for<'local> FnOnce(Xml<'local>) -> Result<R>, { - let xml = Document::parse(raw).map_err(XmlError::Xml)?; - Self::parse_doc(xml, tag, f) + let str = std::str::from_utf8(raw).map_err(Error::Str)?; + let xml = Document::parse(str).map_err(Error::Xml)?; + Self::doc(xml, tag, f) } - pub fn parse_doc<F, R>(xml: Document, tag: &str, f: F) -> Result<R> + pub fn doc<F, R>(xml: Document, tag: &str, f: F) -> Result<R> where R: 'static, F: for<'local> FnOnce(Xml<'local>) -> Result<R>, { let root = xml.root_element(); if !root.has_tag_name(tag) { - return Err(XmlError::Root(tag.into(), root.tag_name().name().into())); + return Err(Error::Root(tag.into(), root.tag_name().name().into())); } let node = Xml { node: root }; let res = f(node); @@ -232,8 +231,8 @@ impl<'xml> Xml<'xml> { buf.into() } - pub fn parse_err(self, err: impl Display) -> XmlError { - XmlError::Parse(self.path(None), err.to_string().into_boxed_str()) + pub fn parse_err(self, err: impl Display) -> Error { + Error::Parse(self.path(None), err.to_string().into_boxed_str()) } pub fn parent(self) -> Result<Xml<'xml>> { @@ -241,7 +240,7 @@ impl<'xml> Xml<'xml> { node: self .node .parent() - .ok_or_else(|| XmlError::Parent(self.path(None)))?, + .ok_or_else(|| Error::Parent(self.path(None)))?, }) } @@ -255,7 +254,7 @@ impl<'xml> Xml<'xml> { let mut iter = self.children(tag, signed); match (iter.next(), iter.next()) { (None, _) => Ok(None), - (Some(_), Some(_)) => Err(XmlError::Duplicate(self.path(Some(tag)), iter.count() + 2)), + (Some(_), Some(_)) => Err(Error::Duplicate(self.path(Some(tag)), iter.count() + 2)), (Some(node), None) => Ok(Some(Xml { node })), } } @@ -263,7 +262,7 @@ impl<'xml> Xml<'xml> { fn one_inner(self, tag: &str, signed: bool) -> Result<Xml<'xml>> { self.opt_inner(tag, signed) .transpose() - .unwrap_or_else(|| Err(XmlError::MissingEl(self.path(Some(tag))))) + .unwrap_or_else(|| Err(Error::MissingEl(self.path(Some(tag))))) } pub fn many(self, tag: &str) -> impl Iterator<Item = Xml<'xml>> { @@ -277,7 +276,7 @@ impl<'xml> Xml<'xml> { pub fn attr(self, name: &str) -> Result<&'xml str> { self.node .attribute(name) - .ok_or_else(|| XmlError::MissingAttr(self.path(None), name.into())) + .ok_or_else(|| Error::MissingAttr(self.path(None), name.into())) } pub fn opt_attr(self, name: &str) -> Option<&'xml str> { @@ -411,7 +410,7 @@ mod test { #[test] pub fn basic() { assert_eq!( - xml_build!("ebicsRequest" ("version": "H004") { + xml!("ebicsRequest" ("version": "H004") { "a" { "b" { "c" ("attribute-of": "c") { @@ -437,10 +436,10 @@ mod test { #[test] pub fn modularity() { fn module(w: &mut XmlWriter) { - xml_el!(w, "module"); + xml!(w, "module"); } assert_eq!( - xml_build!("root" { @ module }), + xml!("root" { @ module }), r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><root><module/></root>"# ) } @@ -448,14 +447,12 @@ mod test { #[test] pub fn iterable() { assert_eq!( - xml_build!("iterable" { + xml!("iterable" { "endOfDocument" { - @ |w: &mut XmlWriter| { - for i in 1..=10 { - xml_el!(w, (format_args!("e{i}")) { - (format_args!("e{i}{i}")): (format_args!("{i}{i}{i}")) - }); - } + @ |w: &mut XmlWriter| for i in 1..=10 { + xml!(w, (format_args!("e{i}")) { + (format_args!("e{i}{i}")): (format_args!("{i}{i}{i}")) + }) } } }),