commit 28b9f91c8566ac7e414f826fd82f03195eadb2cd
parent 181a7ebcb751e05562bdb2cb8d65e316abc62797
Author: Antoine A <>
Date: Fri, 24 Apr 2026 10:38:00 +0200
ebics: fetch & submit part 1
Diffstat:
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(¤t.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(¤t.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}"))
+ })
}
}
}),