libeufin

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

commit e618031731a0d16a36bf14c1b25c46acd20a2eba
parent 00666e2153d40fd5c55189c4f4941965e25d3a89
Author: Antoine A <>
Date:   Thu,  4 Dec 2025 13:10:17 +0100

ebics: move common logic to ebics module and add ebisync setup

Diffstat:
MMakefile | 81+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mbuild.gradle | 2++
Ddatabase-versioning/README | 3---
Adatabase-versioning/libeufin-ebisync-0001.sql | 35+++++++++++++++++++++++++++++++++++
Adatabase-versioning/libeufin-ebisync-drop.sql | 33+++++++++++++++++++++++++++++++++
Adebian/etc/libeufin-ebisync/libeufin-ebysinc.conf | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adebian/etc/libeufin-ebisync/overrides.conf | 2++
Adebian/etc/libeufin-ebisync/secrets/ebisync-db.secret.conf | 10++++++++++
Mlibeufin-common/src/main/kotlin/TalerConfig.kt | 2++
Alibeufin-ebics/build.gradle | 44++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EBicsLogger.kt | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsAdministrative.kt | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsBTS.kt | 341+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsCommon.kt | 437+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsConstants.kt | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsDAO.kt | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsKeyMng.kt | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/cli.kt | 31+++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/config.kt | 40++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/db/Database.kt | 39+++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/http.kt | 41+++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/keys.kt | 221+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/order.kt | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/pdf.kt | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/setup.kt | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/test/TxCheck.kt | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/ws.kt | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/main/kotlin/tech/libeufin/ebics/xml.kt | 376+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/test/kotlin/Keys.kt | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/test/kotlin/MySerializers.kt | 48++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/test/kotlin/WsTest.kt | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebics/src/test/kotlin/XmlTest.kt | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rlibeufin-nexus/src/test/resources/signature1/doc.xml -> libeufin-ebics/src/test/resources/signature1/doc.xml | 0
Rlibeufin-nexus/src/test/resources/signature1/public_key.txt -> libeufin-ebics/src/test/resources/signature1/public_key.txt | 0
Mlibeufin-ebisync/build.gradle | 2+-
Alibeufin-ebisync/conf/test.conf | 18++++++++++++++++++
Alibeufin-ebisync/ebisync.conf | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlibeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/Main.kt | 13+++++++------
Mlibeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/azure.kt | 17++++-------------
Alibeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/DbInit.kt | 45+++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/Fetch.kt | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/LibeufinEbisync.kt | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/Setup.kt | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/config.kt | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/db/Database.kt | 37+++++++++++++++++++++++++++++++++++++
Alibeufin-ebisync/src/test/kotlin/DatabaseTest.kt | 47+++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-ebisync/src/test/kotlin/helpers.kt | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlibeufin-nexus/build.gradle | 5++---
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt | 26++++++++++++++------------
Dlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/KeyFiles.kt | 220-------------------------------------------------------------------------------
Dlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/PDF.kt | 115-------------------------------------------------------------------------------
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/ObservabilityApi.kt | 2+-
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt | 2+-
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt | 16+++++++++-------
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt | 209+++++++++++--------------------------------------------------------------------
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSubmit.kt | 35++++++++++++++++++-----------------
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/InitiatePayment.kt | 2+-
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/List.kt | 1-
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Manual.kt | 2+-
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt | 13+++++++------
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt | 1+
Dlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/EbicsDAO.kt | 47-----------------------------------------------
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/dialect.kt | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt | 172-------------------------------------------------------------------------------
Dlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt | 350-------------------------------------------------------------------------------
Dlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt | 442-------------------------------------------------------------------------------
Dlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsConstants.kt | 109-------------------------------------------------------------------------------
Dlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsKeyMng.kt | 203-------------------------------------------------------------------------------
Dlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsLogger.kt | 146-------------------------------------------------------------------------------
Dlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt | 226-------------------------------------------------------------------------------
Dlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsWS.kt | 207-------------------------------------------------------------------------------
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/helpers.kt | 16----------------
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt | 1+
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/hac.kt | 1+
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/pain001.kt | 2+-
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/pain002.kt | 1+
Dlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/test/TxCheck.kt | 95-------------------------------------------------------------------------------
Dlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/xml.kt | 369-------------------------------------------------------------------------------
Mlibeufin-nexus/src/test/kotlin/CliTest.kt | 86+------------------------------------------------------------------------------
Mlibeufin-nexus/src/test/kotlin/DatabaseTest.kt | 2+-
Mlibeufin-nexus/src/test/kotlin/EbicsTest.kt | 4++--
Mlibeufin-nexus/src/test/kotlin/Iso20022Test.kt | 2+-
Dlibeufin-nexus/src/test/kotlin/Keys.kt | 98-------------------------------------------------------------------------------
Dlibeufin-nexus/src/test/kotlin/MySerializers.kt | 48------------------------------------------------
Mlibeufin-nexus/src/test/kotlin/RegistrationTest.kt | 2+-
Mlibeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt | 2+-
Dlibeufin-nexus/src/test/kotlin/WsTest.kt | 187-------------------------------------------------------------------------------
Dlibeufin-nexus/src/test/kotlin/XmlCombinatorsTest.kt | 101-------------------------------------------------------------------------------
Dlibeufin-nexus/src/test/kotlin/XmlUtilTest.kt | 73-------------------------------------------------------------------------
Mlibeufin-nexus/src/test/kotlin/helpers.kt | 3++-
Msettings.gradle | 1+
Mtestbench/build.gradle | 2++
Atestbench/conf/cli.conf | 27+++++++++++++++++++++++++++
Mtestbench/src/main/kotlin/Main.kt | 282+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Atestbench/src/test/kotlin/CliTest.kt | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtestbench/src/test/kotlin/Iso20022Test.kt | 2+-
96 files changed, 4904 insertions(+), 3722 deletions(-)

diff --git a/Makefile b/Makefile @@ -18,10 +18,8 @@ endef # Absolute DESTDIR or empty string if DESTDIR unset/empty abs_destdir=$(abspath $(DESTDIR)) -man_dir=$(abs_destdir)$(prefix)/share/man -spa_dir=$(abs_destdir)$(prefix)/share/libeufin/spa -sql_dir=$(abs_destdir)$(prefix)/share/libeufin/sql -config_dir=$(abs_destdir)$(prefix)/share/libeufin/config.d +share_dir=$(abs_destdir)$(prefix)/share +man_dir=$(share_dir)/man bin_dir=$(abs_destdir)$(prefix)/bin lib_dir=$(abs_destdir)$(prefix)/lib @@ -30,7 +28,7 @@ lib_dir=$(abs_destdir)$(prefix)/lib # it's like a destdir install that only touches the source tree. .PHONY: build build: - ./gradlew libeufin-bank:installShadowDist libeufin-nexus:installShadowDist + ./gradlew libeufin-bank:installShadowDist libeufin-nexus:installShadowDist libeufin-ebisync:installShadowDist .PHONY: dist @@ -43,51 +41,38 @@ dist: deb: dpkg-buildpackage -rfakeroot -b -uc -us -# The install-nobuild-* targets install under the assumption -# that the compile step has already been run. - -# Install without attempting to build first -.PHONY: install-nobuild -install-nobuild: install-nobuild-bank install-nobuild-nexus - - .PHONY: install-nobuild-files install-nobuild-files: - install -m 644 -D -t $(config_dir) contrib/currencies.conf - install -m 644 -D -t $(config_dir) contrib/bank.conf - install -m 644 -D -t $(config_dir) contrib/nexus.conf - install -m 644 -D -t $(sql_dir) database-versioning/versioning.sql - install -m 644 -D -t $(sql_dir) database-versioning/libeufin-bank*.sql - install -m 644 -D -t $(sql_dir) database-versioning/libeufin-nexus*.sql - install -m 644 -D -t $(sql_dir) database-versioning/libeufin-conversion*.sql + install -m 644 -D -t $(share_dir)/libeufin/config.d contrib/currencies.conf + install -m 644 -D -t $(share_dir)/libeufin/config.d contrib/bank.conf + install -m 644 -D -t $(share_dir)/libeufin/config.d contrib/nexus.conf + install -m 644 -D -t $(share_dir)/libeufin/sql database-versioning/versioning.sql + install -m 644 -D -t $(share_dir)/libeufin/sql database-versioning/libeufin-bank*.sql + install -m 644 -D -t $(share_dir)/libeufin/sql database-versioning/libeufin-nexus*.sql + install -m 644 -D -t $(share_dir)/libeufin/sql database-versioning/libeufin-conversion*.sql + install -m 644 -D -t $(share_dir)/libeufin-ebisync/config.d libeufin-ebisync/ebisync.conf + install -m 644 -D -t $(share_dir)/libeufin-ebisync/sql database-versioning/versioning.sql + install -m 644 -D -t $(share_dir)/libeufin-ebisync/sql database-versioning/libeufin-ebisync*.sql install -D -t $(bin_dir) contrib/libeufin-dbconfig install -D -t $(bin_dir) contrib/libeufin-tan-*.sh - -.PHONY: install-nobuild-bank -install-nobuild-bank: install-nobuild-files - install -d $(spa_dir) - cp contrib/wallet-core/bank/* $(spa_dir)/ - install -d $(abs_destdir)$(prefix) - install -d $(bin_dir) - install -d $(lib_dir) - install -D -t $(bin_dir) contrib/libeufin-bank-dbinit + +.PHONY: install +install: build install-nobuild-files +# Install libeufin-bank + install -d $(share_dir)/libeufin/spa + cp contrib/wallet-core/bank/* $(share_dir)/libeufin/spa/ install -D -t $(bin_dir) libeufin-bank/build/install/libeufin-bank-shadow/bin/libeufin-bank install -m 644 -D -t $(man_dir)/man1 doc/prebuilt/man/libeufin-bank.1 install -m 644 -D -t $(man_dir)/man5 doc/prebuilt/man/libeufin-bank.conf.5 install -m 644 -D -t $(lib_dir) libeufin-bank/build/install/libeufin-bank-shadow/lib/libeufin-bank-all.jar - -.PHONY: install-nobuild-nexus -install-nobuild-nexus: install-nobuild-files - install -D -t $(bin_dir) contrib/libeufin-nexus-dbinit +# Install libeufin-nexus install -D -t $(bin_dir) libeufin-nexus/build/install/libeufin-nexus-shadow/bin/libeufin-nexus install -m 644 -D -t $(man_dir)/man1 doc/prebuilt/man/libeufin-nexus.1 install -m 644 -D -t $(man_dir)/man5 doc/prebuilt/man/libeufin-nexus.conf.5 install -m 644 -D -t $(lib_dir) libeufin-nexus/build/install/libeufin-nexus-shadow/lib/libeufin-nexus-all.jar - -.PHONY: install -install: - $(MAKE) build - $(MAKE) install-nobuild +# Install libeufin-ebisync + install -D -t $(bin_dir) libeufin-ebisync/build/install/libeufin-ebisync-shadow/bin/libeufin-ebisync + install -m 644 -D -t $(lib_dir) libeufin-ebisync/build/install/libeufin-ebisync-shadow/lib/libeufin-ebisync-all.jar .PHONY: assemble assemble: @@ -109,15 +94,29 @@ nexus-test: install-nobuild-files common-test: install-nobuild-files ./gradlew :libeufin-common:test --tests $(test) -i +.PHONY: ebics-test +ebics-test: install-nobuild-files + ./gradlew :libeufin-ebics:test --tests $(test) -i + .PHONY: testbench-test testbench-test: install-nobuild-files ./gradlew :testbench:test --tests $(test) -i -.PHONY: testbench -testbench: install-nobuild-files +.PHONY: ebisync-test +ebisync-test: install-nobuild-files + ./gradlew :libeufin-ebisync:test --tests $(test) -i + +.PHONY: nexus-testbench +nexus-testbench: install-nobuild-files + ./gradlew :testbench:install && \ + cd testbench && \ + ./build/install/libeufin-testbench-test/bin/libeufin-testbench-test nexus $(platform) + +.PHONY: ebisync-testbench +ebisync-testbench: install-nobuild-files ./gradlew :testbench:install && \ cd testbench && \ - ./build/install/libeufin-testbench-test/bin/libeufin-testbench-test $(platform) + ./build/install/libeufin-testbench-test/bin/libeufin-testbench-test ebisync $(platform) .PHONY: doc doc: diff --git a/build.gradle b/build.gradle @@ -63,4 +63,6 @@ dependencies { dokka(project(":libeufin-common:")) dokka(project(":libeufin-bank:")) dokka(project(":libeufin-nexus:")) + dokka(project(":libeufin-ebics:")) + dokka(project(":libeufin-ebisync:")) } \ No newline at end of file diff --git a/database-versioning/README b/database-versioning/README @@ -1,3 +0,0 @@ -This directory contains the database schemas of bank and nexus. -Currently only the bank files get installed. Nexus will after -its refactoring. diff --git a/database-versioning/libeufin-ebisync-0001.sql b/database-versioning/libeufin-ebisync-0001.sql @@ -0,0 +1,35 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2025 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER 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 General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +BEGIN; + +SELECT _v.register_patch('libeufin-ebisync-0001', NULL, NULL); + +CREATE SCHEMA libeufin_ebisync; +SET search_path TO libeufin_ebisync; + +CREATE TABLE kv ( + key TEXT NOT NULL PRIMARY KEY, + value JSONB NOT NULL +); +COMMENT ON TYPE kv + IS 'Store key/value data that do not fit well in a traditional relational table.'; + +CREATE TABLE pending_ebics_transactions ( + tx_id TEXT NOT NULL UNIQUE PRIMARY KEY +); +COMMENT ON TYPE pending_ebics_transactions + IS 'Store pending EBICS transactions ids to cleanly close them on failure.'; +COMMIT; diff --git a/database-versioning/libeufin-ebisync-drop.sql b/database-versioning/libeufin-ebisync-drop.sql @@ -0,0 +1,33 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2025 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER 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 General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +BEGIN; + +DO +$do$ +DECLARE + patch text; +BEGIN + IF EXISTS(SELECT FROM information_schema.schemata WHERE schema_name='_v') THEN + FOR patch IN SELECT patch_name FROM _v.patches WHERE patch_name LIKE 'libeufin_ebisync_%' LOOP + PERFORM _v.unregister_patch(patch); + END LOOP; + END IF; +END +$do$; + +DROP SCHEMA IF EXISTS libeufin_ebisync CASCADE; + +COMMIT; diff --git a/debian/etc/libeufin-ebisync/libeufin-ebysinc.conf b/debian/etc/libeufin-ebisync/libeufin-ebysinc.conf @@ -0,0 +1,57 @@ +# Main entry point for the LibEuFin EbiSync configuration. +# +# Structure: +# - libeufin-ebisync.conf is the main configuration entry point +# used by all LibEuFin EbiSync components (the file you are currently +# looking at. +# - overrides.conf contains configuration overrides that are +# set by some tools that help with the configuration, +# and should not be edited by humans. Comments in this file +# are not preserved. +# - conf.d/ contains configuration files for +# LibEuFin EbiSync components, which can be read by all +# users of the system and are included by the main +# configuration. +# - secrets/ contains configuration snippets +# with secrets for particular services. +# These files should have restrictive permissions +# so that only users of the relevant services +# can read it. All files in it should end with +# ".secret.conf". + +[ebisync] +# Base URL of the bank server. +HOST_BASE_URL = + +# EBICS host ID. +HOST_ID = + +# EBICS user ID, as assigned by the bank. +USER_ID = + +# EBICS partner ID, as assigned by the bank. +PARTNER_ID = + +# EBICS partner ID, as assigned by the bank. +SYSTEM_ID = + + +[ebisync-setup] +# Bank encryption public key hash +# BANK_ENCRYPTION_PUB_KEY_HASH = + +# Bank authentication public key hash +# BANK_AUTHENTICATION_PUB_KEY_HASH = + +# Inline configurations from all LibEuFin EbiSync components. +@inline-matching@ conf.d/*.conf + +# Overrides from tools that help with configuration. +@inline@ overrides.conf + +[paths] + +# Paths for the system-wide installation of the LibEuFin EbyiSync. Do not remove +# or change these unless you are very sure of what you are doing. + +EBISYNC_HOME = /var/lib/libeufin-ebisync/ +\ No newline at end of file diff --git a/debian/etc/libeufin-ebisync/overrides.conf b/debian/etc/libeufin-ebisync/overrides.conf @@ -0,0 +1 @@ +# This configuration will be changed by tooling. Do not touch it manually. +\ No newline at end of file diff --git a/debian/etc/libeufin-ebisync/secrets/ebisync-db.secret.conf b/debian/etc/libeufin-ebisync/secrets/ebisync-db.secret.conf @@ -0,0 +1,9 @@ + +[libeufin-ebisync] + +# Typically, there should only be a single line here, of the form: + +CONFIG=postgres:///libeufin-ebisync + +# The details of the URI depend on where the database lives and how +# access control was configured. +\ No newline at end of file diff --git a/libeufin-common/src/main/kotlin/TalerConfig.kt b/libeufin-common/src/main/kotlin/TalerConfig.kt @@ -139,6 +139,8 @@ private class ConfigLoader( fun loadDefaults() { val installDir = source.installPath() + println("$installDir") + val section = sections.getOrPut("PATHS") { mutableMapOf() } section["PREFIX"] = "$installDir/" section["BINDIR"] = "$installDir/bin/" diff --git a/libeufin-ebics/build.gradle b/libeufin-ebics/build.gradle @@ -0,0 +1,43 @@ +plugins { + id("kotlin") + id("org.jetbrains.kotlin.plugin.serialization") version "$kotlin_version" +} + +version = rootProject.version + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +compileKotlin.kotlinOptions.jvmTarget = "17" +compileTestKotlin.kotlinOptions.jvmTarget = "17" + +sourceSets.main.java.srcDirs = ["src/main/kotlin"] + +dependencies { + // Core language libraries + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version") + + implementation(project(":libeufin-common")) + + // Command line parsing + implementation("com.github.ajalt.clikt:clikt:$clikt_version") + implementation("org.postgresql:postgresql:$postgres_version") + + // Ktor client library + implementation("io.ktor:ktor-client-cio:$ktor_version") + implementation("io.ktor:ktor-client-mock:$ktor_version") + implementation("io.ktor:ktor-client-websockets:$ktor_version") + + // PDF generation + implementation("com.itextpdf:itext-core:9.3.0") + + // Serialization + implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") + + // Unit testing + testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") + testImplementation("io.ktor:ktor-server-test-host:$ktor_version") + testImplementation("io.ktor:ktor-server-cio:$ktor_version") +} +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EBicsLogger.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EBicsLogger.kt @@ -0,0 +1,145 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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/> + */ + +package tech.libeufin.ebics + +import tech.libeufin.common.* +import tech.libeufin.ebics.EbicsOrder +import java.io.* +import java.nio.file.* +import java.time.* +import java.time.format.DateTimeFormatter +import kotlin.io.* +import kotlin.io.path.* +import io.ktor.client.statement.* + +/** Log EBICS transactions steps and payload if [path] is not null */ +class EbicsLogger(private val dir: Path?) { + + init { + if (dir != null) { + try { + // Create logging directory if missing + dir.createDirectories() + } catch (e: Exception) { + throw Exception("Failed to init EBICS debug logging directory", e) + } + logger.info("Logging to '$dir'") + } + } + + /** Create a new [name] EBICS transaction logger */ + fun tx(name: String): TxLogger { + if (dir == null) return TxLogger(null) + val utcDateTime = Instant.now().atOffset(ZoneOffset.UTC) + val txDir = dir + // yyyy-MM-dd per day directory + .resolve(utcDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE)) + // HH:mm:ss.SSS-name per transaction directory + .resolve("${utcDateTime.format(TIME_WITH_MS)}-$name") + txDir.createDirectories() + return TxLogger(txDir) + } + + /** Create a new [order] EBICS transaction logger */ + fun tx(order: EbicsOrder): TxLogger { + if (dir == null) return TxLogger(null) + return tx(order.description()) + } + + companion object { + private val TIME_WITH_MS = DateTimeFormatter.ofPattern("HH:mm:ss.SSS") + } +} + +/** Log EBICS transaction steps and payload */ +class TxLogger internal constructor( + private val dir: Path? +) { + /** Create a new [name] EBICS transaction step logger*/ + fun step(name: String? = null) = StepLogger(dir, name) + + /** Log a [stream] EBICS transaction payload of [type] */ + fun payload(stream: InputStream, type: String = "xml"): InputStream { + if (dir == null) return stream + return payload(stream.readBytes(), type).inputStream() + } + + /** Log a [content] EBICS transaction payload of [type] */ + fun payload(content: ByteArray, type: String = "xml"): ByteArray { + if (dir == null) return content + val type = type.lowercase() + if (type == "zip") { + val payloadDir = dir.resolve("payload") + payloadDir.createDirectory() + content.inputStream().unzipEach { fileName, xmlContent -> + xmlContent.use { + Files.copy(it, payloadDir.resolve(fileName)) + } + } + } else { + dir.resolve("payload.$type").writeBytes(content, StandardOpenOption.CREATE_NEW) + } + return content + } +} + +/** Log EBICS transaction protocol step */ +class StepLogger internal constructor( + private val dir: Path?, + private val name: String? +) { + private val prefix = if (name != null) "$name-" else "" + + /** Log a protocol step [request] */ + fun logRequest(request: ByteArray) { + if (dir != null) { + dir.resolve("${prefix}request.xml") + .writeBytes(request, StandardOpenOption.CREATE_NEW) + } + } + + /** Log a protocol step failure */ + suspend fun logFailure(res: HttpResponse) { + if (dir != null) { + // TODO reduce allocation + // TODO silent io error + val bytes = buildString { + append("${res.version} ${res.status}\n") + for ((k, vs) in res.headers.entries()) { + for (v in vs) { + append("${k}: ${v}\n") + } + } + append('\n') + }.toByteArray() + res.readRawBytes() + dir.resolve("${prefix}failure") + .writeBytes(bytes, StandardOpenOption.CREATE_NEW) + } + } + + /** Log a protocol step [response] */ + fun logResponse(response: InputStream): InputStream { + if (dir == null) return response + val bytes = response.readBytes() + dir.resolve("${prefix}response.xml") + .writeBytes(bytes, StandardOpenOption.CREATE_NEW) + return bytes.inputStream() + } +} +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsAdministrative.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsAdministrative.kt @@ -0,0 +1,169 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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/> + */ + +package tech.libeufin.ebics + +import org.w3c.dom.Document +import java.io.InputStream + +data class VersionNumber(val number: Float, val schema: String) { + override fun toString(): String = "$number:$schema" +} + +data class HKD( + val partner: PartnerInfo, + val users: List<UserInfo> +) +data class PartnerInfo( + val name: String?, + val accounts: List<AccountInfo>, + val orders: List<OrderInfo> +) +data class OrderInfo( + val order: EbicsOrder, + val description: String, +) +data class AccountInfo( + val currency: String, + val iban: String, + val bic: String +) +data class UserInfo( + val id: String, + val status: UserStatus, + val permissions: List<EbicsOrder>, +) + +data class HAA( + val orders: List<EbicsOrder> +) + +enum class UserStatus(val description: String) { + Ready("Subscriber is permitted access"), + New("Subscriber is established, pending access permission"), + INI("Subscriber has sent INI file, but no HIA file yet"), + HIA("Subscriber has sent HIA order, but no INI file yet"), + Initialised("Subscriber has sent both HIA order and INI file"), + SuspendedFailedAttempts("Suspended after several failed attempts, new initialisation via INI and HIA possible"), + SuspendedSPR("Suspended after SPR order, new initialisation via INI and HIA possible"), + SuspendedBank("Suspended by bank, new initialisation via INI and HIA is not possible, suspension can only be revoked by the bank"), +} + +object EbicsAdministrative { + fun HEV(cfg: EbicsHostConfig): ByteArray { + return XmlBuilder.toBytes("ebicsHEVRequest") { + attr("xmlns", "http://www.ebics.org/H000") + el("HostID", cfg.hostId) + } + } + + fun parseHEV(doc: Document): EbicsResponse<List<VersionNumber>> { + return XmlDestructor.parse(doc, "ebicsHEVResponse") { + val technicalCode = one("SystemReturnCode") { + EbicsReturnCode.lookup(one("ReturnCode").text()) + } + val versions = map("VersionNumber") { + VersionNumber(text().toFloat(), attr("ProtocolVersion")) + } + EbicsResponse( + technicalCode = technicalCode, + bankCode = EbicsReturnCode.EBICS_OK, + content = versions + ) + } + } + + private fun XmlDestructor.ebicsOrder(type: String): EbicsOrder = + EbicsOrder.V3( + type = type, + service = opt("ServiceName")?.text(), + scope = opt("Scope")?.text(), + option = opt("ServiceOption")?.text(), + container = opt("Container")?.attr("containerType"), + message = opt("MsgName")?.text(), + version = opt("MsgName")?.optAttr("version"), + ) + + fun parseHKD(stream: InputStream): HKD { + fun XmlDestructor.order(): EbicsOrder { + val type = one("AdminOrderType").text() + return opt("Service") { + ebicsOrder(type) + } ?: EbicsOrder.V3(type) + } + return XmlDestructor.parse(stream, "HKDResponseOrderData") { + val partnerInfo = one("PartnerInfo") { + val name = one("AddressInfo").opt("Name")?.text() + val accounts = map("AccountInfo") { + var currency = attr("Currency") + lateinit var iban: String + lateinit var bic: String + each("AccountNumber") { + if (attr("international") == "true") { + iban = text() + } + } + each("BankCode") { + if (attr("international") == "true") { + bic = text() + } + } + AccountInfo(currency, iban, bic) + } + val orders = map("OrderInfo") { + OrderInfo( + order = order(), + description = one("Description").text() + ) + } + PartnerInfo(name, accounts, orders) + } + val usersInfo = map("UserInfo") { + val (id, status) = one("UserID") { + val id = text() + val status = when (val status = attr("Status")) { + "1" -> UserStatus.Ready + "2" -> UserStatus.New + "3" -> UserStatus.INI + "4" -> UserStatus.HIA + "5" -> UserStatus.Initialised + "6" -> UserStatus.SuspendedFailedAttempts + // 7 is not applicable per spec + "8" -> UserStatus.SuspendedSPR + "9" -> UserStatus.SuspendedBank + else -> throw Exception("Unknown user statte $status") + } + Pair(id, status) + } + val permissions = map("Permission") { order() } + UserInfo(id, status, permissions) + } + HKD(partnerInfo, usersInfo) + } + } + + fun parseHAA(stream: InputStream): HAA { + return XmlDestructor.parse(stream, "HAAResponseOrderData") { + val orders = map("Service") { + ebicsOrder("BTD") + } + HAA(orders) + } + } +} diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsBTS.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsBTS.kt @@ -0,0 +1,340 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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/> + */ +package tech.libeufin.ebics + +import org.w3c.dom.Document +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.common.decodeBase64 +import tech.libeufin.common.encodeBase64 +import tech.libeufin.common.encodeHex +import tech.libeufin.common.encodeUpHex +import java.time.Instant + +/** EBICS protocol for business transactions */ +class EbicsBTS( + val cfg: EbicsHostConfig, + val bankKeys: BankPublicKeysFile, + val clientKeys: ClientPrivateKeysFile, + val order: EbicsOrder +) { + /* ----- Download ----- */ + + fun downloadInitialization(startDate: Instant?, endDate: Instant?): ByteArray { + val nonce = getNonce(128) + return signedRequest { + el("header") { + attr("authenticate", "true") + el("static") { + el("HostID", cfg.hostId) + el("Nonce", nonce.encodeHex()) + el("Timestamp", Instant.now().xmlDateTime()) + el("PartnerID", cfg.partnerId) + el("UserID", cfg.userId) + // SystemID + // Product + el("OrderDetails") { + when (order) { + is EbicsOrder.V2_5 -> { + el("OrderType", order.type) + el("OrderAttribute", order.attribute) + el("StandardOrderParams") { + if (startDate != null) { + el("DateRange") { + el("Start", startDate.xmlDate()) + el("End", (endDate ?: Instant.now()).xmlDate()) + } + } + } + } + is EbicsOrder.V3 -> { + el("AdminOrderType", order.type) + if (order.type == "BTD") { + el("BTDOrderParams") { + service(order) + if (startDate != null) { + el("DateRange") { + el("Start", startDate.xmlDate()) + el("End", (endDate ?: Instant.now()).xmlDate()) + } + } + } + } else { + el("StandardOrderParams") + } + } + } + } + bankDigest() + } + el("mutable/TransactionPhase", "Initialisation") + } + el("AuthSignature") + el("body") + } + } + + fun downloadTransfer( + nbSegment: Int, + segmentNumber: Int, + transactionId: String + ): ByteArray { + return signedRequest { + el("header") { + attr("authenticate", "true") + el("static") { + el("HostID", cfg.hostId) + el("TransactionID", transactionId) + } + el("mutable") { + el("TransactionPhase", "Transfer") + el("SegmentNumber") { + attr("lastSegment", if (nbSegment == segmentNumber) "true" else "false") + text(segmentNumber.toString()) + } + } + } + el("AuthSignature") + el("body") + } + } + + fun downloadReceipt( + transactionId: String, + success: Boolean + ): ByteArray { + return signedRequest { + el("header") { + attr("authenticate", "true") + el("static") { + el("HostID", cfg.hostId) + el("TransactionID", transactionId) + } + el("mutable") { + el("TransactionPhase", "Receipt") + } + } + el("AuthSignature") + el("body/TransferReceipt") { + attr("authenticate", "true") + el("ReceiptCode", if (success) "0" else "1") + } + } + } + + /* ----- Upload ----- */ + + fun uploadInitialization(uploadData: PreparedUploadData): ByteArray { + val nonce = getNonce(128) + return signedRequest { + el("header") { + attr("authenticate", "true") + el("static") { + el("HostID", cfg.hostId) + el("Nonce", nonce.encodeUpHex()) + el("Timestamp", Instant.now().xmlDateTime()) + el("PartnerID", cfg.partnerId) + el("UserID", cfg.userId) + // SystemID + // Product + el("OrderDetails") { + when (order) { + is EbicsOrder.V2_5 -> { + // TODO + } + is EbicsOrder.V3 -> { + el("AdminOrderType", order.type) + el("BTUOrderParams") { + service(order) + el("SignatureFlag", "true") + } + } + } + } + bankDigest() + el("NumSegments", uploadData.segments.size.toString()) + + } + el("mutable/TransactionPhase", "Initialisation") + } + el("AuthSignature") + el("body") { + el("DataTransfer") { + el("DataEncryptionInfo") { + attr("authenticate", "true") + el("EncryptionPubKeyDigest") { + attr("Version", "E002") + attr("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256") + text(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).encodeBase64()) + } + el("TransactionKey", uploadData.transactionKey.encodeBase64()) + } + el("SignatureData") { + attr("authenticate", "true") + text(uploadData.userSignatureDataEncrypted) + } + el("DataDigest") { + attr("SignatureVersion", "A006") + text(uploadData.dataDigest.encodeBase64()) + } + } + } + } + } + + fun uploadTransfer( + transactionId: String, + uploadData: PreparedUploadData, + segmentNumber: Int + ): ByteArray { + return signedRequest { + el("header") { + attr("authenticate", "true") + el("static") { + el("HostID", cfg.hostId) + el("TransactionID", transactionId) + } + el("mutable") { + el("TransactionPhase", "Transfer") + el("SegmentNumber") { + attr("lastSegment", if (uploadData.segments.size == segmentNumber) "true" else "false") + text(segmentNumber.toString()) + } + } + } + el("AuthSignature") + el("body/DataTransfer/OrderData", uploadData.segments[segmentNumber-1]) + } + } + + /* ----- Helpers ----- */ + + /** Generate a signed ebicsRequest */ + private fun signedRequest(lambda: XmlBuilder.() -> Unit): ByteArray { + val doc = XmlBuilder.toDom("ebicsRequest", "urn:org:ebics:${order.schema}") { + attr("http://www.w3.org/2000/xmlns/", "xmlns", "urn:org:ebics:${order.schema}") + attr("http://www.w3.org/2000/xmlns/", "xmlns:ds", "http://www.w3.org/2000/09/xmldsig#") + attr("Version", order.schema) + attr("Revision", "1") + lambda() + } + XMLUtil.signEbicsDocument( + doc, + clientKeys.authentication_private_key + ) + return XMLUtil.convertDomToBytes(doc) + } + + private fun XmlBuilder.bankDigest() { + el("BankPubKeyDigests") { + el("Authentication") { + attr("Version", "X002") + attr("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256") + text(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key).encodeBase64()) + } + el("Encryption") { + attr("Version", "E002") + attr("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256") + text(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).encodeBase64()) + } + // Signature + } + el("SecurityMedium", "0000") + } + + private fun XmlBuilder.service(order: EbicsOrder.V3) { + el("Service") { + el("ServiceName", order.service!!) + if (order.scope != null) { + el("Scope", order.scope) + } + if (order.option != null) { + el("ServiceOption", order.option) + } + if (order.container != null) { + el("Container") { + attr("containerType", order.container) + } + } + el("MsgName") { + if (order.version != null) + attr("version", order.version) + text(order.message!!) + } + } + } + + companion object { + fun parseResponse(doc: Document): EbicsResponse<BTSResponse> { + return XmlDestructor.parse(doc, "ebicsResponse") { + var transactionID: String? = null + var numSegments: Int? = null + lateinit var technicalCode: EbicsReturnCode + lateinit var bankCode: EbicsReturnCode + var orderID: String? = null + var segmentNumber: Int? = null + var segment: ByteArray? = null + var dataEncryptionInfo: DataEncryptionInfo? = null + one("header", signed = true) { + one("static") { + transactionID = opt("TransactionID")?.text() + numSegments = opt("NumSegments")?.text()?.toInt() + } + one("mutable") { + segmentNumber = opt("SegmentNumber")?.text()?.toInt() + orderID = opt("OrderID")?.text() + technicalCode = EbicsReturnCode.lookup(one("ReturnCode").text()) + } + } + one("body") { + opt("DataTransfer") { + segment = one("OrderData").base64() + dataEncryptionInfo = opt("DataEncryptionInfo", signed = true) { + DataEncryptionInfo( + one("TransactionKey").base64(), + one("EncryptionPubKeyDigest").base64() + ) + } + } + bankCode = EbicsReturnCode.lookup(one("ReturnCode", signed = true).text()) + } + EbicsResponse( + bankCode = bankCode, + technicalCode = technicalCode, + content = BTSResponse( + transactionID = transactionID, + orderID = orderID, + segment = segment, + dataEncryptionInfo = dataEncryptionInfo, + numSegments = numSegments, + segmentNumber = segmentNumber + ) + ) + } + } + } +} + +class BTSResponse( + val transactionID: String?, + val orderID: String?, + val dataEncryptionInfo: DataEncryptionInfo?, + val segment: ByteArray?, + val segmentNumber: Int?, + val numSegments: Int? +) +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsCommon.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsCommon.kt @@ -0,0 +1,436 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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/> + */ + +package tech.libeufin.ebics + +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.utils.io.jvm.javaio.* +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import org.w3c.dom.Document +import org.xml.sax.SAXException +import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil +import java.io.InputStream +import java.io.SequenceInputStream +import java.security.interfaces.RSAPrivateCrtKey +import java.time.Instant +import java.util.* + +internal val logger: Logger = LoggerFactory.getLogger("libeufin-ebics") + +/** Supported documents that can be downloaded via EBICS */ +enum class SupportedDocument { + PAIN_002, + PAIN_002_LOGS, + CAMT_053, + CAMT_052, + CAMT_054 +} + +/** EBICS related errors */ +sealed class EbicsError(msg: String, cause: Throwable? = null): Exception(msg, cause) { + /** Network errors */ + class Network(msg: String, cause: Throwable): EbicsError(msg, cause) + /** Http errors */ + class HTTP(msg: String, val status: HttpStatusCode): EbicsError(msg) + /** EBICS protocol & XML format error */ + class Protocol(msg: String, cause: Throwable? = null): EbicsError(msg, cause) + /** EBICS protocol & XML format error */ + class Code(msg: String, val technicalCode: EbicsReturnCode, val bankCode: EbicsReturnCode): EbicsError(msg) +} + +/** POST an EBICS request [msg] to [bankUrl] returning a parsed XML response */ +suspend fun HttpClient.postToBank( + bankUrl: String, + msg: ByteArray, + phase: String, + stepLogger: StepLogger +): Document { + stepLogger.logRequest(msg) + val res = try { + post(urlString = bankUrl) { + contentType(ContentType.Text.Xml) + setBody(msg) + } + } catch (e: Exception) { + throw EbicsError.Network("$phase: failed to contact bank", e) + } + + if (res.status != HttpStatusCode.OK) { + stepLogger.logFailure(res) + throw EbicsError.HTTP("$phase: bank HTTP error: ${res.status}", res.status) + } + try { + val bodyStream = res.bodyAsChannel().toInputStream() + val loggedStream = stepLogger.logResponse(bodyStream) + return XMLUtil.parseIntoDom(loggedStream) + } catch (e: SAXException) { + throw EbicsError.Protocol("$phase: invalid XML bank response", e) + } catch (e: Exception) { + throw EbicsError.Network("$phase: failed read bank response", e) + } +} + +/** POST an EBICS BTS request [xmlReq] using [client] returning a validated and parsed XML response */ +suspend fun EbicsBTS.postBTS( + client: HttpClient, + xmlReq: ByteArray, + phase: String, + stepLogger: StepLogger +): BTSResponse { + val doc = client.postToBank(cfg.baseUrl, xmlReq, phase, stepLogger) + try { + XMLUtil.verifyEbicsDocument( + doc, + bankKeys.bank_authentication_public_key + ) + } catch (e: Exception) { + throw EbicsError.Protocol("$phase ${order.description()}: invalid signature", e) + } + val response = try { + EbicsBTS.parseResponse(doc) + } catch (e: Exception) { + throw EbicsError.Protocol("$phase ${order.description()}: invalid ebics response", e) + } + logger.debug { + buildString { + append(phase) + response.content.transactionID?.let { + append(" for ") + append(it) + } + append(": ") + append(response.technicalCode) + append(" & ") + append(response.bankCode) + } + } + return response.okOrFail(phase) +} + +/** High level EBICS client */ +class EbicsClient( + val cfg: EbicsHostConfig, + val client: HttpClient, + val dao: EbicsDAO, + val ebicsLogger: EbicsLogger, + val clientKeys: ClientPrivateKeysFile, + val bankKeys: BankPublicKeysFile +) { + /** + * 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. + */ + suspend fun <T> download( + order: EbicsOrder, + startDate: Instant? = null, + endDate: Instant? = null, + peek: Boolean = false, + processing: suspend (InputStream) -> T, + ): T { + val description = order.description() + logger.debug { + buildString { + append("Download order ") + append(description) + if (startDate != null) { + append(" from ") + append(startDate) + if (endDate != null) { + append(" to ") + append(endDate) + } + } + } + } + val impl = EbicsBTS(cfg, bankKeys, clientKeys, order) + + // Close interrupted + val interruptedLog = ebicsLogger.tx("INTD") + while (true) { + val tId = dao.first() + if (tId == null) break + val xml = impl.downloadReceipt(tId, false) + try { + impl.postBTS(client, xml, "Closing interrupted transaction ${tId}", interruptedLog.step(tId)) + } catch (e: Exception) { + when (e) { + // Transaction already closed or expired - EBICS protocol error + is EbicsError.Code if e.technicalCode == EbicsReturnCode.EBICS_TX_UNKNOWN_TXID -> {} + // Transaction already closed or expired - HTTP protocol error for non compliant banks + is EbicsError.HTTP if e.status == HttpStatusCode.BadRequest -> {} + // Unexpected error + else -> throw e + } + logger.debug("${e.fmt()}") + } + dao.remove(tId) + } + + val txLog = ebicsLogger.tx(order) + + // We need to run the logic in a non-cancelable context because we need to send + // a receipt for each open download transaction, otherwise we'll be stuck in an + // error loop until the pending transaction timeout. + val (tId, initContent) = withContext(NonCancellable) { + // Init phase + val initReq = impl.downloadInitialization(startDate, endDate) + val initContent = impl.postBTS(client, initReq, "Download init $description", txLog.step("init")) + val tId = requireNotNull(initContent.transactionID) { + "Download init $description: missing transaction ID" + } + dao.register(tId) + Pair(tId, initContent) + } + val howManySegments = requireNotNull(initContent.numSegments) { + "Download init $description: missing num segments" + } + val firstSegment = requireNotNull(initContent.segment) { + "Download init $description: missing OrderData" + } + val dataEncryptionInfo = requireNotNull(initContent.dataEncryptionInfo) { + "Download init $description: missing EncryptionInfo" + } + + // Transfer phase + val segments = mutableListOf(firstSegment) + for (x in 2 .. howManySegments) { + val transReq = impl.downloadTransfer(x, howManySegments, tId) + val transResp = impl.postBTS(client, transReq, "Download transfer $description", txLog.step("transfer$x")) + val segment = requireNotNull(transResp.segment) { + "Download transfer: missing encrypted segment" + } + segments.add(segment) + } + + + // Decompress encrypted chunks + val payloadStream = try { + decryptAndDecompressPayload( + clientKeys.encryption_private_key, + dataEncryptionInfo, + segments + ) + } catch (e: Exception) { + throw EbicsError.Protocol("invalid chunks", e) + } + + val container = when (order) { + is EbicsOrder.V2_5 -> "rax" // TODO infer ? + is EbicsOrder.V3 -> order.container ?: "xml" + } + val loggedStream = txLog.payload(payloadStream, container) + + // Run business logic + val res = runCatching { + processing(loggedStream) + } + + // First send a proper EBICS transaction receipt + val xml = impl.downloadReceipt(tId, res.isSuccess && !peek) + impl.postBTS(client, xml, "Download receipt $description", txLog.step("receipt")) + runCatching { dao.remove(tId) } + // Then throw business logic exception if any + return res.getOrThrow() + } + + /** + * Performs an EBICS upload transaction of [order] using [payload]. + * + * It conducts init -> upload phases. + * + * Returns upload orderID + */ + suspend fun upload( + order: EbicsOrder, + payload: ByteArray, + ): String { + val description = order.description(); + logger.debug { "Upload order $description" } + val txLog = ebicsLogger.tx(order) + val impl = EbicsBTS(cfg, bankKeys, clientKeys, order) + val preparedPayload = prepareUploadPayload(cfg, clientKeys, bankKeys, payload) + + // Init phase + val initXml = impl.uploadInitialization(preparedPayload) + val initResp = impl.postBTS(client, initXml, "Upload init $description", txLog.step("init")) + val tId = requireNotNull(initResp.transactionID) { + "Upload init $description: missing transaction ID" + } + val orderId = requireNotNull(initResp.orderID) { + "Upload init $description: missing order ID" + } + + txLog.payload(payload, "xml") + + // Transfer phase + for (i in 1..preparedPayload.segments.size) { + val transferXml = impl.uploadTransfer(tId, preparedPayload, i) + impl.postBTS(client, transferXml, "Upload transfer $description", txLog.step("transfer$i")) + } + return orderId + } +} + +suspend fun HEV( + client: HttpClient, + cfg: EbicsHostConfig, + ebicsLogger: EbicsLogger +): List<VersionNumber> { + logger.info("Doing administrative request HEV") + val txLog = ebicsLogger.tx("HEV") + val req = EbicsAdministrative.HEV(cfg) + val xml = client.postToBank(cfg.baseUrl, req, "HEV", txLog.step()) + return EbicsAdministrative.parseHEV(xml).okOrFail("HEV") +} + +suspend fun keyManagement( + cfg: EbicsHostConfig, + privs: ClientPrivateKeysFile, + client: HttpClient, + ebicsLogger: EbicsLogger, + order: EbicsKeyMng.Order, + ebics3: Boolean +): EbicsResponse<InputStream?> { + logger.info("Doing key request $order") + val txLog = ebicsLogger.tx(order.name) + // TODO is this still necessary ? + val req = EbicsKeyMng(cfg, privs, ebics3).request(order) + val xml = client.postToBank(cfg.baseUrl, req, order.name, txLog.step()) + return EbicsKeyMng.parseResponse(xml, privs.encryption_private_key) +} + +class PreparedUploadData( + val transactionKey: ByteArray, + val userSignatureDataEncrypted: String, + val dataDigest: ByteArray, + val segments: List<String> +) + +/** Signs, encrypts and format EBICS BTS payload */ +fun prepareUploadPayload( + cfg: EbicsHostConfig, + clientKeys: ClientPrivateKeysFile, + bankKeys: BankPublicKeysFile, + payload: ByteArray, +): PreparedUploadData { + val payloadDigest = CryptoUtil.digestEbicsOrderA006(payload) + val innerSignedEbicsXml = XmlBuilder.toBytes("UserSignatureData") { + attr("xmlns", "http://www.ebics.org/S002") + el("OrderSignatureData") { + el("SignatureVersion", "A006") + el("SignatureValue", CryptoUtil.signEbicsA006( + payloadDigest, + clientKeys.signature_private_key, + ).encodeBase64()) + el("PartnerID", cfg.partnerId) + el("UserID", cfg.userId) + } + } + // Generate ephemeral transaction key + val (transactionKey, encryptedTransactionKey) = CryptoUtil.genEbicsE002Key(bankKeys.bank_encryption_public_key) + // Compress and encrypt order signature + val orderSignature = CryptoUtil.encryptEbicsE002( + transactionKey, + innerSignedEbicsXml.inputStream().deflate() + ).encodeBase64() + // Compress and encrypt payload + val encrypted = CryptoUtil.encryptEbicsE002( + transactionKey, + payload.inputStream().deflate() + ) + // Chunks of 1MB and encode segments + val segments = encrypted.encodeBase64().chunked(1000000) + + return PreparedUploadData( + encryptedTransactionKey, + orderSignature, + payloadDigest, + segments + ) +} + +/** Decrypts and decompresses EBICS BTS payload */ +fun decryptAndDecompressPayload( + clientEncryptionKey: RSAPrivateCrtKey, + encryptionInfo: DataEncryptionInfo, + segments: List<ByteArray> +): InputStream { + val transactionKey = CryptoUtil.decryptEbicsE002Key(clientEncryptionKey, encryptionInfo.transactionKey) + return SequenceInputStream(Collections.enumeration(segments.map { it.inputStream() })) // Aggregate + .run { + CryptoUtil.decryptEbicsE002( + transactionKey, + this + ) + }.inflate() +} + +/** Generate a secure random nonce of [size] bytes */ +fun getNonce(size: Int): ByteArray { + return ByteArray(size / 8).secureRand() +} + +private val EBICS_ID_ALPHABET = ('A'..'Z') + ('0'..'9') + +fun randEbicsId(): String { + return List(34) { EBICS_ID_ALPHABET.random() }.joinToString("") +} + +class DataEncryptionInfo( + val transactionKey: ByteArray, + val bankPubDigest: ByteArray +) + +class EbicsResponse<T>( + val technicalCode: EbicsReturnCode, + val bankCode: EbicsReturnCode, + internal val content: T +) { + /** Checks that return codes are both EBICS_OK */ + fun ok(): T? { + return if (technicalCode.kind() != EbicsReturnCode.Kind.Error && + bankCode.kind() != EbicsReturnCode.Kind.Error) { + content + } else { + null + } + } + + /** Checks that return codes are both EBICS_OK or throw an exception */ + fun okOrFail(phase: String): T { + if (technicalCode.kind() == EbicsReturnCode.Kind.Error) { + throw EbicsError.Code("$phase has technical error: $technicalCode", technicalCode, bankCode) + } else if (bankCode.kind() == EbicsReturnCode.Kind.Error) { + throw EbicsError.Code("$phase has bank error: $bankCode", technicalCode, bankCode) + } else { + return content + } + } +} +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsConstants.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsConstants.kt @@ -0,0 +1,108 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 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/> + */ + +package tech.libeufin.ebics + + +// TODO import missing using a script +@Suppress("SpellCheckingInspection") +enum class EbicsReturnCode(val code: String) { + EBICS_OK("000000"), + EBICS_DOWNLOAD_POSTPROCESS_DONE("011000"), + EBICS_DOWNLOAD_POSTPROCESS_SKIPPED("011001"), + EBICS_TX_SEGMENT_NUMBER_UNDERRUN("011101"), + EBICS_AUTHENTICATION_FAILED("061001"), + EBICS_INVALID_REQUEST("061002"), + EBICS_INTERNAL_ERROR("061099"), + EBICS_TX_RECOVERY_SYNC("061101"), + EBICS_AUTHORISATION_ORDER_IDENTIFIER_FAILED("090003"), + EBICS_INVALID_ORDER_DATA_FORMAT("090004"), + EBICS_NO_DOWNLOAD_DATA_AVAILABLE("090005"), + + // Transaction administration + EBICS_INVALID_USER_OR_USER_STATE("091002"), + EBICS_USER_UNKNOWN("091003"), + EBICS_INVALID_USER_STATE("091004"), + EBICS_INVALID_ORDER_IDENTIFIER("091005"), + EBICS_UNSUPPORTED_ORDER_TYPE("091006"), + EBICS_INVALID_XML("091010"), + EBICS_TX_MESSAGE_REPLAY("091103"), + EBICS_TX_SEGMENT_NUMBER_EXCEEDED("091104"), + EBICS_TX_UNKNOWN_TXID("091101"), + EBICS_INVALID_REQUEST_CONTENT("091113"), + EBICS_PROCESSING_ERROR("091116"), + + // Key-Management errors + EBICS_KEYMGMT_UNSUPPORTED_VERSION_SIGNATURE("091201"), + EBICS_KEYMGMT_UNSUPPORTED_VERSION_AUTHENTICATION("091202"), + EBICS_KEYMGMT_UNSUPPORTED_VERSION_ENCRYPTION("091203"), + EBICS_KEYMGMT_KEYLENGTH_ERROR_SIGNATURE("091204"), + EBICS_KEYMGMT_KEYLENGTH_ERROR_AUTHENTICATION("091205"), + EBICS_KEYMGMT_KEYLENGTH_ERROR_ENCRYPTION("091206"), + EBICS_X509_CERTIFICATE_EXPIRED("091208"), + EBICS_X509_CERTIFICATE_NOT_VALID_YET("091209"), + EBICS_X509_WRONG_KEY_USAGE("091210"), + EBICS_X509_WRONG_ALGORITHM("091211"), + EBICS_X509_INVALID_THUMBPRINT("091212"), + EBICS_X509_CTL_INVALID("091213"), + EBICS_X509_UNKNOWN_CERTIFICATE_AUTHORITY("091214"), + EBICS_X509_INVALID_POLICY("091215"), + EBICS_X509_INVALID_BASIC_CONSTRAINTS("091216"), + EBICS_ONLY_X509_SUPPORT("091217"), + EBICS_KEYMGMT_DUPLICATE_KEY("091218"), + EBICS_CERTIFICATE_VALIDATION_ERROR("091219"), + + // Pre-erification errors + EBICS_SIGNATURE_VERIFICATION_FAILED("091301"), + EBICS_ACCOUNT_AUTHORISATION_FAILED("091302"), + EBICS_AMOUNT_CHECK_FAILED("091303"), + EBICS_SIGNER_UNKNOWN("091304"), + EBICS_INVALID_SIGNER_STATE("091305"), + EBICS_DUPLICATE_SIGNATURE("091306"); + + enum class Kind { + Information, + Note, + Warning, + Error + } + + fun kind(): Kind { + return when (val errorClass = code.substring(0..1)) { + "00" -> Kind.Information + "01" -> Kind.Note + "03" -> Kind.Warning + "06", "09" -> Kind.Error + else -> throw Exception("Unknown EBICS status code error class: $errorClass") + } + } + + companion object { + fun lookup(code: String): EbicsReturnCode { + for (x in entries) { + if (x.code == code) { + return x + } + } + throw Exception( + "Unknown EBICS status code: $code" + ) + } + } +} +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsDAO.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsDAO.kt @@ -0,0 +1,48 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 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/> + */ + +package tech.libeufin.ebics + +import tech.libeufin.common.db.DbPool + +/** Data access logic for EBICS transaction */ +class EbicsDAO(private val db: DbPool) { + /** Register a pending transaction */ + suspend fun register(id: String) = db.serializable( + "INSERT INTO pending_ebics_transactions (tx_id) VALUES (?) ON CONFLICT DO NOTHING" + ) { + bind(id) + executeUpdate() + } + + /** Remove pending transaction */ + suspend fun remove(id: String) = db.serializable( + "DELETE FROM pending_ebics_transactions WHERE tx_id = ?" + ) { + bind(id) + executeUpdate() + } + + /** Get first pending transaction */ + suspend fun first(): String? = db.serializable( + "SELECT tx_id FROM pending_ebics_transactions LIMIT 1" + ) { + oneOrNull { it.getString(1) } + } +} +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsKeyMng.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/EbicsKeyMng.kt @@ -0,0 +1,201 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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/> + */ + +package tech.libeufin.ebics + +import org.w3c.dom.Document +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.common.decodeBase64 +import tech.libeufin.common.deflate +import tech.libeufin.common.encodeBase64 +import tech.libeufin.common.encodeUpHex +import tech.libeufin.ebics.EbicsKeyMng.Order.* +import java.io.InputStream +import java.security.interfaces.RSAPrivateCrtKey +import java.security.interfaces.RSAPublicKey +import java.time.Instant + +/** EBICS protocol for key management */ +class EbicsKeyMng( + private val cfg: EbicsHostConfig, + private val clientKeys: ClientPrivateKeysFile, + private val ebics3: Boolean +) { + private val schema = if (ebics3) "H005" else "H004" + + enum class Order { + INI, + HIA, + HPB + } + + fun request(order: Order): ByteArray { + val (name, securityMedium, orderAttribute) = when (order) { + INI, HIA -> Triple("ebicsUnsecuredRequest", "0200", "DZNNN") + HPB -> Triple("ebicsNoPubKeyDigestsRequest", "0000", "DZHNN") + } + val data = when (order) { + INI -> XMLOrderData("SignaturePubKeyOrderData", "http://www.ebics.org/S00${if (ebics3) 2 else 1}") { + el("SignaturePubKeyInfo") { + RSAKeyXml(clientKeys.signature_private_key) + el("SignatureVersion", "A006") + } + } + HIA -> XMLOrderData("HIARequestOrderData", "urn:org:ebics:$schema") { + el("AuthenticationPubKeyInfo") { + RSAKeyXml(clientKeys.authentication_private_key) + el("AuthenticationVersion", "X002") + } + el("EncryptionPubKeyInfo") { + RSAKeyXml(clientKeys.encryption_private_key) + el("EncryptionVersion", "E002") + } + } + HPB -> null + } + val sign = order == HPB + val doc = XmlBuilder.toDom(name, "urn:org:ebics:$schema") { + attr("http://www.w3.org/2000/xmlns/", "xmlns", "urn:org:ebics:$schema") + attr("http://www.w3.org/2000/xmlns/", "xmlns:ds", "http://www.w3.org/2000/09/xmldsig#") + attr("Version", schema) + attr("Revision", "1") + el("header") { + attr("authenticate", "true") + el("static") { + el("HostID", cfg.hostId) + if (order == HPB) { + el("Nonce", getNonce(128).encodeUpHex()) + el("Timestamp", Instant.now().xmlDateTime()) + } + el("PartnerID", cfg.partnerId) + el("UserID", cfg.userId) + el("OrderDetails") { + if (ebics3) { + el("AdminOrderType", order.name) + } else { + el("OrderType", order.name) + el("OrderAttribute", orderAttribute) + } + } + el("SecurityMedium", securityMedium) + } + el("mutable") + } + if (sign) el("AuthSignature") + el("body") { + if (data != null) el("DataTransfer/OrderData", data) + } + } + if (sign) XMLUtil.signEbicsDocument(doc, clientKeys.authentication_private_key) + return XMLUtil.convertDomToBytes(doc) + } + + private fun XmlBuilder.RSAKeyXml(key: RSAPrivateCrtKey) { + if (ebics3) { + val cert = CryptoUtil.X509CertificateFromRSAPrivate(key, "LibEuFin EBICS") + el("ds:X509Data") { + el("ds:X509Certificate", cert.encoded.encodeBase64()) + } + } else { + el("PubKeyValue") { + el("ds:RSAKeyValue") { + el("ds:Modulus", key.modulus.encodeBase64()) + el("ds:Exponent", key.publicExponent.encodeBase64()) + } + } + } + } + + private fun XMLOrderData(name: String, schema: String, build: XmlBuilder.() -> Unit): String { + return XmlBuilder.toBytes(name) { + attr("xmlns:ds", "http://www.w3.org/2000/09/xmldsig#") + attr("xmlns", schema) + build() + el("PartnerID", cfg.partnerId) + el("UserID", cfg.userId) + }.inputStream().deflate().encodeBase64() + } + + companion object { + fun parseResponse(doc: Document, clientEncryptionKey: RSAPrivateCrtKey): EbicsResponse<InputStream?> { + return XmlDestructor.parse(doc, "ebicsKeyManagementResponse") { + lateinit var technicalCode: EbicsReturnCode + lateinit var bankCode: EbicsReturnCode + var payload: InputStream? = null + one("header", signed = true) { + one("mutable") { + technicalCode = EbicsReturnCode.lookup(one("ReturnCode").text()) + } + } + one("body") { + bankCode = EbicsReturnCode.lookup(one("ReturnCode", signed = true).text()) + payload = opt("DataTransfer") { + val descriptionInfo = one("DataEncryptionInfo", signed = true) { + DataEncryptionInfo( + one("TransactionKey").base64(), + one("EncryptionPubKeyDigest").base64() + ) + } + val chunk = one("OrderData").base64() + decryptAndDecompressPayload( + clientEncryptionKey, + descriptionInfo, + listOf(chunk) + ) + } + } + EbicsResponse( + technicalCode = technicalCode, + bankCode, + content = payload + ) + } + } + + fun parseHpbOrder(data: InputStream): Pair<RSAPublicKey, RSAPublicKey> { + return XmlDestructor.parse(data, "HPBResponseOrderData") { + val authPub = one("AuthenticationPubKeyInfo") { + val version = one("AuthenticationVersion").text() + require(version == "X002") { "Expected authentication version X002 got unsupported $version" } + rsaPubKey() + } + val encPub = one("EncryptionPubKeyInfo") { + val version = one("EncryptionVersion").text() + require(version == "E002") { "Expected encryption version E002 got unsupported $version" } + rsaPubKey() + } + Pair(authPub, encPub) + } + } + } +} + +fun XmlDestructor.rsaPubKey(): RSAPublicKey { + val cert = opt("X509Data")?.one("X509Certificate")?.text()?.decodeBase64() + return if (cert != null) { + CryptoUtil.RSAPublicFromCertificate(cert) + } else { + one("PubKeyValue").one("RSAKeyValue") { + CryptoUtil.RSAPublicFromComponents( + one("Modulus").base64(), + one("Exponent").base64(), + ) + } + } +} +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/cli.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/cli.kt @@ -0,0 +1,30 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 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/> + */ + +package tech.libeufin.ebics + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.types.path + +fun CliktCommand.ebicsLogOption() = option( + "--debug-ebics", + help = "Log EBICS transactions steps and payload at log_dir", + metavar = "log_dir" +).path() +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/config.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/config.kt @@ -0,0 +1,39 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 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/> + */ + +package tech.libeufin.ebics + +import java.nio.file.Path + +interface EbicsKeysConfig { + val clientPrivateKeysPath: Path + val bankPublicKeysPath: Path +} + +interface EbicsSetupConfig { + val bankAuthPubKey: ByteArray? + val bankEncPubKey: ByteArray? +} + +interface EbicsHostConfig { + val baseUrl: String + val hostId: String + val userId: String + val partnerId: String +} +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/db/Database.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/db/Database.kt @@ -0,0 +1,38 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 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/> + */ + +package tech.libeufin.ebisync.db + +import kotlinx.coroutines.flow.Flow +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import tech.libeufin.common.db.* +import tech.libeufin.common.* +import tech.libeufin.ebics.EbicsDAO +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +private val logger: Logger = LoggerFactory.getLogger("libeufin-ebisync-db") + +class Database( + dbConfig: DatabaseConfig +): DbPool(dbConfig, "libeufin-ebisync") { + // DAOs + val ebics = EbicsDAO(this) +} +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/http.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/http.kt @@ -0,0 +1,40 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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/> + */ + +package tech.libeufin.ebics + +import io.ktor.client.* +import io.ktor.client.plugins.* +import io.ktor.client.engine.mock.MockEngine +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** Use for unit testing */ +var MOCK_ENGINE: MockEngine? = null + +/** Create an HTTP client for EBICS requests */ +fun httpClient(): HttpClient = MOCK_ENGINE?.let { + HttpClient(it) +} ?: HttpClient { + install(HttpTimeout) { + // It can take a lot of time for the bank to generate documents + socketTimeoutMillis = 5 * 60 * 1000 + } +} +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/keys.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/keys.kt @@ -0,0 +1,220 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 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/> + */ + +package tech.libeufin.ebics + +import kotlinx.serialization.Contextual +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encodeToString +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import tech.libeufin.common.Base32Crockford +import tech.libeufin.common.crypto.CryptoUtil +import java.nio.file.* +import java.security.interfaces.RSAPrivateCrtKey +import java.security.interfaces.RSAPublicKey +import kotlin.io.path.* + +val JSON = Json { + this.serializersModule = SerializersModule { + contextual(RSAPrivateCrtKey::class) { RSAPrivateCrtKeySerializer } + contextual(RSAPublicKey::class) { RSAPublicKeySerializer } + } +} + +/** + * Converts base 32 representation of RSA public keys and vice versa. + */ +object RSAPublicKeySerializer : KSerializer<RSAPublicKey> { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("RSAPublicKey", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: RSAPublicKey) { + encoder.encodeString(Base32Crockford.encode(value.encoded)) + } + + // Caller must handle exceptions here. + override fun deserialize(decoder: Decoder): RSAPublicKey { + val fieldValue = decoder.decodeString() + val bytes = Base32Crockford.decode(fieldValue) + return CryptoUtil.loadRSAPublic(bytes) + } +} + +/** + * Converts base 32 representation of RSA private keys and vice versa. + */ +object RSAPrivateCrtKeySerializer : KSerializer<RSAPrivateCrtKey> { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("RSAPrivateCrtKey", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: RSAPrivateCrtKey) { + encoder.encodeString(Base32Crockford.encode(value.encoded)) + } + + // Caller must handle exceptions here. + override fun deserialize(decoder: Decoder): RSAPrivateCrtKey { + val fieldValue = decoder.decodeString() + val bytes = Base32Crockford.decode(fieldValue) + return CryptoUtil.loadRSAPrivate(bytes) + } +} + +/** + * Structure of the JSON filethat contains the client + * private keys on disk. + */ +@Serializable +data class ClientPrivateKeysFile( + @Contextual val signature_private_key: RSAPrivateCrtKey, + @Contextual val encryption_private_key: RSAPrivateCrtKey, + @Contextual val authentication_private_key: RSAPrivateCrtKey, + var submitted_ini: Boolean, + var submitted_hia: Boolean +) + +/** + * Structure of the JSON file that contains the bank + * public keys on disk. + */ +@Serializable +data class BankPublicKeysFile( + @Contextual val bank_encryption_public_key: RSAPublicKey, + @Contextual val bank_authentication_public_key: RSAPublicKey, + var accepted: Boolean +) + +/** + * Generates new client private keys. + * + * @return [ClientPrivateKeysFile] + */ +fun generateNewKeys(): ClientPrivateKeysFile = + ClientPrivateKeysFile( + authentication_private_key = CryptoUtil.genRSAPrivate(2048), + encryption_private_key = CryptoUtil.genRSAPrivate(2048), + signature_private_key = CryptoUtil.genRSAPrivate(2048), + submitted_hia = false, + submitted_ini = false + ) + +internal inline fun <reified T> persistJsonFile(obj: T, path: Path, name: String) { + val content = try { + JSON.encodeToString(obj) + } catch (e: Exception) { + throw Exception("Could not encode $name", e) + } + val parent = try { + path.parent ?: path.absolute().parent + } catch (e: Exception) { + throw Exception("Could not write $name at '$path'", e) + } + try { + // Write to temp file then rename to enable atomicity when possible + val tmp = Files.createTempFile(parent, "tmp_", "_${path.fileName}") + tmp.writeText(content) + tmp.moveTo(path, StandardCopyOption.REPLACE_EXISTING) + } catch (e: Exception) { + when { + !parent.isWritable() -> throw Exception("Could not write $name at '$path': permission denied on '$parent'") + !path.isWritable() -> throw Exception("Could not write $name at '$path': permission denied") + else -> throw Exception("Could not write $name at '$path'", e) + } + } +} + +/** + * Persist the bank keys file to disk + * + * @param location the keys file location + */ +fun persistBankKeys(keys: BankPublicKeysFile, location: Path) = persistJsonFile(keys, location, "bank public keys") + +/** + * Persist the client keys file to disk + * + * @param location the keys file location + */ +fun persistClientKeys(keys: ClientPrivateKeysFile, location: Path) = persistJsonFile(keys, location, "client private keys") + + +inline fun <reified T> loadJsonFile(path: Path, name: String): T? { + val content = try { + path.readText() + } catch (e: Exception) { + when (e) { + is NoSuchFileException -> return null + is AccessDeniedException -> throw Exception("Could not read $name at '$path': permission denied") + else -> throw Exception("Could not read $name at '$path'", e) + } + } + return try { + JSON.decodeFromString(content) + } catch (e: Exception) { + throw Exception("Could not decode $name at '$path'", e) + } +} + +/** + * Load the bank keys file from disk. + * + * @param location the keys file location. + * @return the internal JSON representation of the keys file, + * or null if the file does not exist + */ +fun loadBankKeys(location: Path): BankPublicKeysFile? = loadJsonFile(location, "bank public keys") + +/** + * Load the client keys file from disk. + * + * @param location the keys file location. + * @return the internal JSON representation of the keys file, + * or null if the file does not exist + */ +fun loadClientKeys(location: Path): ClientPrivateKeysFile? = loadJsonFile(location, "client private keys") + + +/** + * Load client and bank keys from disk. + * Checks that the keying process has been fully completed. + * + * Helps to fail before starting to talk EBICS to the bank. + * + * @param cfg configuration handle. + * @return both client and bank keys + */ +fun expectFullKeys(cfg: EbicsKeysConfig, setupCmd: String): Pair<ClientPrivateKeysFile, BankPublicKeysFile> { + val clientKeys = loadClientKeys(cfg.clientPrivateKeysPath) + if (clientKeys == null) { + throw Exception("Missing client private keys file at '${cfg.clientPrivateKeysPath}', run '$setupCmd' first") + } else if (!clientKeys.submitted_ini || !clientKeys.submitted_hia) { + throw Exception("Unsubmitted client private keys, run '$setupCmd' first") + } + val bankKeys = loadBankKeys(cfg.bankPublicKeysPath) + if (bankKeys == null) { + throw Exception("Missing bank public keys at '${cfg.bankPublicKeysPath}', run '$setupCmd' first") + } else if (!bankKeys.accepted) { + throw Exception("Unaccepted bank public keys, run '$setupCmd' until accepting the bank keys") + } + return Pair(clientKeys, bankKeys) +} +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/order.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/order.kt @@ -0,0 +1,146 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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/> + */ + +package tech.libeufin.ebics + +sealed class EbicsOrder(val schema: String) { + data class V2_5( + val type: String, + val attribute: String + ): EbicsOrder("H004") + data class V3( + val type: String, + val service: String? = null, + val scope: String? = null, + val message: String? = null, + val version: String? = null, + val container: String? = null, + val option: String? = null + ): EbicsOrder("H005") { + companion object { + val WSS_PARAMS = V3( + type = "BTD", + service = "OTH", + scope = "DE", + message = "wssparam" + ) + val HAC = V3(type = "HAC") + val HKD = V3(type = "HKD") + val HAA = V3(type = "HAA") + } + } + + fun description(): String = buildString { + when (this@EbicsOrder) { + is V2_5 -> { + append(type) + append('-') + append(attribute) + } + is V3 -> { + append(type) + for (part in sequenceOf(service, scope, option, container)) { + if (part != null) { + append('-') + append(part) + } + } + if (message != null) { + append('-') + append(message) + if (version != null) { + append('.') + append(version) + } + } + } + } + } + + fun doc(): OrderDoc? { + return when (this) { + is V2_5 -> { + when (this.type) { + "HAC" -> OrderDoc.acknowledgement + "Z01" -> OrderDoc.status + "Z52" -> OrderDoc.report + "Z53" -> OrderDoc.statement + "Z54" -> OrderDoc.notification + else -> null + } + } + is V3 -> { + when (this.type) { + "HAC" -> OrderDoc.acknowledgement + "BTD" -> when (this.message) { + "pain.002" -> OrderDoc.status + "camt.052" -> OrderDoc.report + "camt.053" -> OrderDoc.statement + "camt.054" -> OrderDoc.notification + else -> null + } + else -> null + } + } + } + } + + /** Check if two EBICS order match ignoring the message version */ + fun match(other: EbicsOrder): Boolean = when (this) { + is V2_5 -> other is V2_5 + && type == other.type + && attribute == other.attribute + is V3 -> other is V3 + && type == other.type + && service == other.service + && scope == other.scope + && message == other.message + && container == other.container + && option == other.option + } +} + +enum class OrderDoc { + /// EBICS acknowledgement - CustomerAcknowledgement HAC pain.002 + acknowledgement, + /// Payment status - CustomerPaymentStatusReport pain.002 + status, + /// Account intraday reports - BankToCustomerAccountReport camt.052 + report, + /// Account statements - BankToCustomerStatement camt.053 + statement, + /// Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054 + notification; + + fun shortDescription(): String = when (this) { + acknowledgement -> "EBICS acknowledgement" + status -> "Payment status" + report -> "Account intraday reports" + statement -> "Account statements" + notification -> "Debit & credit notifications" + } + + fun fullDescription(): String = when (this) { + acknowledgement -> "EBICS acknowledgement - CustomerAcknowledgement HAC pain.002" + status -> "Payment status - CustomerPaymentStatusReport pain.002" + report -> "Account intraday reports - BankToCustomerAccountReport camt.052" + statement -> "Account statements - BankToCustomerStatement camt.053" + notification -> "Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054" + } +} +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/pdf.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/pdf.kt @@ -0,0 +1,114 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 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/> + */ + +package tech.libeufin.ebics + +import com.itextpdf.kernel.pdf.PdfDocument +import com.itextpdf.kernel.pdf.PdfWriter +import com.itextpdf.layout.Document +import com.itextpdf.layout.element.AreaBreak +import com.itextpdf.layout.element.Paragraph +import tech.libeufin.common.crypto.CryptoUtil +import java.io.ByteArrayOutputStream +import java.security.interfaces.RSAPrivateCrtKey +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +/** + * Generate the PDF document with all the client public keys + * to be sent on paper to the bank. + */ +fun generateKeysPdf( + clientKeys: ClientPrivateKeysFile, + cfg: EbicsHostConfig +): ByteArray { + val po = ByteArrayOutputStream() + val pdfWriter = PdfWriter(po) + val pdfDoc = PdfDocument(pdfWriter) + val date = LocalDateTime.now() + val dateStr = date.format(DateTimeFormatter.ISO_LOCAL_DATE) + + fun formatHex(ba: ByteArray): String { + var out = "" + for (i in ba.indices) { + val b = ba[i] + if (i > 0 && i % 16 == 0) { + out += "\n" + } + out += java.lang.String.format("%02X", b) + out += " " + } + return out + } + + fun writeCommon(doc: Document) { + doc.add( + Paragraph( + """ + Datum: $dateStr + Host-ID: ${cfg.hostId} + User-ID: ${cfg.userId} + Partner-ID: ${cfg.partnerId} + ES version: A006 + """.trimIndent() + ) + ) + } + + fun writeKey(doc: Document, priv: RSAPrivateCrtKey) { + val pub = CryptoUtil.RSAPublicFromPrivate(priv) + val hash = CryptoUtil.getEbicsPublicKeyHash(pub) + doc.add(Paragraph("Exponent:\n${formatHex(pub.publicExponent.toByteArray())}")) + doc.add(Paragraph("Modulus:\n${formatHex(pub.modulus.toByteArray())}")) + doc.add(Paragraph("SHA-256 hash:\n${formatHex(hash)}")) + } + + fun writeSigLine(doc: Document) { + doc.add(Paragraph("Ort / Datum: ________________")) + doc.add(Paragraph("Firma / Name: ________________")) + doc.add(Paragraph("Unterschrift: ________________")) + } + + Document(pdfDoc).use { + it.add(Paragraph("Signaturschlüssel").setFontSize(24f)) + writeCommon(it) + it.add(Paragraph("Öffentlicher Schlüssel (Public key for the electronic signature)")) + writeKey(it, clientKeys.signature_private_key) + it.add(Paragraph("\n")) + writeSigLine(it) + it.add(AreaBreak()) + + it.add(Paragraph("Authentifikationsschlüssel").setFontSize(24f)) + writeCommon(it) + it.add(Paragraph("Öffentlicher Schlüssel (Public key for the identification and authentication signature)")) + writeKey(it, clientKeys.authentication_private_key) + it.add(Paragraph("\n")) + writeSigLine(it) + it.add(AreaBreak()) + + it.add(Paragraph("Verschlüsselungsschlüssel").setFontSize(24f)) + writeCommon(it) + it.add(Paragraph("Öffentlicher Schlüssel (Public encryption key)")) + writeKey(it, clientKeys.encryption_private_key) + it.add(Paragraph("\n")) + writeSigLine(it) + } + pdfWriter.flush() + return po.toByteArray() +} +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/setup.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/setup.kt @@ -0,0 +1,234 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 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/> + */ + +package tech.libeufin.ebics + +import io.ktor.client.HttpClient +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.common.encodeUpHex +import tech.libeufin.common.fmtChunkByTwo +import tech.libeufin.ebics.EbicsKeyMng.Order.* +import java.time.Instant +import kotlin.io.path.Path +import kotlin.io.path.writeBytes +import java.nio.file.FileAlreadyExistsException +import java.nio.file.Path +import java.nio.file.StandardOpenOption + +/** Load client private keys at [path] or create new ones if missing */ +private fun loadOrGenerateClientKeys(path: Path): ClientPrivateKeysFile { + // If exists load from disk + val current = loadClientKeys(path) + if (current != null) return current + // Else create new keys + val newKeys = generateNewKeys() + persistClientKeys(newKeys, path) + logger.info("New client private keys created at '$path'") + return newKeys +} + +/** + * Asks the user to accept the bank public keys. + * + * @param bankKeys bank public keys, in format stored on disk. + * @return true if the user accepted, false otherwise. + */ +fun askUserToAcceptKeys(bankKeys: BankPublicKeysFile, cfg: EbicsSetupConfig): Boolean { + val encHash = CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key) + val authHash = CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key) + val authPubKey = cfg.bankAuthPubKey + val encPubKey = cfg.bankEncPubKey + if (authPubKey != null && encPubKey != null) { + if (encHash.contentEquals(encPubKey) && authHash.contentEquals(authPubKey)) { + logger.info("Accepting bank keys matching config hashes") + return true + } + throw Exception(buildString { + append("Bank keys does not match config hashes\nBank encryption key: ") + append(encHash.encodeUpHex().fmtChunkByTwo()) + append("\nConfig encryption key: ") + append(encPubKey.encodeUpHex().fmtChunkByTwo()) + append("\nBank authentication key: ") + append(authHash.encodeUpHex().fmtChunkByTwo()) + append("\nConfig authentication key: ") + append(authPubKey.encodeUpHex().fmtChunkByTwo()) + }) + } + println("The bank has the following keys:") + println("Encryption key: ${encHash.encodeUpHex().fmtChunkByTwo()}") + println("Authentication key: ${authHash.encodeUpHex().fmtChunkByTwo()}") + print("type 'yes, accept' to accept them: ") + val userResponse: String? = readlnOrNull() + return userResponse == "yes, accept" +} + + +/** + * Mere collector of the PDF generation steps. Fails the + * process if a problem occurs. + * + * @param privs client private keys. + * @param cfg configuration handle. + */ +private fun makePdf(privs: ClientPrivateKeysFile, cfg: EbicsHostConfig) { + val pdf = generateKeysPdf(privs, cfg) + val path = Path("/tmp/libeufin-ebics-keys-${Instant.now().epochSecond}.pdf") + try { + path.writeBytes(pdf, StandardOpenOption.CREATE_NEW) + } catch (e: Exception) { + if (e is FileAlreadyExistsException) throw Exception("PDF file exists already at '$path', not overriding it") + throw Exception("Could not write PDF to '$path'", e) + } + println("PDF file with keys created at '$path'") +} + + +/** Perform an EBICS public key management [order] using [client] and update on disk state */ +private suspend fun submitClientKeys( + keyCfg: EbicsKeysConfig, + hostCfg: EbicsHostConfig, + privs: ClientPrivateKeysFile, + client: HttpClient, + ebicsLogger: EbicsLogger, + order: EbicsKeyMng.Order, + ebics3: Boolean +) { + require(order != HPB) { "Only INI & HIA are supported for client keys" } + val resp = keyManagement(hostCfg, privs, client, ebicsLogger, order, ebics3) + if (resp.technicalCode == EbicsReturnCode.EBICS_INVALID_USER_OR_USER_STATE || resp.technicalCode == EbicsReturnCode.EBICS_INVALID_USER_STATE) { + throw Exception("$order status code ${resp.technicalCode}: either your IDs are incorrect, or you already have keys registered with this bank") + } + val orderData = resp.okOrFail(order.name) + when (order) { + INI -> privs.submitted_ini = true + HIA -> privs.submitted_hia = true + HPB -> {} + } + try { + persistClientKeys(privs, keyCfg.clientPrivateKeysPath) + } catch (e: Exception) { + throw Exception("Could not update the $order state on disk", e) + } +} + +/** Perform an EBICS private key management HPB using [client] */ +private suspend fun fetchPrivateKeys( + cfg: EbicsHostConfig, + privs: ClientPrivateKeysFile, + client: HttpClient, + ebicsLogger: EbicsLogger, + ebics3: Boolean +): BankPublicKeysFile { + val order = HPB + val resp = keyManagement(cfg, privs, client, ebicsLogger, order, ebics3) + if (resp.technicalCode == EbicsReturnCode.EBICS_AUTHENTICATION_FAILED) { + throw Exception("$order status code ${resp.technicalCode}: could not download bank keys, send client keys (and/or related PDF document with --generate-registration-pdf) to the bank") + } + val orderData = requireNotNull(resp.okOrFail(order.name)) { + "$order: missing order data" + } + val (authPub, encPub) = EbicsKeyMng.parseHpbOrder(orderData) + return BankPublicKeysFile( + bank_authentication_public_key = authPub, + bank_encryption_public_key = encPub, + accepted = false + ) +} + + +suspend fun ebicsSetup( + client: HttpClient, + ebicsLogger: EbicsLogger, + keyCfg: EbicsKeysConfig, + hostCfg: EbicsHostConfig, + setupCfg: EbicsSetupConfig, + forceKeysResubmission: Boolean, + generateRegistrationPdf: Boolean, + autoAcceptKeys: Boolean, + ebics3: Boolean +): Pair<ClientPrivateKeysFile, BankPublicKeysFile>{ + val clientKeys = loadOrGenerateClientKeys(keyCfg.clientPrivateKeysPath) + var bankKeys = loadBankKeys(keyCfg.bankPublicKeysPath) + + // Check EBICS 3 support + val versions = HEV(client, hostCfg, ebicsLogger) + logger.debug("HEV: {}", versions) + if (!versions.contains(VersionNumber(3.0f, "H005")) && !versions.contains(VersionNumber(3.02f, "H005"))) { + throw Exception("EBICS 3 is not supported by your bank") + } + + // Privs exist. Upload their pubs + val keysNotSub = !clientKeys.submitted_ini + if (!clientKeys.submitted_ini || forceKeysResubmission) + submitClientKeys(keyCfg, hostCfg, clientKeys, client, ebicsLogger, INI, ebics3) + // Eject PDF if the keys were submitted for the first time, or the user asked. + if (keysNotSub || generateRegistrationPdf) makePdf(clientKeys, hostCfg) + if (!clientKeys.submitted_hia || forceKeysResubmission) + submitClientKeys(keyCfg, hostCfg, clientKeys, client, ebicsLogger, HIA, ebics3) + + val fetchedBankKeys = fetchPrivateKeys(hostCfg, clientKeys, client, ebicsLogger, ebics3) + if (bankKeys == null) { + // Accept bank keys + logger.info("Bank keys stored at ${keyCfg.bankPublicKeysPath}") + try { + persistBankKeys(fetchedBankKeys, keyCfg.bankPublicKeysPath) + } catch (e: Exception) { + throw Exception("Could not store bank keys on disk", e) + } + bankKeys = fetchedBankKeys + } else { + // Check current bank keys + if (bankKeys.bank_encryption_public_key != fetchedBankKeys.bank_encryption_public_key) { + throw Exception(buildString { + append("On disk bank encryption key stored at ") + append(keyCfg.bankPublicKeysPath) + append(" doesn't match server key\nDisk: ") + append(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).encodeUpHex().fmtChunkByTwo()) + append("\nServer: ") + append(CryptoUtil.getEbicsPublicKeyHash(fetchedBankKeys.bank_encryption_public_key).encodeUpHex().fmtChunkByTwo()) + }) + } else if (bankKeys.bank_authentication_public_key != fetchedBankKeys.bank_authentication_public_key) { + throw Exception(buildString { + append("On disk bank authentication key stored at ") + append(keyCfg.bankPublicKeysPath) + append(" doesn't match server key\nDisk: ") + append(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key).encodeUpHex().fmtChunkByTwo()) + append("\nServer: ") + append(CryptoUtil.getEbicsPublicKeyHash(fetchedBankKeys.bank_authentication_public_key).encodeUpHex().fmtChunkByTwo()) + }) + } + } + + if (!bankKeys.accepted) { + // Finishing the setup by accepting the bank keys. + if (autoAcceptKeys) bankKeys.accepted = true + else bankKeys.accepted = askUserToAcceptKeys(bankKeys, setupCfg) + + if (!bankKeys.accepted) { + throw Exception("Cannot successfully finish the setup without accepting the bank keys") + } + try { + persistBankKeys(bankKeys, keyCfg.bankPublicKeysPath) + } catch (e: Exception) { + throw Exception("Could not set bank keys as accepted on disk", e) + } + } + + return Pair(clientKeys, bankKeys) +} +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/test/TxCheck.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/test/TxCheck.kt @@ -0,0 +1,93 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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/> + */ + +package tech.libeufin.ebics.test + +import io.ktor.client.* +import tech.libeufin.common.* +import tech.libeufin.ebics.* +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +private val logger: Logger = LoggerFactory.getLogger("libeufin-nexus") + +data class TxCheckResult( + var concurrentFetchAndFetch: Boolean = false, + var concurrentFetchAndSubmit: Boolean = false, + var concurrentSubmitAndSubmit: Boolean = false, + var idempotentClose: Boolean = false +) + +/** + * Test EBICS implementation's transactions semantic: + * - Can two fetch transactions run concurrently ? + * - Can a fetch & submit transactions run concurrently ? + * - Can two submit transactions run concurrently ? + * - Is closing a submit transaction idempotent + */ +suspend fun txCheck( + client: HttpClient, + cfg: EbicsHostConfig, + clientKeys: ClientPrivateKeysFile, + bankKeys: BankPublicKeysFile, + fetchOrder: EbicsOrder, + submitOrder: EbicsOrder +): TxCheckResult { + val result = TxCheckResult() + val fetch = EbicsBTS(cfg, bankKeys, clientKeys, fetchOrder) + val submit = EbicsBTS(cfg, bankKeys, clientKeys, submitOrder) + val ebicsLogger = EbicsLogger(null).tx("test").step("step") + + suspend fun EbicsBTS.close(id: String, phase: String, ebicsLogger: StepLogger) { + val xml = downloadReceipt(id, false) + postBTS(client, xml, phase, ebicsLogger) + } + + val firstTxId = fetch.postBTS(client, fetch.downloadInitialization(null, null), "Init first fetch", ebicsLogger) + .transactionID!! + try { + try { + val id = fetch.postBTS(client, fetch.downloadInitialization(null, null), "Init second fetch", ebicsLogger).transactionID!! + result.concurrentFetchAndFetch = true + fetch.close(id, "Init second fetch", ebicsLogger) + } catch (e: EbicsError.Code) {} + + var paylod = prepareUploadPayload(cfg, clientKeys, bankKeys, ByteArray(2000000).rand()) + try { + val submitId = submit.postBTS(client, submit.uploadInitialization(paylod), "Init first submit", ebicsLogger). transactionID!! + result.concurrentFetchAndSubmit = true + submit.postBTS(client, submit.uploadTransfer(submitId, paylod, 1), "Submit first upload", ebicsLogger) + try { + submit.postBTS(client, submit.uploadInitialization(paylod), "Init second submit", ebicsLogger) + result.concurrentSubmitAndSubmit = true + } catch (e: EbicsError.Code) {} + } catch (e: EbicsError.Code) {} + } finally { + fetch.close(firstTxId, "Close first fetch", ebicsLogger) + } + + try { + fetch.close(firstTxId, "Close first fetch a second time", ebicsLogger) + result.idempotentClose = true + } catch (e: Exception) { + logger.debug { e.fmt() } + } + + return result +} +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/ws.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/ws.kt @@ -0,0 +1,206 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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/> + */ + +package tech.libeufin.ebics + +import io.ktor.client.* +import io.ktor.client.plugins.websocket.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.* +import io.ktor.websocket.* +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.* +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import tech.libeufin.common.* + +private val wsLog: Logger = LoggerFactory.getLogger("libeufin-ebics-ws") + +@Serializable +data class WssParams( + val URL: String, + val TOKEN: String, + val OTT: String, + val VALIDITY: String, + val PARTNERID: String, + val USERID: String? = null, +) + +@Serializable +data class WssNotificationClass( + val NAME: String, + val VERS: String, + val TIMESTAMP: String, +) + +@Serializable +data class WssNotificationBTF( + val SERVICE: String, + val SCOPE: String? = null, + val OPTION: String? = null, + val CONTTYPE: String? = null, + val MSGNAME: String, + val VARIANT: String? = null, + val VERSION: String? = null, + val FORMAT: String? = null, +) + +@Serializable +data class WssNewData( + val MCLASS: List<WssNotificationClass>, + val PARTNERID: String, + val USERID: String? = null, + val BTF: List<WssNotificationBTF>, + val ORDERTYPE: List<String>? = null +): WssNotification + +@Serializable +data class WssInfo( + val LANG: String, + val FREE: String +) + +@Serializable +data class WssGeneralInfo( + val MCLASS: List<WssNotificationClass>, + val INFO: List<WssInfo> +): WssNotification + +@Serializable(with = WssNotification.Serializer::class) +sealed interface WssNotification { + companion object Serializer : JsonContentPolymorphicSerializer<WssNotification>(WssNotification::class) { + override fun selectDeserializer(element: JsonElement) = when { + "INFO" in element.jsonObject -> WssGeneralInfo.serializer() + else -> WssNewData.serializer() + } + } +} + +/** Download EBICS real-time notifications websocket params */ +suspend fun EbicsClient.wssParams(): WssParams = + download(EbicsOrder.V3.WSS_PARAMS) { stream -> + Json.decodeFromStream(stream) + } + +/** Receive a JSON message from a websocket session */ +private suspend inline fun <reified T> DefaultClientWebSocketSession.receiveJson(): T { + val frame = incoming.receive() + val content = frame.readBytes() + val msg = Json.decodeFromStream(kotlinx.serialization.serializer<T>(), content.inputStream()) + return msg +} + +/** Connect to the EBICS real-time notifications websocket */ +suspend fun WssParams.connect(client: HttpClient, lambda: suspend (WssNotification) -> Unit) { + val client = client.config { + install(WebSockets) { + contentConverter = KotlinxWebsocketSerializationConverter(Json) + } + } + // TODO check PARTNERID and USERID match conf ? + val credentials = buildString { + // Username + append(PARTNERID) + if (USERID != null) { + append('_') + append(USERID) + } + // Password + append(':') + append(TOKEN) + }.encodeBase64() + + client.wss(URL.replace("https://", "wss://"), request = { + headers { + append(HttpHeaders.Authorization, "Basic $credentials") + } + }) { + while (true) { + wsLog.trace("wait for ws msg") + // TODO use receiveDeserialized from ktor when it works + val msg = receiveJson<WssNotification>() + wsLog.trace("received: {}", msg) + lambda(msg) + } + } +} + +suspend fun listenForNotification(client: EbicsClient): ReceiveChannel<List<EbicsOrder>>? { + val channel = Channel<List<EbicsOrder>>() + val backoff = ExpoBackoffDecorr( + 30 * 1000, // 30 seconds + 30 * 60 * 1000 // 30 min + ) + kotlin.concurrent.thread(isDaemon = true) { + runBlocking { + while (true) { + try { + // Try to get params + val params = try { + client.wssParams() + } catch (e: EbicsError) { + if ( + // Expected EBICS error + (e is EbicsError.Code && e.technicalCode == EbicsReturnCode.EBICS_INVALID_ORDER_IDENTIFIER) || + // Netzbon HTTP error + (e is EbicsError.HTTP && e.status == HttpStatusCode.BadRequest) + ) { + // Failure is expected if this wss is not supported + wsLog.info("Real-time EBICS notifications is not supported") + return@runBlocking + } else throw e + } + wsLog.info("Listening to real-time EBICS notifications") + wsLog.trace("{}", params) + params.connect(client.client) { msg -> + backoff.reset() + when (msg) { + is WssGeneralInfo -> { + for (info in msg.INFO) { + wsLog.info("info: {}", info.FREE) + } + } + is WssNewData -> { + val orders = msg.BTF.map { + EbicsOrder.V3( + type = "BTD", + service = it.SERVICE, + scope = it.SCOPE, + message = it.MSGNAME, + version = it.VERSION, + container = it.CONTTYPE, + option = it.OPTION + ) + } + channel.send(orders) + } + } + } + } catch (e: Exception) { + e.fmtLog(wsLog) + delay(backoff.next()) + } + } + } + } + return channel +} +\ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/xml.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/xml.kt @@ -0,0 +1,375 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2020-2025 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/> + */ + +package tech.libeufin.ebics + +import tech.libeufin.common.decodeBase64 +import org.w3c.dom.Document +import org.w3c.dom.Node +import org.w3c.dom.NodeList +import org.w3c.dom.Element +import org.xml.sax.InputSource +import java.io.InputStream +import java.io.StringWriter +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.UUID +import java.time.Instant +import java.time.ZoneId +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.security.PrivateKey +import java.security.PublicKey +import javax.xml.XMLConstants +import javax.xml.crypto.* +import javax.xml.crypto.dom.DOMURIReference +import javax.xml.crypto.dsig.* +import javax.xml.crypto.dsig.dom.DOMSignContext +import javax.xml.crypto.dsig.dom.DOMValidateContext +import javax.xml.crypto.dsig.spec.C14NMethodParameterSpec +import javax.xml.crypto.dsig.spec.TransformParameterSpec +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.OutputKeys +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult +import javax.xml.stream.XMLOutputFactory +import javax.xml.stream.XMLStreamWriter +import javax.xml.xpath.XPath +import javax.xml.xpath.XPathConstants +import javax.xml.xpath.XPathFactory + +fun Instant.xmlDate(): String = + DateTimeFormatter.ISO_DATE.withZone(ZoneId.of("UTC")).format(this) +fun Instant.xmlDateTime(): String = + DateTimeFormatter.ISO_OFFSET_DATE_TIME.withZone(ZoneId.of("UTC")).format(this) + +interface XmlBuilder { + fun el(path: String, lambda: XmlBuilder.() -> Unit = {}) + fun el(path: String, content: String) { + el(path) { + text(content) + } + } + fun attr(namespace: String, name: String, value: String) + fun attr(name: String, value: String) + fun text(content: String) + + companion object { + fun toBytes(root: String, f: XmlBuilder.() -> Unit): ByteArray { + val factory = XMLOutputFactory.newFactory() + val stream = StringWriter() + val writer = factory.createXMLStreamWriter(stream) + /** + * NOTE: commenting out because it wasn't obvious how to output the + * "standalone = 'yes' directive". Manual forge was therefore preferred. + */ + stream.write("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>") + XmlStreamBuilder(writer).el(root) { + this.f() + } + writer.writeEndDocument() + return stream.buffer.toString().toByteArray() + } + + fun toDom(root: String, schema: String?, f: XmlBuilder.() -> Unit): Document { + val factory = DocumentBuilderFactory.newInstance() + factory.isNamespaceAware = true + val builder = factory.newDocumentBuilder() + val doc = builder.newDocument() + doc.xmlVersion = "1.0" + doc.xmlStandalone = true + val root = doc.createElementNS(schema, root) + doc.appendChild(root) + XmlDOMBuilder(doc, schema, root).f() + doc.normalize() + return doc + } + } +} + +private class XmlStreamBuilder(private val w: XMLStreamWriter): XmlBuilder { + override fun el(path: String, lambda: XmlBuilder.() -> Unit) { + path.splitToSequence('/').forEach { + w.writeStartElement(it) + } + lambda() + path.splitToSequence('/').forEach { + w.writeEndElement() + } + } + + override fun attr(namespace: String, name: String, value: String) { + w.writeAttribute(namespace, name, value) + } + + override fun attr(name: String, value: String) { + w.writeAttribute(name, value) + } + + override fun text(content: String) { + w.writeCharacters(content) + } +} + +private class XmlDOMBuilder(private val doc: Document, private val schema: String?, private var node: Element): XmlBuilder { + override fun el(path: String, lambda: XmlBuilder.() -> Unit) { + val current = node + path.splitToSequence('/').forEach { + val new = doc.createElementNS(schema, it) + node.appendChild(new) + node = new + } + lambda() + node = current + } + + override fun attr(namespace: String, name: String, value: String) { + node.setAttributeNS(namespace, name, value) + } + + override fun attr(name: String, value: String) { + node.setAttribute(name, value) + } + + override fun text(content: String) { + node.appendChild(doc.createTextNode(content)) + } +} + +private fun Element.childrenByTag(tag: String, signed: Boolean): Sequence<Element> = sequence { + for (i in 0..childNodes.length) { + val el = childNodes.item(i) + if (el is Element + && el.localName == tag + && (!signed || el.getAttribute("authenticate") == "true")) { + yield(el) + } + } +} + +class XmlDestructor internal constructor(private val el: Element) { + fun each(path: String, signed: Boolean = false, f: XmlDestructor.() -> Unit) { + el.childrenByTag(path, signed).forEach { + f(XmlDestructor(it)) + } + } + + fun <T> map(path: String, signed: Boolean = false, f: XmlDestructor.() -> T): List<T> { + return el.childrenByTag(path, signed).map { + f(XmlDestructor(it)) + }.toList() + } + + fun one(tag: String, signed: Boolean = false): XmlDestructor { + val children = el.childrenByTag(tag, signed).iterator() + if (!children.hasNext()) { + throw Exception("expected unique '${el.tagName}.$tag', got none") + } + val child = children.next() + if (children.hasNext()) { + throw Exception("expected unique '${el.tagName}.$tag', got ${children.asSequence().count() + 1}") + } + return XmlDestructor(child) + } + fun opt(tag: String, signed: Boolean = false): XmlDestructor? { + val children = el.childrenByTag(tag, signed).iterator() + if (!children.hasNext()) { + return null + } + val child = children.next() + if (children.hasNext()) { + throw Exception("expected optional '${el.tagName}.$tag', got ${children.asSequence().count() + 1}") + } + return XmlDestructor(child) + } + + fun <T> one(path: String, signed: Boolean = false, f: XmlDestructor.() -> T): T = f(one(path, signed)) + fun <T> opt(path: String, signed: Boolean = false, f: XmlDestructor.() -> T): T? = opt(path, signed)?.run(f) + + fun uuid(): UUID = UUID.fromString(text()) + fun text(): String = el.textContent + fun base64(): ByteArray = el.textContent.decodeBase64() + fun bool(): Boolean = el.textContent.toBoolean() + fun float(): Float = el.textContent.toFloat() + fun date(): LocalDate = LocalDate.parse(text(), DateTimeFormatter.ISO_DATE) + fun dateTime(): LocalDateTime = LocalDateTime.parse(text(), DateTimeFormatter.ISO_DATE_TIME) + inline fun <reified T : Enum<T>> enum(): T = java.lang.Enum.valueOf(T::class.java, text()) + + fun optAttr(index: String): String? { + val attr = el.getAttribute(index) + if (attr == "") { + return null + } else { + return attr + } + } + fun attr(index: String): String { + val attr = optAttr(index) + if (attr == null) { + throw Exception("missing attribute '$index' at '${el.tagName}'") + } + return attr + } + + + companion object { + fun <T> parse(xml: String, root: String, f: XmlDestructor.() -> T): T { + val inputStream = ByteArrayInputStream(xml.toByteArray()) + return parse(inputStream, root, f) + } + + fun <T> parse(xml: InputStream, root: String, f: XmlDestructor.() -> T): T { + val doc = XMLUtil.parseIntoDom(xml) + return parse(doc, root, f) + } + + fun <T> parse(doc: Document, root: String, f: XmlDestructor.() -> T): T { + if (doc.documentElement.localName != root) { + throw Exception("expected root '$root' got '${doc.documentElement.localName}'") + } + val destr = XmlDestructor(doc.documentElement) + return f(destr) + } + } +} + + +/** + * This URI dereferencer allows handling the resource reference used for + * XML signatures in EBICS. + */ +private class EbicsSigUriDereferencer : URIDereferencer { + override fun dereference(myRef: URIReference?, myCtx: XMLCryptoContext?): Data { + if (myRef !is DOMURIReference) + throw Exception("invalid type") + if (myRef.uri != "#xpointer(//*[@authenticate='true'])") + throw Exception("invalid EBICS XML signature URI: '${myRef.uri}'") + val xp: XPath = XPathFactory.newInstance().newXPath() + val nodeSet = xp.compile("//*[@authenticate='true']/descendant-or-self::node()").evaluate( + myRef.here.ownerDocument, XPathConstants.NODESET + ) + if (nodeSet !is NodeList) + throw Exception("invalid type") + if (nodeSet.length <= 0) { + throw Exception("no nodes to sign") + } + val nodeList = ArrayList<Node>() + for (i in 0 until nodeSet.length) { + val node = nodeSet.item(i) + nodeList.add(node) + } + return NodeSetData { nodeList.iterator() } + } +} + +/** + * Helpers for dealing with XML in EBICS. + */ +object XMLUtil { + fun convertDomToBytes(document: Document): ByteArray { + val w = ByteArrayOutputStream() + val transformer = TransformerFactory.newInstance().newTransformer() + transformer.setOutputProperty(OutputKeys.STANDALONE, "yes") + transformer.transform(DOMSource(document), StreamResult(w)) + return w.toByteArray() + } + + /** Parse [xml] into a XML DOM */ + fun parseIntoDom(xml: InputStream): Document { + val factory = DocumentBuilderFactory.newInstance().apply { + // Enable secure processing + setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) + // Disable all external access + setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "") + setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "") + isNamespaceAware = true + } + val builder = factory.newDocumentBuilder() + return xml.use { + builder.parse(InputSource(it)) + } + } + + /** Sign an EBICS document with the authentication and identity signature */ + fun signEbicsDocument( + doc: Document, + signingPriv: PrivateKey + ) { + val authSigNode = XPathFactory.newInstance().newXPath() + .evaluate("/*[1]/*[local-name()='AuthSignature']", doc, XPathConstants.NODE) + if (authSigNode !is Node) + throw java.lang.Exception("sign: no AuthSignature") + val fac = XMLSignatureFactory.getInstance("DOM") + val c14n = fac.newTransform(CanonicalizationMethod.INCLUSIVE, null as TransformParameterSpec?) + val ref: Reference = + fac.newReference( + "#xpointer(//*[@authenticate='true'])", + fac.newDigestMethod(DigestMethod.SHA256, null), + listOf(c14n), + null, + null + ) + val canon: CanonicalizationMethod = + fac.newCanonicalizationMethod(CanonicalizationMethod.INCLUSIVE, null as C14NMethodParameterSpec?) + val signatureMethod = fac.newSignatureMethod("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", null) + val si: SignedInfo = fac.newSignedInfo(canon, signatureMethod, listOf(ref)) + val sig: XMLSignature = fac.newXMLSignature(si, null) + val dsc = DOMSignContext(signingPriv, authSigNode) + dsc.defaultNamespacePrefix = "ds" + dsc.uriDereferencer = EbicsSigUriDereferencer() + dsc.setProperty("javax.xml.crypto.dsig.cacheReference", true) + sig.sign(dsc) + val innerSig = authSigNode.firstChild + while (innerSig.hasChildNodes()) { + authSigNode.appendChild(innerSig.firstChild) + } + authSigNode.removeChild(innerSig) + } + + /** Check an EBICS document signature */ + fun verifyEbicsDocument( + doc: Document, + signingPub: PublicKey + ) { + // Find SignedInfo + val sigInfos = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "SignedInfo"); + if (sigInfos.length == 0) { + throw Exception("missing SignedInfo") + } else if (sigInfos.length != 1) { + throw Exception("many SignedInfo") + } + val sigInfo = sigInfos.item(0) + + // Rename AuthSignature + val authSig = sigInfo.parentNode + doc.renameNode(authSig, XMLSignature.XMLNS, "${sigInfo.prefix}:Signature") + + // Check signature + val fac = XMLSignatureFactory.getInstance("DOM") + val dvc = DOMValidateContext(signingPub, authSig) + dvc.setProperty("javax.xml.crypto.dsig.cacheReference", true) + dvc.uriDereferencer = EbicsSigUriDereferencer() + val sig = fac.unmarshalXMLSignature(dvc) + if (!sig.validate(dvc)) { + throw Exception("bank signature did not verify") + } + } +} +\ No newline at end of file diff --git a/libeufin-ebics/src/test/kotlin/Keys.kt b/libeufin-ebics/src/test/kotlin/Keys.kt @@ -0,0 +1,99 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 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/> + */ + +import org.junit.Test +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.common.fmtChunkByTwo +import tech.libeufin.ebics.* +import kotlin.io.path.Path +import kotlin.io.path.deleteIfExists +import kotlin.io.path.notExists +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PublicKeys { + + // Tests intermittent spaces in public keys fingerprint. + @Test + fun splitTest() { + assertEquals("0099887766".fmtChunkByTwo(), "00 99 88 77 66") // even + assertEquals("ZZYYXXWWVVU".fmtChunkByTwo(), "ZZ YY XX WW VV U") // odd + } + + // Tests loading the bank public keys from disk. + @Test + fun loadBankKeys() { + // artificially creating the keys. + val fileContent = BankPublicKeysFile( + accepted = true, + bank_authentication_public_key = CryptoUtil.genRSAPublic(2028), + bank_encryption_public_key = CryptoUtil.genRSAPublic(2028) + ) + // storing them on disk. + persistBankKeys(fileContent, Path("/tmp/nexus-tests-bank-keys.json")) + // loading them and check that values are the same. + val fromDisk = loadBankKeys(Path("/tmp/nexus-tests-bank-keys.json")) + assertNotNull(fromDisk) + assertTrue { + fromDisk.accepted && + fromDisk.bank_encryption_public_key == fileContent.bank_encryption_public_key && + fromDisk.bank_authentication_public_key == fileContent.bank_authentication_public_key + } + } + @Test + fun loadNotFound() { + assertNull(loadBankKeys(Path("/tmp/highly-unlikely-to-be-found.json"))) + } +} + +class PrivateKeys { + val f = Path("/tmp/nexus-privs-test.json") + init { + f.deleteIfExists() + } + + /** + * Tests whether loading keys from disk yields the same + * values that were stored to the file. + */ + @Test + fun load() { + assert(f.notExists()) + val clientKeys = generateNewKeys() + persistClientKeys(clientKeys, f) // Artificially storing this to the file. + val fromDisk = loadClientKeys(f) // loading it via the tested routine. + assertNotNull(fromDisk) + // Checking the values from disk match the initial object. + assertTrue { + clientKeys.authentication_private_key == fromDisk.authentication_private_key && + clientKeys.encryption_private_key == fromDisk.encryption_private_key && + clientKeys.signature_private_key == fromDisk.signature_private_key && + clientKeys.submitted_ini == fromDisk.submitted_ini && + clientKeys.submitted_hia == fromDisk.submitted_hia + } + } + + // Testing failure on file not found. + @Test + fun loadNotFound() { + assertNull(loadClientKeys(Path("/tmp/highly-unlikely-to-be-found.json"))) + } +} +\ No newline at end of file diff --git a/libeufin-ebics/src/test/kotlin/MySerializers.kt b/libeufin-ebics/src/test/kotlin/MySerializers.kt @@ -0,0 +1,47 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 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/> + */ + +import org.junit.Test +import tech.libeufin.common.Base32Crockford +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.ebics.ClientPrivateKeysFile +import tech.libeufin.ebics.JSON +import kotlin.test.assertEquals + +class MySerializers { + // Testing deserialization of RSA private keys. + @Test + fun rsaPrivDeserialization() { + val s = Base32Crockford.encode(CryptoUtil.genRSAPrivate(2048).encoded) + val a = Base32Crockford.encode(CryptoUtil.genRSAPrivate(2048).encoded) + val e = Base32Crockford.encode(CryptoUtil.genRSAPrivate(2048).encoded) + val obj = JSON.decodeFromString<ClientPrivateKeysFile>(""" + { + "signature_private_key": "$s", + "authentication_private_key": "$a", + "encryption_private_key": "$e", + "submitted_ini": true, + "submitted_hia": true + } + """.trimIndent()) + assertEquals(obj.signature_private_key, CryptoUtil.loadRSAPrivate(Base32Crockford.decode(s))) + assertEquals(obj.authentication_private_key, CryptoUtil.loadRSAPrivate(Base32Crockford.decode(a))) + assertEquals(obj.encryption_private_key, CryptoUtil.loadRSAPrivate(Base32Crockford.decode(e))) + } +} +\ No newline at end of file diff --git a/libeufin-ebics/src/test/kotlin/WsTest.kt b/libeufin-ebics/src/test/kotlin/WsTest.kt @@ -0,0 +1,186 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2024 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/> +*/ + +import io.ktor.http.HttpHeaders +import io.ktor.serialization.kotlinx.* +import io.ktor.server.application.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import io.ktor.server.websocket.* +import io.ktor.websocket.* +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import tech.libeufin.ebics.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class WsTest { + // WSS params example from the spec + val PARAMS_EXAMPLE = """ + { + "URL": "https://bankmitwebsocket.de", + "TOKEN": "550e8400-e29b-11d4-a716-446655440000", + "OTT": "N", + "VALIDITY": "2019-03-21T10:35:22Z", + "PARTNERID": "K1234567", + "USERID": "USER4711" + } + """ + // Authorization header example from the spec + val AUTH_EXAMPLE = "Basic SzEyMzQ1NjdfVVNFUjQ3MTE6NTUwZTg0MDAtZTI5Yi0xMWQ0LWE3MTYtNDQ2NjU1NDQwMDAw" + // Notifications examples from the spec + val NOTIFICATION_EXAMPLES = sequenceOf( + """ + { + "MCLASS": [ + { + "NAME": "EBICS-HAA", + "VERS": "1.0", + "TIMESTAMP": "2019-05-13T12:21:50Z" + } + ], + "PARTNERID": "K1234567", + "USERID": "USER471", + "BTF": [ + { + "SERVICE": "REP", + "SCOPE": "DE", + "CONTTYPE": "ZIP", + "MSGNAME": "camt.054" + } + ], + "ORDERTYPE": [ + "C5N" + ] + } + """, """ + { + "MCLASS": [ + { + "NAME": "EBICS-HAA", + "VERS": "1.0", + "TIMESTAMP": "2019-05-13T12:21:53Z" + } + ], + "PARTNERID": "K1234567", + "USERID": "USER471", + "BTF": [ + { + "SERVICE": "REP", + "SCOPE": "DE", + "CONTTYPE": "ZIP", + "MSGNAME": "camt.052" + }, + { + "SERVICE": "REP", + "SCOPE": "DE", + "OPTION": "SCI", + "CONTTYPE": "ZIP", + "MSGNAME": "pain.002" + } + ], + "ORDERTYPE": [ + "C52", + "CIZ" + ] + } + """, """ + { + "MCLASS": [ + { + "NAME": "INFO", + "VERS": "1.0", + "TIMESTAMP": "2019-03-25T12:25:34Z" + } + ], + "INFO": [ + { + "LANG": "EN", + "FREE": " The EBICS-Service is limited on 30.03.2019 from 10:00 a.m. - 11:00a.m. due to maintenance work " + } + ] + } + """ + ) + + /** Test JSON serialization roudtrip */ + inline fun <reified B> roundtrip(raw: String): B { + val json: JsonObject = Json.decodeFromString(raw) + val decoded: B = Json.decodeFromJsonElement(json) + val encoded = Json.encodeToJsonElement(decoded) + assertEquals(json, encoded) + return decoded + } + + /** Test our serialization implementation works with spec examples */ + @Test + fun serialization() { + roundtrip<WssParams>(PARAMS_EXAMPLE) + for (raw in NOTIFICATION_EXAMPLES) { + roundtrip<WssNotification>(raw) + } + } + + /** Test our implementation works with spec examples */ + @Test + fun wss() { + val params: WssParams = Json.decodeFromString(PARAMS_EXAMPLE) + + testApplication { + externalServices { + hosts(params.URL.replace("https://", "wss://")) { + install(WebSockets) { + contentConverter = KotlinxWebsocketSerializationConverter(Json) + } + routing { + webSocket("/") { + assertEquals(AUTH_EXAMPLE, call.request.headers[HttpHeaders.Authorization]) + // Send all examples + for (example in NOTIFICATION_EXAMPLES) { + send(example) + } + close(CloseReason(CloseReason.Codes.NORMAL, "Test done")) + } + } + } + } + var count = 0 + try { + params.connect(client) { msg -> + count++ + // Check message number and type + assert(count <= 3) + if (count == 3) { + assertIs<WssGeneralInfo>(msg) + } else { + assertIs<WssNewData>(msg) + } + } + } catch (e: ClosedReceiveChannelException) { + // Expected + } + // Check receive all messages + assertEquals(3, count) + } + } +} +\ No newline at end of file diff --git a/libeufin-ebics/src/test/kotlin/XmlTest.kt b/libeufin-ebics/src/test/kotlin/XmlTest.kt @@ -0,0 +1,152 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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/> + */ + +import kotlin.test.* +import org.w3c.dom.Document +import org.junit.Test +import tech.libeufin.ebics.* +import tech.libeufin.common.asUtf8 +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.common.decodeBase64 +import tech.libeufin.ebics.XMLUtil +import java.security.KeyPairGenerator + +class XmlCombinatorsTest { + fun testBuilder(expected: String, root: String, builder: XmlBuilder.() -> Unit): Document { + val toBytes = XmlBuilder.toBytes(root, builder) + val toDom = XmlBuilder.toDom(root, null, builder) + //assertEquals(expected, toString) TODO fix empty tag being closed only with toString + assertEquals(expected, XMLUtil.convertDomToBytes(toDom).asUtf8()) + return toDom + } + + @Test + fun testWithModularity() { + fun module(base: XmlBuilder) { + base.el("module") + } + testBuilder( + "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><root><module/></root>", + "root" + ) { + module(this) + } + } + + @Test + fun testWithIterable() { + testBuilder( + "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><iterable><endOfDocument><e1><e11>111</e11></e1><e2><e22>222</e22></e2><e3><e33>333</e33></e3><e4><e44>444</e44></e4><e5><e55>555</e55></e5><e6><e66>666</e66></e6><e7><e77>777</e77></e7><e8><e88>888</e88></e8><e9><e99>999</e99></e9><e10><e1010>101010</e1010></e10></endOfDocument></iterable>", + "iterable" + ) { + el("endOfDocument") { + for (i in 1..10) + el("e$i/e$i$i", "$i$i$i") + } + } + } + + @Test + fun testBasicXmlBuilding() { + testBuilder( + "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><ebicsRequest version=\"H004\"><a><b><c attribute-of=\"c\"><d><e><f nested=\"true\"><g><h/></g></f></e></d></c></b></a><one_more/></ebicsRequest>", + "ebicsRequest" + ) { + attr("version", "H004") + el("a/b/c") { + attr("attribute-of", "c") + el("d/e/f") { + attr("nested", "true") + el("g/h") + } + } + el("one_more") + } + } + + @Test + fun signed() { + val trapped = XmlBuilder.toDom("document", "urn:org:ebics:test") { + el("order") { + text("not signed") + } + el("order") { + attr("authenticate", "true") + text("signed") + } + el("order") { + attr("authenticate", "false") + text("not signed 2") + } + } + XmlDestructor.parse(trapped, "document") { + assertEquals(3, map("order") { text() }.size) + one("order", signed = true) { + assertEquals("signed", text()) + } + } + } +} + +class XmlUtilTest { + + @Test + fun basicSigningTest() { + val doc = XMLUtil.parseIntoDom(""" + <myMessage xmlns:ebics="urn:org:ebics:H004"> + <ebics:AuthSignature /> + <foo authenticate="true">Hello World</foo> + </myMessage> + """.trimIndent().toByteArray().inputStream()) + val kpg = KeyPairGenerator.getInstance("RSA") + kpg.initialize(2048) + val pair = kpg.genKeyPair() + val otherPair = kpg.genKeyPair() + XMLUtil.signEbicsDocument(doc, pair.private) + XMLUtil.verifyEbicsDocument(doc, pair.public) + assertFails { XMLUtil.verifyEbicsDocument(doc, otherPair.public) } + } + + @Test + fun multiAuthSigningTest() { + val doc = XMLUtil.parseIntoDom(""" + <myMessage xmlns:ebics="urn:org:ebics:H004"> + <ebics:AuthSignature /> + <foo authenticate="true">Hello World</foo> + <bar authenticate="true">Another one!</bar> + </myMessage> + """.trimIndent().toByteArray().inputStream()) + val kpg = KeyPairGenerator.getInstance("RSA") + kpg.initialize(2048) + val pair = kpg.genKeyPair() + XMLUtil.signEbicsDocument(doc, pair.private) + XMLUtil.verifyEbicsDocument(doc, pair.public) + } + + @Test + fun testRefSignature() { + val classLoader = ClassLoader.getSystemClassLoader() + val docText = classLoader.getResourceAsStream("signature1/doc.xml") + val doc = XMLUtil.parseIntoDom(docText) + val keyStream = classLoader.getResourceAsStream("signature1/public_key.txt") + val keyBytes = keyStream.decodeBase64().readAllBytes() + val key = CryptoUtil.loadRSAPublic(keyBytes) + XMLUtil.verifyEbicsDocument(doc, key) + } +} +\ No newline at end of file diff --git a/libeufin-nexus/src/test/resources/signature1/doc.xml b/libeufin-ebics/src/test/resources/signature1/doc.xml diff --git a/libeufin-nexus/src/test/resources/signature1/public_key.txt b/libeufin-ebics/src/test/resources/signature1/public_key.txt diff --git a/libeufin-ebisync/build.gradle b/libeufin-ebisync/build.gradle @@ -22,7 +22,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version") implementation(project(":libeufin-common")) - implementation(project(":libeufin-nexus")) + implementation(project(":libeufin-ebics")) // Metrics implementation("io.prometheus:prometheus-metrics-core:$prometheus_version") diff --git a/libeufin-ebisync/conf/test.conf b/libeufin-ebisync/conf/test.conf @@ -0,0 +1,17 @@ +[ebisync] +HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb +BANK_PUBLIC_KEYS_FILE = test/tmp/bank-keys.json +CLIENT_PRIVATE_KEYS_FILE = test/tmp/client-keys.json +HOST_ID = PFEBICS +USER_ID = PFC00563 +PARTNER_ID = PFC00563 + +[ebisyncdb-postgres] +CONFIG = postgresql:///libeufincheck + +[ebisync-fetch] +DESTINATION = azure-blob-storage +AZURE_API_URL = http://localhost:10000/devstoreaccount1/ +AZURE_ACCOUNT_NAME = devstoreaccount1 +AZURE_ACCOUNT_KEY = Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== +AZURE_CONTAINER = test +\ No newline at end of file diff --git a/libeufin-ebisync/ebisync.conf b/libeufin-ebisync/ebisync.conf @@ -0,0 +1,76 @@ +[paths] +EBISYNC_HOME = /var/lib/libeufin-ebisync/ + +[ebisync] +# Base URL of the bank server. +HOST_BASE_URL = + +# EBICS host ID. +HOST_ID = + +# EBICS user ID, as assigned by the bank. +USER_ID = + +# EBICS partner ID, as assigned by the bank. +PARTNER_ID = + +# EBICS partner ID, as assigned by the bank. +SYSTEM_ID = + +# File that holds the bank EBICS keys. +BANK_PUBLIC_KEYS_FILE = ${EBISYNC_HOME}/bank-ebics-keys.json + +# File that holds the client EBICS keys. +CLIENT_PRIVATE_KEYS_FILE = ${EBISYNC_HOME}/client-ebics-keys.json + +[ebisync-setup] +# Bank encryption public key hash +# BANK_ENCRYPTION_PUB_KEY_HASH = + +# Bank authentication public key hash +# BANK_AUTHENTICATION_PUB_KEY_HASH = + +[ebisyncdb-postgres] +# Where are the SQL files to setup our tables? +SQL_DIR = $DATADIR/sql/ + +# DB connection string +CONFIG = postgres:///libeufin-ebisync + +[ebisync-fetch] +# How often should ebics-fetch run when the bank does not support real time notification +FREQUENCY = 30m + +# At what time of day should ebics-fetch perform a checkpoint +CHECKPOINT_TIME_OF_DAY = 19:00 + +# Where should the ebics file be stored? This can only be azure-blob-storage +DESTINATION = azure-blob-storage + +# Azure API account url +AZURE_API_URL = + +# Azure API account name +AZURE_ACCOUNT_NAME = + +# Azure API account key +AZURE_ACCOUNT_KEY = + +# Which Azure Blob Storage container to use +AZURE_COUNTAINER = + +[ebisync-httpd] +# How "libeufin-ebisync serve" serves its API, this can either be tcp or unix +SERVE = tcp + +# Port on which the HTTP server listens, e.g. 9967. Only used if SERVE is tcp. +PORT = 8080 + +# Which IP address should we bind to? E.g. ``127.0.0.1`` or ``::1``for loopback. Can also be given as a hostname. Only used if SERVE is tcp. +BIND_TO = 0.0.0.0 + +# Which unix domain path should we bind to? Only used if SERVE is unix. +# UNIXPATH = libeufin-ebisync.sock + +# What should be the file access permissions for UNIXPATH? Only used if SERVE is unix. +# UNIXPATH_MODE = 660 +\ No newline at end of file diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/Main.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/Main.kt @@ -28,7 +28,8 @@ import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.util.* import tech.libeufin.common.setupSecurityProperties -import tech.libeufin.nexus.* +import tech.libeufin.ebics.httpClient +import tech.libeufin.ebisync.cli.LibeufinEbiSync import java.security.Key import java.time.* import java.time.format.DateTimeFormatter @@ -36,17 +37,17 @@ import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec import kotlinx.coroutines.runBlocking import java.util.Base64 - -val ACCOUNT_NAME = "devstoreaccount1" -val ACCOUNT_KEY = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==" +import com.github.ajalt.clikt.core.main fun main(args: Array<String>) { setupSecurityProperties() - val client = AzureBlogStorage(ACCOUNT_NAME, ACCOUNT_KEY, "http://localhost:10000/${ACCOUNT_NAME}/", httpClient()) + setupSecurityProperties() + LibeufinEbiSync().main(args) + /* runBlocking { //client.createContainer("main") //client.listBlobs("main1") client.putBlob("main", "file2", "Hello World".toByteArray(Charsets.UTF_8), ContentType.Application.Docx) - } + }*/ } \ No newline at end of file diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/azure.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/azure.kt @@ -28,7 +28,8 @@ import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.util.* import tech.libeufin.common.setupSecurityProperties -import tech.libeufin.nexus.* +import tech.libeufin.common.BaseURL +import tech.libeufin.ebics.httpClient import java.security.Key import java.time.* import java.time.format.DateTimeFormatter @@ -156,12 +157,12 @@ data class AzureError(val status: HttpStatusCode, val code: String?): Exception( class AzureBlogStorage( name: String, key: String, - url: String, + url: BaseURL, client: HttpClient ) { private val client = client.config { defaultRequest { - url(url) + url(url.toString()) } install(AzureSharedKeyAuth) { accountName = name @@ -195,14 +196,4 @@ class AzureBlogStorage( headers.set("x-ms-blob-type", "BlockBlob") } } -} - -fun main(args: Array<String>) { - setupSecurityProperties() - val client = AzureBlogStorage(ACCOUNT_NAME, ACCOUNT_KEY, "http://localhost:10000/${ACCOUNT_NAME}/", httpClient()) - runBlocking { - //client.createContainer("main") - //client.listBlobs("main1") - client.putBlob("main", "file2", "Hello World".toByteArray(Charsets.UTF_8), ContentType.Application.Docx) - } } \ No newline at end of file diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/DbInit.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/DbInit.kt @@ -0,0 +1,44 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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/> + */ + +package tech.libeufin.ebisync.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import tech.libeufin.common.TalerCmd +import tech.libeufin.common.db.dbInit +import tech.libeufin.common.db.pgDataSource +import tech.libeufin.ebisync.dbConfig + +class DbInit : TalerCmd("dbinit") { + override fun help(context: Context) = "Initialize the libeufin-ebisync database" + + private val reset by option( + "--reset", "-r", + help = "Reset database (DANGEROUS: All existing data is lost)" + ).flag() + + override fun run() = cliCmd(logger) { + val cfg = dbConfig(config) + pgDataSource(cfg.dbConnStr).dbInit(cfg, "libeufin-ebisync", reset) + } +} +\ No newline at end of file diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/Fetch.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/Fetch.kt @@ -0,0 +1,72 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023-2025 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/> + */ + +package tech.libeufin.ebisync.cli + +import io.ktor.client.HttpClient +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import kotlinx.coroutines.delay +import tech.libeufin.common.* +import tech.libeufin.ebics.* +import tech.libeufin.ebisync.* +import tech.libeufin.ebisync.db.Database +import java.time.Instant +import kotlin.time.toKotlinDuration + +suspend fun submit(cfg: EbisyncFetchConfig, client: EbicsClient, db: Database) { + val client = AzureBlogStorage(cfg.accountName, cfg.accountKey, cfg.apiUrl, client.client) +} + +class Fetch : EbicsCmd() { + override fun help(context: Context) = "Downloads EBICS files from the bank and store them in the configured destination" + + override fun run() = cliCmd(logger) { + ebisyncConfig(config).withDb { db, cfg -> + val (clientKeys, bankKeys) = expectFullKeys(cfg) + val client = EbicsClient( + cfg, + httpClient(), + db.ebics, + EbicsLogger(ebicsLog), + clientKeys, + bankKeys + ) + + + if (transient) { + logger.info("Transient mode: submitting what found and returning.") + submit(cfg.fetch, client, db) + } else { + logger.debug("Running with a frequency of ${cfg.fetch.frequencyRaw}") + while (true) { + val now = Instant.now(); + try { + submit(cfg.fetch, client, db) + } catch (e: Exception) { + e.fmtLog(logger) + } + // TODO take submit taken time in the delay + delay(cfg.fetch.frequency.toKotlinDuration()) + } + } + } + } +} +\ No newline at end of file diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/LibeufinEbisync.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/LibeufinEbisync.kt @@ -0,0 +1,53 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 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/> + */ + +package tech.libeufin.ebisync.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.subcommands +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.versionOption +import com.github.ajalt.clikt.parameters.types.path +import tech.libeufin.common.* +import tech.libeufin.ebisync.EBISYNC_CONFIG_SOURCE +import tech.libeufin.ebics.ebicsLogOption +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +internal val logger: Logger = LoggerFactory.getLogger("libeufin-ebysinc") + +fun CliktCommand.transientOption() = option( + "--transient", + help = "Execute once and return, ignoring the 'FREQUENCY' configuration value" +).flag(default = false) + +abstract class EbicsCmd(name: String? = null): TalerCmd(name) { + val ebicsLog by ebicsLogOption() + val transient by transientOption() +} + + +class LibeufinEbiSync : CliktCommand() { + init { + versionOption(VERSION) + subcommands(DbInit(), Setup(), Fetch(), CliConfigCmd(EBISYNC_CONFIG_SOURCE)) + } + override fun run() = Unit +} +\ No newline at end of file diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/Setup.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/Setup.kt @@ -0,0 +1,135 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 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/> + */ + +package tech.libeufin.ebisync.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import io.ktor.client.* +import tech.libeufin.ebics.* +import tech.libeufin.ebisync.* +import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil +import java.nio.file.FileAlreadyExistsException +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.time.Instant +import kotlin.io.path.Path +import kotlin.io.path.writeBytes + +fun expectFullKeys(cfg: EbicsKeysConfig): Pair<ClientPrivateKeysFile, BankPublicKeysFile> = + expectFullKeys(cfg, "libeufin-ebisync setup") + +class Setup: TalerCmd() { + override fun help(context: Context) = "Set up the EBICS subscriber" + + private val forceKeysResubmission by option( + help = "Resubmits all the keys to the bank" + ).flag(default = false) + private val autoAcceptKeys by option( + help = "Accepts the bank keys without interactively asking the user" + ).flag(default = false) + private val generateRegistrationPdf by option( + help = "Generates the PDF with the client public keys to send to the bank" + ).flag(default = false) + private val ebicsLog by ebicsLogOption() + /** + * This function collects the main steps of setting up an EBICS access. + */ + override fun run() = cliCmd(logger) { + val cfg = ebisyncConfig(config) + + val client = httpClient() + val ebicsLogger = EbicsLogger(ebicsLog) + + val (clientKeys, bankKeys) = ebicsSetup( + client, + ebicsLogger, + cfg, + cfg, + cfg.setup, + forceKeysResubmission, + generateRegistrationPdf, + autoAcceptKeys, + false + ) + + // Check account information + logger.info("Doing administrative request HKD") + cfg.withDb { db, _ -> + EbicsClient( + cfg, + client, + db.ebics, + ebicsLogger, + clientKeys, + bankKeys + ).download(EbicsOrder.V3.HKD) { stream -> + val (partner, users) = EbicsAdministrative.parseHKD(stream) + // Debug logging + logger.debug { + buildString { + if (partner.name != null || partner.accounts.isNotEmpty()) { + append("Partner Info: ") + if (partner.name != null) { + append("'") + append(partner.name) + append("'") + } + for ((currency, iban, bic) in partner.accounts) { + append(' ') + append(currency) + append('-') + append(iban) + append('-') + append(bic) + } + append('\n') + } + append("Supported orders:\n") + for ((order, description) in partner.orders) { + append("- ") + append(order.description()) + append(": ") + append(description) + append('\n') + } + } + } + + // Check partner info match config + /*val account = partner.accounts.find { it.iban == cfg.ebics.account.iban } + if (account != null) { + if (account.currency != null && account.currency != cfg.currency) + logger.error("Expected CURRENCY '${cfg.currency}' from config got '${account.currency}' from bank") + if (account.bic != cfg.ebics.account.bic) + logger.error("Expected BIC '${cfg.ebics.account.bic}' from config got '${account.bic}' from bank") + } else if (partner.accounts.isNotEmpty()) { + val ibans = partner.accounts.map { it.iban }.joinToString(" ") + logger.error("Expected IBAN ${cfg.ebics.account.iban} from config got $ibans from bank") + }*/ + } + } + + println("setup ready") + } +} +\ No newline at end of file diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/config.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/config.kt @@ -0,0 +1,99 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 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/> + */ + +package tech.libeufin.ebisync + +import tech.libeufin.common.* +import tech.libeufin.common.db.DatabaseConfig +import tech.libeufin.ebics.EbicsSetupConfig +import tech.libeufin.ebics.EbicsHostConfig +import tech.libeufin.ebics.EbicsKeysConfig +import tech.libeufin.ebisync.db.Database +import java.nio.file.Path +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +private val logger: Logger = LoggerFactory.getLogger("libeufin-config") + +val EBISYNC_CONFIG_SOURCE = ConfigSource("libeufin-ebisync", "ebisync", "libeufin-ebisync") + +class EbisyncSetupConfig(cfg: TalerConfig): EbicsSetupConfig { + private val sect = cfg.section("ebisync-setup") + + override val bankAuthPubKey = sect.hex("bank_authentication_pub_key_hash").orNull() + override val bankEncPubKey = sect.hex("bank_encryption_pub_key_hash").orNull() +} + +class EbisyncConfig internal constructor (val cfg: TalerConfig): EbicsKeysConfig, EbicsHostConfig { + private val sect = cfg.section("ebisync") + + /** The bank base URL */ + override val baseUrl = sect.string("host_base_url").require() + /** The bank EBICS host ID */ + override val hostId = sect.string("host_id").require() + /** EBICS user ID */ + override val userId = sect.string("user_id").require() + /** EBICS partner ID */ + override val partnerId = sect.string("partner_id").require() + + /** Path where we store the bank public keys */ + override val bankPublicKeysPath = sect.path("bank_public_keys_file").require() + /** Path where we store our private keys */ + override val clientPrivateKeysPath = sect.path("client_private_keys_file").require() + + val setup by lazy { EbisyncSetupConfig(cfg) } + val dbCfg by lazy { cfg.dbConfig() } + val fetch by lazy { EbisyncFetchConfig(cfg) } +} + +class EbisyncFetchConfig(cfg: TalerConfig) { + private val sect = cfg.section("ebisync-fetch") + + val frequency = sect.duration("frequency").require() + val frequencyRaw = sect.string("frequency").require() + val checkpointTime = sect.time("checkpoint_time_of_day").require() + + val apiUrl = sect.baseURL("azure_api_url").require() + val accountName = sect.string("account_name").require() + val accountKey = sect.string("account_key").require() + val container = sect.string("azure_container").require() +} + +private fun TalerConfig.dbConfig(): DatabaseConfig { + val sect = section("ebisyncdb-postgres") + return DatabaseConfig( + dbConnStr = sect.string("config").require(), + sqlDir = sect.path("sql_dir").require() + ) +} + +/** Load nexus cfg at [configPath] */ +fun ebisyncConfig(configPath: Path?): EbisyncConfig { + val cfg = EBISYNC_CONFIG_SOURCE.fromFile(configPath) + return EbisyncConfig(cfg) +} + +/** Load nexus db cfg at [configPath] */ +fun dbConfig(configPath: Path?): DatabaseConfig = + EBISYNC_CONFIG_SOURCE.fromFile(configPath).dbConfig() + +/** Run [lambda] with access to a database conn pool */ +suspend fun EbisyncConfig.withDb(lambda: suspend (Database, EbisyncConfig) -> Unit) { + Database(dbCfg).use { lambda(it, this) } +} +\ No newline at end of file diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/db/Database.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/db/Database.kt @@ -0,0 +1,36 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 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/> + */ +package tech.libeufin.ebics.db + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import org.slf4j.LoggerFactory +import tech.libeufin.common.TalerAmount +import tech.libeufin.common.TransferStatusState +import tech.libeufin.common.IbanPayto +import tech.libeufin.common.db.DatabaseConfig +import tech.libeufin.common.db.DbPool +import tech.libeufin.common.db.watchNotifications +import tech.libeufin.ebics.EbicsDAO +import java.time.Instant + +/** Collects database connection steps and any operation on the EbiSync tables */ +class Database(dbConfig: DatabaseConfig): DbPool(dbConfig, "libeufin_ebisync") { + val ebics = EbicsDAO(this) +} +\ No newline at end of file diff --git a/libeufin-ebisync/src/test/kotlin/DatabaseTest.kt b/libeufin-ebisync/src/test/kotlin/DatabaseTest.kt @@ -0,0 +1,46 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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/> + */ + +import org.junit.Test +import tech.libeufin.common.* +import tech.libeufin.common.db.* +import tech.libeufin.ebics.db.* +import tech.libeufin.ebics.* +import java.time.Instant +import java.util.UUID; +import kotlin.test.* + +class EbicsTxTest { + // Test pending transaction's id + @Test + fun pending() = setup { db, _ -> + val ids = setOf("first", "second", "third") + for (id in ids) { + db.ebics.register(id) + } + + repeat(ids.size) { + val id = db.ebics.first() + assert(ids.contains(id)) + db.ebics.remove(id!!) + } + + assertNull(db.ebics.first()) + } +} +\ No newline at end of file diff --git a/libeufin-ebisync/src/test/kotlin/helpers.kt b/libeufin-ebisync/src/test/kotlin/helpers.kt @@ -0,0 +1,52 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 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/> + */ + +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.testing.* +import kotlinx.coroutines.runBlocking +import tech.libeufin.ebisync.* +import tech.libeufin.ebisync.db.* +import tech.libeufin.common.* +import tech.libeufin.common.test.* +import tech.libeufin.common.db.dbInit +import tech.libeufin.common.db.pgDataSource +import java.nio.file.NoSuchFileException +import kotlin.io.path.Path +import kotlin.io.path.deleteExisting +import kotlin.io.path.readText +import kotlin.random.Random +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull + +/* ----- Setup ----- */ + +fun setup( + conf: String = "test.conf", + lambda: suspend (Database, EbisyncConfig) -> Unit +) = runBlocking { + val cfg = ebisyncConfig(Path("conf/$conf")) + pgDataSource(cfg.dbCfg.dbConnStr).run { + dbInit(cfg.dbCfg, "libeufin-ebisync", true) + } + cfg.withDb(lambda) +} diff --git a/libeufin-nexus/build.gradle b/libeufin-nexus/build.gradle @@ -22,6 +22,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version") implementation(project(":libeufin-common")) + implementation(project(":libeufin-ebics")) // Metrics implementation("io.prometheus:prometheus-metrics-core:$prometheus_version") @@ -31,15 +32,13 @@ dependencies { // Command line parsing implementation("com.github.ajalt.clikt:clikt:$clikt_version") implementation("org.postgresql:postgresql:$postgres_version") + // Ktor client library implementation("io.ktor:ktor-server-core:$ktor_version") implementation("io.ktor:ktor-client-cio:$ktor_version") implementation("io.ktor:ktor-client-mock:$ktor_version") implementation("io.ktor:ktor-client-websockets:$ktor_version") - // PDF generation - implementation("com.itextpdf:itext-core:9.3.0") - // UNIX domain sockets support (used to connect to PostgreSQL) implementation("com.kohlschutter.junixsocket:junixsocket-core:$junixsocket_version") diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt @@ -22,7 +22,9 @@ package tech.libeufin.nexus import tech.libeufin.common.* import tech.libeufin.common.db.DatabaseConfig import tech.libeufin.nexus.db.Database -import tech.libeufin.nexus.ebics.Dialect +import tech.libeufin.ebics.EbicsKeysConfig +import tech.libeufin.ebics.EbicsSetupConfig +import tech.libeufin.ebics.EbicsHostConfig import java.nio.file.Path import java.time.Instant import org.slf4j.Logger @@ -65,26 +67,26 @@ class NexusSubmitConfig(config: TalerConfig) { val requireAck = section.boolean("manual_ack").default(false) } -class NexusSetupConfig(config: TalerConfig) { +class NexusSetupConfig(config: TalerConfig): EbicsSetupConfig { private val section = config.section("nexus-setup") - val bankAuthPubKey = section.hex("bank_authentication_pub_key_hash").orNull() - val bankEncPubKey = section.hex("bank_encryption_pub_key_hash").orNull() + override val bankAuthPubKey = section.hex("bank_authentication_pub_key_hash").orNull() + override val bankEncPubKey = section.hex("bank_encryption_pub_key_hash").orNull() } -class NexusHostConfig(sect: TalerConfigSection) { +class NexusHostConfig(sect: TalerConfigSection): EbicsHostConfig { /** The bank base URL */ - val baseUrl = sect.string("host_base_url").require() + override val baseUrl = sect.string("host_base_url").require() /** The bank EBICS host ID */ - val ebicsHostId = sect.string("host_id").require() + override val hostId = sect.string("host_id").require() /** EBICS user ID */ - val ebicsUserId = sect.string("user_id").require() + override val userId = sect.string("user_id").require() /** EBICS partner ID */ - val ebicsPartnerId = sect.string("partner_id").require() + override val partnerId = sect.string("partner_id").require() } class NexusEbicsConfig( sect: TalerConfigSection, -) { +): EbicsKeysConfig { val host by lazy { NexusHostConfig(sect) } /** Bank account metadata */ val account = IbanAccountMetadata( @@ -103,9 +105,9 @@ class NexusEbicsConfig( )).require() /** Path where we store the bank public keys */ - val bankPublicKeysPath = sect.path("bank_public_keys_file").require() + override val bankPublicKeysPath = sect.path("bank_public_keys_file").require() /** Path where we store our private keys */ - val clientPrivateKeysPath = sect.path("client_private_keys_file").require() + override val clientPrivateKeysPath = sect.path("client_private_keys_file").require() } class ApiConfig(section: TalerConfigSection) { diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/KeyFiles.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/KeyFiles.kt @@ -1,219 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2023 Stanisci and Dold. - - * 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/> - */ - -package tech.libeufin.nexus - -import kotlinx.serialization.Contextual -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encodeToString -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.Json -import kotlinx.serialization.modules.SerializersModule -import tech.libeufin.common.Base32Crockford -import tech.libeufin.common.crypto.CryptoUtil -import java.nio.file.* -import java.security.interfaces.RSAPrivateCrtKey -import java.security.interfaces.RSAPublicKey -import kotlin.io.path.* - -val JSON = Json { - this.serializersModule = SerializersModule { - contextual(RSAPrivateCrtKey::class) { RSAPrivateCrtKeySerializer } - contextual(RSAPublicKey::class) { RSAPublicKeySerializer } - } -} - -/** - * Converts base 32 representation of RSA public keys and vice versa. - */ -object RSAPublicKeySerializer : KSerializer<RSAPublicKey> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("RSAPublicKey", PrimitiveKind.STRING) - override fun serialize(encoder: Encoder, value: RSAPublicKey) { - encoder.encodeString(Base32Crockford.encode(value.encoded)) - } - - // Caller must handle exceptions here. - override fun deserialize(decoder: Decoder): RSAPublicKey { - val fieldValue = decoder.decodeString() - val bytes = Base32Crockford.decode(fieldValue) - return CryptoUtil.loadRSAPublic(bytes) - } -} - -/** - * Converts base 32 representation of RSA private keys and vice versa. - */ -object RSAPrivateCrtKeySerializer : KSerializer<RSAPrivateCrtKey> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("RSAPrivateCrtKey", PrimitiveKind.STRING) - override fun serialize(encoder: Encoder, value: RSAPrivateCrtKey) { - encoder.encodeString(Base32Crockford.encode(value.encoded)) - } - - // Caller must handle exceptions here. - override fun deserialize(decoder: Decoder): RSAPrivateCrtKey { - val fieldValue = decoder.decodeString() - val bytes = Base32Crockford.decode(fieldValue) - return CryptoUtil.loadRSAPrivate(bytes) - } -} - -/** - * Structure of the JSON file that contains the client - * private keys on disk. - */ -@Serializable -data class ClientPrivateKeysFile( - @Contextual val signature_private_key: RSAPrivateCrtKey, - @Contextual val encryption_private_key: RSAPrivateCrtKey, - @Contextual val authentication_private_key: RSAPrivateCrtKey, - var submitted_ini: Boolean, - var submitted_hia: Boolean -) - -/** - * Structure of the JSON file that contains the bank - * public keys on disk. - */ -@Serializable -data class BankPublicKeysFile( - @Contextual val bank_encryption_public_key: RSAPublicKey, - @Contextual val bank_authentication_public_key: RSAPublicKey, - var accepted: Boolean -) - -/** - * Generates new client private keys. - * - * @return [ClientPrivateKeysFile] - */ -fun generateNewKeys(): ClientPrivateKeysFile = - ClientPrivateKeysFile( - authentication_private_key = CryptoUtil.genRSAPrivate(2048), - encryption_private_key = CryptoUtil.genRSAPrivate(2048), - signature_private_key = CryptoUtil.genRSAPrivate(2048), - submitted_hia = false, - submitted_ini = false - ) - -inline fun <reified T> persistJsonFile(obj: T, path: Path, name: String) { - val content = try { - JSON.encodeToString(obj) - } catch (e: Exception) { - throw Exception("Could not encode $name", e) - } - val parent = try { - path.parent ?: path.absolute().parent - } catch (e: Exception) { - throw Exception("Could not write $name at '$path'", e) - } - try { - // Write to temp file then rename to enable atomicity when possible - val tmp = Files.createTempFile(parent, "tmp_", "_${path.fileName}") - tmp.writeText(content) - tmp.moveTo(path, StandardCopyOption.REPLACE_EXISTING) - } catch (e: Exception) { - when { - !parent.isWritable() -> throw Exception("Could not write $name at '$path': permission denied on '$parent'") - !path.isWritable() -> throw Exception("Could not write $name at '$path': permission denied") - else -> throw Exception("Could not write $name at '$path'", e) - } - } -} - -/** - * Persist the bank keys file to disk - * - * @param location the keys file location - */ -fun persistBankKeys(keys: BankPublicKeysFile, location: Path) = persistJsonFile(keys, location, "bank public keys") - -/** - * Persist the client keys file to disk - * - * @param location the keys file location - */ -fun persistClientKeys(keys: ClientPrivateKeysFile, location: Path) = persistJsonFile(keys, location, "client private keys") - - -inline fun <reified T> loadJsonFile(path: Path, name: String): T? { - val content = try { - path.readText() - } catch (e: Exception) { - when (e) { - is NoSuchFileException -> return null - is AccessDeniedException -> throw Exception("Could not read $name at '$path': permission denied") - else -> throw Exception("Could not read $name at '$path'", e) - } - } - return try { - JSON.decodeFromString(content) - } catch (e: Exception) { - throw Exception("Could not decode $name at '$path'", e) - } -} - -/** - * Load the bank keys file from disk. - * - * @param location the keys file location. - * @return the internal JSON representation of the keys file, - * or null if the file does not exist - */ -fun loadBankKeys(location: Path): BankPublicKeysFile? = loadJsonFile(location, "bank public keys") - -/** - * Load the client keys file from disk. - * - * @param location the keys file location. - * @return the internal JSON representation of the keys file, - * or null if the file does not exist - */ -fun loadClientKeys(location: Path): ClientPrivateKeysFile? = loadJsonFile(location, "client private keys") - -/** - * Load client and bank keys from disk. - * Checks that the keying process has been fully completed. - * - * Helps to fail before starting to talk EBICS to the bank. - * - * @param cfg configuration handle. - * @return both client and bank keys - */ -fun expectFullKeys(cfg: NexusEbicsConfig): Pair<ClientPrivateKeysFile, BankPublicKeysFile> { - val clientKeys = loadClientKeys(cfg.clientPrivateKeysPath) - if (clientKeys == null) { - throw Exception("Missing client private keys file at '${cfg.clientPrivateKeysPath}', run 'libeufin-nexus ebics-setup' first") - } else if (!clientKeys.submitted_ini || !clientKeys.submitted_hia) { - throw Exception("Unsubmitted client private keys, run 'libeufin-nexus ebics-setup' first") - } - val bankKeys = loadBankKeys(cfg.bankPublicKeysPath) - if (bankKeys == null) { - throw Exception("Missing bank public keys at '${cfg.bankPublicKeysPath}', run 'libeufin-nexus ebics-setup' first") - } else if (!bankKeys.accepted) { - throw Exception("Unaccepted bank public keys, run 'libeufin-nexus ebics-setup' until accepting the bank keys") - } - return Pair(clientKeys, bankKeys) -} -\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/PDF.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/PDF.kt @@ -1,114 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024 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/> - */ - -package tech.libeufin.nexus - -import com.itextpdf.kernel.pdf.PdfDocument -import com.itextpdf.kernel.pdf.PdfWriter -import com.itextpdf.layout.Document -import com.itextpdf.layout.element.AreaBreak -import com.itextpdf.layout.element.Paragraph -import tech.libeufin.common.crypto.CryptoUtil -import java.io.ByteArrayOutputStream -import java.security.interfaces.RSAPrivateCrtKey -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter - -/** - * Generate the PDF document with all the client public keys - * to be sent on paper to the bank. - */ -fun generateKeysPdf( - clientKeys: ClientPrivateKeysFile, - cfg: NexusEbicsConfig -): ByteArray { - val po = ByteArrayOutputStream() - val pdfWriter = PdfWriter(po) - val pdfDoc = PdfDocument(pdfWriter) - val date = LocalDateTime.now() - val dateStr = date.format(DateTimeFormatter.ISO_LOCAL_DATE) - - fun formatHex(ba: ByteArray): String { - var out = "" - for (i in ba.indices) { - val b = ba[i] - if (i > 0 && i % 16 == 0) { - out += "\n" - } - out += java.lang.String.format("%02X", b) - out += " " - } - return out - } - - fun writeCommon(doc: Document) { - doc.add( - Paragraph( - """ - Datum: $dateStr - Host-ID: ${cfg.host.ebicsHostId} - User-ID: ${cfg.host.ebicsUserId} - Partner-ID: ${cfg.host.ebicsPartnerId} - ES version: A006 - """.trimIndent() - ) - ) - } - - fun writeKey(doc: Document, priv: RSAPrivateCrtKey) { - val pub = CryptoUtil.RSAPublicFromPrivate(priv) - val hash = CryptoUtil.getEbicsPublicKeyHash(pub) - doc.add(Paragraph("Exponent:\n${formatHex(pub.publicExponent.toByteArray())}")) - doc.add(Paragraph("Modulus:\n${formatHex(pub.modulus.toByteArray())}")) - doc.add(Paragraph("SHA-256 hash:\n${formatHex(hash)}")) - } - - fun writeSigLine(doc: Document) { - doc.add(Paragraph("Ort / Datum: ________________")) - doc.add(Paragraph("Firma / Name: ________________")) - doc.add(Paragraph("Unterschrift: ________________")) - } - - Document(pdfDoc).use { - it.add(Paragraph("Signaturschlüssel").setFontSize(24f)) - writeCommon(it) - it.add(Paragraph("Öffentlicher Schlüssel (Public key for the electronic signature)")) - writeKey(it, clientKeys.signature_private_key) - it.add(Paragraph("\n")) - writeSigLine(it) - it.add(AreaBreak()) - - it.add(Paragraph("Authentifikationsschlüssel").setFontSize(24f)) - writeCommon(it) - it.add(Paragraph("Öffentlicher Schlüssel (Public key for the identification and authentication signature)")) - writeKey(it, clientKeys.authentication_private_key) - it.add(Paragraph("\n")) - writeSigLine(it) - it.add(AreaBreak()) - - it.add(Paragraph("Verschlüsselungsschlüssel").setFontSize(24f)) - writeCommon(it) - it.add(Paragraph("Öffentlicher Schlüssel (Public encryption key)")) - writeKey(it, clientKeys.encryption_private_key) - it.add(Paragraph("\n")) - writeSigLine(it) - } - pdfWriter.flush() - return po.toByteArray() -} -\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/ObservabilityApi.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/ObservabilityApi.kt @@ -37,8 +37,8 @@ import tech.libeufin.nexus.db.* import tech.libeufin.nexus.db.KvDAO.* import tech.libeufin.nexus.db.ExchangeDAO.TransferResult import tech.libeufin.nexus.db.PaymentDAO.IncomingRegistrationResult -import tech.libeufin.nexus.ebics.randEbicsId import tech.libeufin.nexus.iso20022.* +import tech.libeufin.ebics.randEbicsId import java.time.Instant import java.io.ByteArrayOutputStream diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt @@ -32,8 +32,8 @@ import tech.libeufin.nexus.db.Database import tech.libeufin.nexus.db.ExchangeDAO import tech.libeufin.nexus.db.ExchangeDAO.TransferResult import tech.libeufin.nexus.db.PaymentDAO.IncomingRegistrationResult -import tech.libeufin.nexus.ebics.randEbicsId import tech.libeufin.nexus.iso20022.* +import tech.libeufin.ebics.randEbicsId import java.time.Instant diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt @@ -34,8 +34,8 @@ import tech.libeufin.common.* import tech.libeufin.nexus.* import tech.libeufin.nexus.db.* import tech.libeufin.nexus.db.PaymentDAO.* -import tech.libeufin.nexus.ebics.* import tech.libeufin.nexus.iso20022.* +import tech.libeufin.ebics.* import java.io.IOException import java.io.InputStream import java.time.* @@ -362,6 +362,8 @@ private suspend fun registerPayload( */ private suspend fun fetchEbicsDocuments( client: EbicsClient, + db: Database, + cfg: NexusConfig, orders: Collection<EbicsOrder>, pinnedStart: Instant?, peek: Boolean @@ -385,7 +387,7 @@ private suspend fun fetchEbicsDocuments( null, peek ) { payload -> - registerPayload(client.db, client.cfg, payload, doc) + registerPayload(db, cfg, payload, doc) } } catch (e: EbicsError.Code) { when (e.bankCode) { @@ -434,9 +436,9 @@ class EbicsFetch: EbicsCmd() { nexusConfig(config).withDb { db, cfg -> val (clientKeys, bankKeys) = expectFullKeys(cfg.ebics) val client = EbicsClient( - cfg, + cfg.ebics.host, httpClient(), - db, + db.ebics, EbicsLogger(ebicsLog), clientKeys, bankKeys @@ -504,7 +506,7 @@ class EbicsFetch: EbicsCmd() { } selectedOrder select supportedOrder } - fetchEbicsDocuments(client, orders, since, transient && peek) + fetchEbicsDocuments(client, db, cfg, orders, since, transient && peek) } catch (e: Exception) { e.fmtLog(logger) false @@ -523,7 +525,7 @@ class EbicsFetch: EbicsCmd() { } selectedOrder select haa.orders } - fetchEbicsDocuments(client, orders, if (transient) pinnedStart else null, transient && peek) + fetchEbicsDocuments(client, db, cfg, orders, if (transient) pinnedStart else null, transient && peek) } catch (e: Exception) { e.fmtLog(logger) false @@ -546,7 +548,7 @@ class EbicsFetch: EbicsCmd() { val orders = selectedOrder select notifications if (orders.isNotEmpty()) { logger.info("Running at real-time notifications reception") - fetchEbicsDocuments(client, notifications, null, false) + fetchEbicsDocuments(client, db, cfg, notifications, null, false) } } } diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt @@ -27,9 +27,14 @@ import com.github.ajalt.clikt.parameters.options.option import io.ktor.client.* import tech.libeufin.common.* import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.ebics.ClientPrivateKeysFile +import tech.libeufin.ebics.BankPublicKeysFile +import tech.libeufin.ebics.ebicsSetup +import tech.libeufin.ebics.askUserToAcceptKeys +import tech.libeufin.ebics.EbicsLogger +import tech.libeufin.ebics.EbicsKeyMng import tech.libeufin.nexus.* -import tech.libeufin.nexus.ebics.* -import tech.libeufin.nexus.ebics.EbicsKeyMng.Order.* +import tech.libeufin.ebics.* import java.nio.file.FileAlreadyExistsException import java.nio.file.Path import java.nio.file.StandardOpenOption @@ -37,118 +42,8 @@ import java.time.Instant import kotlin.io.path.Path import kotlin.io.path.writeBytes -/** Load client private keys at [path] or create new ones if missing */ -private fun loadOrGenerateClientKeys(path: Path): ClientPrivateKeysFile { - // If exists load from disk - val current = loadClientKeys(path) - if (current != null) return current - // Else create new keys - val newKeys = generateNewKeys() - persistClientKeys(newKeys, path) - logger.info("New client private keys created at '$path'") - return newKeys -} - -/** - * Asks the user to accept the bank public keys. - * - * @param bankKeys bank public keys, in format stored on disk. - * @return true if the user accepted, false otherwise. - */ -private fun askUserToAcceptKeys(bankKeys: BankPublicKeysFile, cfg: NexusSetupConfig): Boolean { - val encHash = CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key) - val authHash = CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key) - if (cfg.bankAuthPubKey != null && cfg.bankEncPubKey != null) { - if (encHash.contentEquals(cfg.bankEncPubKey) && authHash.contentEquals(cfg.bankAuthPubKey)) { - logger.info("Accepting bank keys matching config hashes") - return true - } - throw Exception(buildString { - append("Bank keys does not match config hashes\nBank encryption key: ") - append(encHash.encodeUpHex().fmtChunkByTwo()) - append("\nConfig encryption key: ") - append(cfg.bankEncPubKey.encodeUpHex().fmtChunkByTwo()) - append("\nBank authentication key: ") - append(authHash.encodeUpHex().fmtChunkByTwo()) - append("\nConfig authentication key: ") - append(cfg.bankAuthPubKey.encodeUpHex().fmtChunkByTwo()) - }) - } - println("The bank has the following keys:") - println("Encryption key: ${encHash.encodeUpHex().fmtChunkByTwo()}") - println("Authentication key: ${authHash.encodeUpHex().fmtChunkByTwo()}") - print("type 'yes, accept' to accept them: ") - val userResponse: String? = readlnOrNull() - return userResponse == "yes, accept" -} - -/** Perform an EBICS public key management [order] using [client] and update on disk state */ -private suspend fun submitClientKeys( - cfg: NexusEbicsConfig, - privs: ClientPrivateKeysFile, - client: HttpClient, - ebicsLogger: EbicsLogger, - order: EbicsKeyMng.Order -) { - require(order != HPB) { "Only INI & HIA are supported for client keys" } - val resp = keyManagement(cfg, privs, client, ebicsLogger, order) - if (resp.technicalCode == EbicsReturnCode.EBICS_INVALID_USER_OR_USER_STATE || resp.technicalCode == EbicsReturnCode.EBICS_INVALID_USER_STATE) { - throw Exception("$order status code ${resp.technicalCode}: either your IDs are incorrect, or you already have keys registered with this bank") - } - val orderData = resp.okOrFail(order.name) - when (order) { - INI -> privs.submitted_ini = true - HIA -> privs.submitted_hia = true - HPB -> {} - } - try { - persistClientKeys(privs, cfg.clientPrivateKeysPath) - } catch (e: Exception) { - throw Exception("Could not update the $order state on disk", e) - } -} - -/** Perform an EBICS private key management HPB using [client] */ -private suspend fun fetchPrivateKeys( - cfg: NexusEbicsConfig, - privs: ClientPrivateKeysFile, - client: HttpClient, - ebicsLogger: EbicsLogger -): BankPublicKeysFile { - val order = HPB - val resp = keyManagement(cfg, privs, client, ebicsLogger, order) - if (resp.technicalCode == EbicsReturnCode.EBICS_AUTHENTICATION_FAILED) { - throw Exception("$order status code ${resp.technicalCode}: could not download bank keys, send client keys (and/or related PDF document with --generate-registration-pdf) to the bank") - } - val orderData = requireNotNull(resp.okOrFail(order.name)) { - "$order: missing order data" - } - val (authPub, encPub) = EbicsKeyMng.parseHpbOrder(orderData) - return BankPublicKeysFile( - bank_authentication_public_key = authPub, - bank_encryption_public_key = encPub, - accepted = false - ) -} - -/** - * Mere collector of the PDF generation steps. Fails the - * process if a problem occurs. - * - * @param privs client private keys. - * @param cfg configuration handle. - */ -private fun makePdf(privs: ClientPrivateKeysFile, cfg: NexusEbicsConfig) { - val pdf = generateKeysPdf(privs, cfg) - val path = Path("/tmp/libeufin-nexus-keys-${Instant.now().epochSecond}.pdf") - try { - path.writeBytes(pdf, StandardOpenOption.CREATE_NEW) - } catch (e: Exception) { - if (e is FileAlreadyExistsException) throw Exception("PDF file exists already at '$path', not overriding it") - throw Exception("Could not write PDF to '$path'", e) - } - println("PDF file with keys created at '$path'") -} +fun expectFullKeys(cfg: EbicsKeysConfig): Pair<ClientPrivateKeysFile, BankPublicKeysFile> = + expectFullKeys(cfg, "libeufin-nexus ebics-setup") /** * CLI class implementing the "ebics-setup" subcommand. @@ -176,86 +71,36 @@ class EbicsSetup: TalerCmd() { val client = httpClient() val ebicsLogger = EbicsLogger(ebicsLog) - val clientKeys = loadOrGenerateClientKeys(cfg.ebics.clientPrivateKeysPath) - var bankKeys = loadBankKeys(cfg.ebics.bankPublicKeysPath) - - // Check EBICS 3 support - val versions = HEV(client, cfg.ebics, ebicsLogger) - logger.debug("HEV: {}", versions) - if (!versions.contains(VersionNumber(3.0f, "H005")) && !versions.contains(VersionNumber(3.02f, "H005"))) { - throw Exception("EBICS 3 is not supported by your bank") + val ebics3 = when (cfg.ebics.dialect) { + // TODO GLS needs EBICS 2.5 for key management + Dialect.gls -> false + else -> true } - - // Privs exist. Upload their pubs - val keysNotSub = !clientKeys.submitted_ini - if (!clientKeys.submitted_ini || forceKeysResubmission) - submitClientKeys(cfg.ebics, clientKeys, client, ebicsLogger, INI) - // Eject PDF if the keys were submitted for the first time, or the user asked. - if (keysNotSub || generateRegistrationPdf) makePdf(clientKeys, cfg.ebics) - if (!clientKeys.submitted_hia || forceKeysResubmission) - submitClientKeys(cfg.ebics, clientKeys, client, ebicsLogger, HIA) + val (clientKeys, bankKeys) = ebicsSetup( + client, + ebicsLogger, + cfg.ebics, + cfg.ebics.host, + cfg.setup, + forceKeysResubmission, + generateRegistrationPdf, + autoAcceptKeys, + ebics3 + ) - val fetchedBankKeys = fetchPrivateKeys(cfg.ebics, clientKeys, client, ebicsLogger) - if (bankKeys == null) { - // Accept bank keys - logger.info("Bank keys stored at ${cfg.ebics.bankPublicKeysPath}") - try { - persistBankKeys(fetchedBankKeys, cfg.ebics.bankPublicKeysPath) - } catch (e: Exception) { - throw Exception("Could not store bank keys on disk", e) - } - bankKeys = fetchedBankKeys - } else { - // Check current bank keys - if (bankKeys.bank_encryption_public_key != fetchedBankKeys.bank_encryption_public_key) { - throw Exception(buildString { - append("On disk bank encryption key stored at ") - append(cfg.ebics.bankPublicKeysPath) - append(" doesn't match server key\nDisk: ") - append(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).encodeUpHex().fmtChunkByTwo()) - append("\nServer: ") - append(CryptoUtil.getEbicsPublicKeyHash(fetchedBankKeys.bank_encryption_public_key).encodeUpHex().fmtChunkByTwo()) - }) - } else if (bankKeys.bank_authentication_public_key != fetchedBankKeys.bank_authentication_public_key) { - throw Exception(buildString { - append("On disk bank authentication key stored at ") - append(cfg.ebics.bankPublicKeysPath) - append(" doesn't match server key\nDisk: ") - append(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key).encodeUpHex().fmtChunkByTwo()) - append("\nServer: ") - append(CryptoUtil.getEbicsPublicKeyHash(fetchedBankKeys.bank_authentication_public_key).encodeUpHex().fmtChunkByTwo()) - }) - } - } - - if (!bankKeys.accepted) { - // Finishing the setup by accepting the bank keys. - if (autoAcceptKeys) bankKeys.accepted = true - else bankKeys.accepted = askUserToAcceptKeys(bankKeys, setupCfg) - - if (!bankKeys.accepted) { - throw Exception("Cannot successfully finish the setup without accepting the bank keys") - } - try { - persistBankKeys(bankKeys, cfg.ebics.bankPublicKeysPath) - } catch (e: Exception) { - throw Exception("Could not set bank keys as accepted on disk", e) - } - } - // Check account information logger.info("Doing administrative request HKD") cfg.withDb { db, _ -> EbicsClient( - cfg, + cfg.ebics.host, client, - db, + db.ebics, ebicsLogger, clientKeys, bankKeys ).download(EbicsOrder.V3.HKD) { stream -> val (partner, users) = EbicsAdministrative.parseHKD(stream) - val user = users.find { it -> it.id == cfg.ebics.host.ebicsUserId } + val user = users.find { it -> it.id == cfg.ebics.host.userId } // Debug logging logger.debug { buildString { diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSubmit.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSubmit.kt @@ -24,9 +24,9 @@ import com.github.ajalt.clikt.core.Context import com.github.ajalt.clikt.parameters.groups.provideDelegate import kotlinx.coroutines.delay import tech.libeufin.common.* +import tech.libeufin.ebics.* import tech.libeufin.nexus.* -import tech.libeufin.nexus.db.PaymentBatch -import tech.libeufin.nexus.ebics.* +import tech.libeufin.nexus.db.* import tech.libeufin.nexus.iso20022.* import java.time.Instant import kotlin.time.toKotlinDuration @@ -67,9 +67,10 @@ private suspend fun submitBatch( client: EbicsClient, order: EbicsOrder, batch: PaymentBatch, - instant: Boolean + cfg: NexusConfig, + instant: Boolean, ): String { - val ebicsCfg = client.cfg.ebics + val ebicsCfg = cfg.ebics val msg = batchToPain001Msg(ebicsCfg.account, batch) val xml = createPain001( msg = msg, @@ -80,30 +81,30 @@ private suspend fun submitBatch( } /** Submit all pending initiated payments using [client] */ -private suspend fun submitAll(client: EbicsClient, requireAck: Boolean) { +private suspend fun submitAll(client: EbicsClient, requireAck: Boolean, cfg: NexusConfig, db: Database) { // Find a supported debit order - var instantDebitOrder = client.cfg.ebics.dialect.instantDirectDebit() - val debitOrder = client.cfg.ebics.dialect.directDebit() + var instantDebitOrder = cfg.ebics.dialect.instantDirectDebit() + val debitOrder = cfg.ebics.dialect.directDebit() // Create batch if necessary - client.db.initiated.batch(Instant.now(), randEbicsId(), requireAck) + db.initiated.batch(Instant.now(), randEbicsId(), requireAck) // Send submittable batches - client.db.initiated.submittable().forEach { batch -> + db.initiated.submittable().forEach { batch -> logger.debug("Submitting batch {}", batch.messageId) runCatching { if (instantDebitOrder != null) { try { - return@runCatching submitBatch(client, instantDebitOrder!!, batch, true) + return@runCatching submitBatch(client, instantDebitOrder!!, batch, cfg, true) } catch (e: EbicsError.Code) { // No longer try to submit using the instant method for now logger.debug("Failed to submit using instant credit order ${e.fmt()}") instantDebitOrder = null } } - submitBatch(client, debitOrder, batch, false) + submitBatch(client, debitOrder, batch, cfg, false) }.fold( onSuccess = { orderId -> - client.db.initiated.batchSubmissionSuccess(batch.id, Instant.now(), orderId) + db.initiated.batchSubmissionSuccess(batch.id, Instant.now(), orderId) val transactions = batch.payments.map { it.endToEndId }.joinToString(",") if (instantDebitOrder == null) { logger.info("Batch ${batch.messageId} submitted as order $orderId: $transactions") @@ -112,7 +113,7 @@ private suspend fun submitAll(client: EbicsClient, requireAck: Boolean) { } }, onFailure = { e -> - client.db.initiated.batchSubmissionFailure(batch.id, Instant.now(), e.message) + db.initiated.batchSubmissionFailure(batch.id, Instant.now(), e.message) logger.error("Batch ${batch.messageId} submission failure: ${e.fmt()}") throw e } @@ -127,9 +128,9 @@ class EbicsSubmit : EbicsCmd() { nexusConfig(config).withDb { db, cfg -> val (clientKeys, bankKeys) = expectFullKeys(cfg.ebics) val client = EbicsClient( - cfg, + cfg.ebics.host, httpClient(), - db, + db.ebics, EbicsLogger(ebicsLog), clientKeys, bankKeys @@ -137,13 +138,13 @@ class EbicsSubmit : EbicsCmd() { if (transient) { logger.info("Transient mode: submitting what found and returning.") - submitAll(client, cfg.submit.requireAck) + submitAll(client, cfg.submit.requireAck, cfg, db) } else { logger.debug("Running with a frequency of ${cfg.submit.frequencyRaw}") while (true) { val now = Instant.now(); val success = try { - submitAll(client, cfg.submit.requireAck) + submitAll(client, cfg.submit.requireAck, cfg, db) true } catch (e: Exception) { e.fmtLog(logger) diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/InitiatePayment.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/InitiatePayment.kt @@ -27,9 +27,9 @@ import com.github.ajalt.clikt.parameters.options.convert import com.github.ajalt.clikt.parameters.options.option import tech.libeufin.common.* import tech.libeufin.nexus.db.InitiatedPayment -import tech.libeufin.nexus.ebics.randEbicsId import tech.libeufin.nexus.nexusConfig import tech.libeufin.nexus.withDb +import tech.libeufin.ebics.randEbicsId import java.time.Instant class InitiatePayment: TalerCmd() { diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/List.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/List.kt @@ -32,7 +32,6 @@ import com.github.ajalt.clikt.parameters.types.* import com.github.ajalt.mordant.terminal.* import tech.libeufin.common.* import tech.libeufin.nexus.* -import tech.libeufin.nexus.ebics.* import tech.libeufin.nexus.iso20022.* import java.util.zip.* import java.time.Instant diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Manual.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Manual.kt @@ -33,7 +33,7 @@ import com.github.ajalt.mordant.terminal.* import tech.libeufin.common.* import tech.libeufin.nexus.* import tech.libeufin.nexus.db.* -import tech.libeufin.nexus.ebics.* +import tech.libeufin.ebics.randEbicsId import tech.libeufin.nexus.iso20022.* import java.util.zip.* import java.time.Instant diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt @@ -32,8 +32,9 @@ import com.github.ajalt.clikt.parameters.types.* import com.github.ajalt.mordant.terminal.* import tech.libeufin.common.* import tech.libeufin.nexus.* -import tech.libeufin.nexus.ebics.* import tech.libeufin.nexus.iso20022.* +import tech.libeufin.ebics.* +import tech.libeufin.ebics.test.txCheck import java.util.zip.* import java.time.Instant import java.io.* @@ -47,9 +48,9 @@ class Wss: TalerCmd() { nexusConfig(config).withDb { db, cfg -> val (clientKeys, bankKeys) = expectFullKeys(cfg.ebics) val client = EbicsClient( - cfg, + cfg.ebics.host, httpClient(), - db, + db.ebics, EbicsLogger(ebicsLog), clientKeys, bankKeys @@ -116,7 +117,7 @@ class TxCheck: TalerCmd() { val (clientKeys, bankKeys) = expectFullKeys(cfg) val order = cfg.dialect.downloadDoc(OrderDoc.acknowledgement) val client = httpClient() - val result = tech.libeufin.nexus.test.txCheck(client, cfg, clientKeys, bankKeys, order[0], cfg.dialect.directDebit()) + val result = txCheck(client, cfg.host, clientKeys, bankKeys, order[0], cfg.dialect.directDebit()) println("$result") } } @@ -165,9 +166,9 @@ class EbicsDownload: TalerCmd("ebics-btd") { dateToInstant(pinnedStartVal) } else null val client = EbicsClient( - cfg, + cfg.ebics.host, httpClient(), - db, + db.ebics, EbicsLogger(ebicsLog), clientKeys, bankKeys diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt @@ -27,6 +27,7 @@ import tech.libeufin.common.IbanPayto import tech.libeufin.common.db.DatabaseConfig import tech.libeufin.common.db.DbPool import tech.libeufin.common.db.watchNotifications +import tech.libeufin.ebics.EbicsDAO import java.time.Instant /** Batch of initiated outgoing payment to sent together */ diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/EbicsDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/EbicsDAO.kt @@ -1,46 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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/> - */ - -package tech.libeufin.nexus.db - -/** Data access logic for EBICS transaction */ -class EbicsDAO(private val db: Database) { - /** Register a pending transaction */ - suspend fun register(id: String) = db.serializable( - "INSERT INTO pending_ebics_transactions (tx_id) VALUES (?) ON CONFLICT DO NOTHING" - ) { - bind(id) - executeUpdate() - } - - /** Remove pending transaction */ - suspend fun remove(id: String) = db.serializable( - "DELETE FROM pending_ebics_transactions WHERE tx_id = ?" - ) { - bind(id) - executeUpdate() - } - - /** Get first pending transaction */ - suspend fun first(): String? = db.serializable( - "SELECT tx_id FROM pending_ebics_transactions LIMIT 1" - ) { - oneOrNull { it.getString(1) } - } -} -\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/dialect.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/dialect.kt @@ -0,0 +1,101 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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/> + */ +package tech.libeufin.nexus + +import tech.libeufin.ebics.* + +infix fun Collection<EbicsOrder>.select(other: Collection<EbicsOrder>): List<EbicsOrder> + = this.flatMap { filter -> other.filter { order -> filter.match(order) } } + +/** Supported EBICS standard */ +enum class Standard { + /// Swiss Payment Standards + SIX, + /// German Banking Industry Committee + GBIC; + + fun downloadDoc(doc: OrderDoc): List<EbicsOrder> = when (this) { + SIX -> when (doc) { + OrderDoc.acknowledgement -> listOf(EbicsOrder.V3.HAC) + OrderDoc.status -> listOf(EbicsOrder.V3("BTD", "PSR", "CH", "pain.002", "10", "ZIP")) + OrderDoc.report -> listOf(EbicsOrder.V3("BTD", "STM", "CH", "camt.052", "08", "ZIP")) + OrderDoc.statement -> listOf(EbicsOrder.V3("BTD", "EOP", "CH", "camt.053", "08", "ZIP")) + OrderDoc.notification -> listOf(EbicsOrder.V3("BTD", "REP", "CH", "camt.054", "08", "ZIP")) + } + GBIC -> when (doc) { + OrderDoc.acknowledgement -> listOf(EbicsOrder.V3.HAC) + OrderDoc.status -> listOf( + EbicsOrder.V3("BTD", "REP", "DE", "pain.002", null, "ZIP", "SCI"), + EbicsOrder.V3("BTD", "REP", "DE", "pain.002", null, "ZIP", "SCT") + ) + OrderDoc.report -> listOf(EbicsOrder.V3("BTD", "STM", "DE", "camt.052", null, "ZIP")) + OrderDoc.statement -> listOf(EbicsOrder.V3("BTD", "EOP", "DE", "camt.053", null, "ZIP")) + OrderDoc.notification -> listOf( + EbicsOrder.V3("BTD", "STM", "DE", "camt.054", null, "ZIP"), + EbicsOrder.V3("BTD", "STM", "DE", "camt.054", null, "ZIP", "SCI") + ) + } + } + + fun directDebit(): EbicsOrder = when (this) { + SIX -> EbicsOrder.V3("BTU", "MCT", "CH", "pain.001", "09") + GBIC -> EbicsOrder.V3("BTU", "SCT", null, "pain.001") + } + + fun instantDirectDebit(): EbicsOrder? = when (this) { + SIX -> null + GBIC -> EbicsOrder.V3("BTU", "SCI", "DE", "pain.001") + } +} + +/** Supported bank dialects */ +enum class Dialect { + valiant, + postfinance, + gls, + maerki_baumann; + + fun standard(): Standard = when (this) { + valiant, postfinance, maerki_baumann -> Standard.SIX + gls -> Standard.GBIC + } + + fun downloadDoc(doc: OrderDoc): List<EbicsOrder> { + if (this == maerki_baumann) throw IllegalArgumentException("Maerki Baumann does not have EBICS access") + return this.standard().downloadDoc(doc) + } + + fun directDebit(): EbicsOrder { + if (this == maerki_baumann) throw IllegalArgumentException("Maerki Baumann does not have EBICS access") + return this.standard().directDebit() + } + + fun instantDirectDebit(): EbicsOrder? { + if (this == maerki_baumann) throw IllegalArgumentException("Maerki Baumann does not have EBICS access") + return this.standard().instantDirectDebit() + } + + /** All orders required for a dialect implementation to work */ + fun downloadOrders(): Set<EbicsOrder> = ( + // Administrative orders + sequenceOf(EbicsOrder.V3.HAA, EbicsOrder.V3.HKD) + // and documents orders + + OrderDoc.entries.flatMap { downloadDoc(it) } + ).toSet() +} +\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt @@ -1,172 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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/> - */ - -package tech.libeufin.nexus.ebics - -import org.w3c.dom.Document -import tech.libeufin.nexus.NexusEbicsConfig -import tech.libeufin.nexus.XmlBuilder -import tech.libeufin.nexus.XmlDestructor -import java.io.InputStream - -data class VersionNumber(val number: Float, val schema: String) { - override fun toString(): String = "$number:$schema" -} - -data class HKD( - val partner: PartnerInfo, - val users: List<UserInfo> -) -data class PartnerInfo( - val name: String?, - val accounts: List<AccountInfo>, - val orders: List<OrderInfo> -) -data class OrderInfo( - val order: EbicsOrder, - val description: String, -) -data class AccountInfo( - val currency: String, - val iban: String, - val bic: String -) -data class UserInfo( - val id: String, - val status: UserStatus, - val permissions: List<EbicsOrder>, -) - -data class HAA( - val orders: List<EbicsOrder> -) - -enum class UserStatus(val description: String) { - Ready("Subscriber is permitted access"), - New("Subscriber is established, pending access permission"), - INI("Subscriber has sent INI file, but no HIA file yet"), - HIA("Subscriber has sent HIA order, but no INI file yet"), - Initialised("Subscriber has sent both HIA order and INI file"), - SuspendedFailedAttempts("Suspended after several failed attempts, new initialisation via INI and HIA possible"), - SuspendedSPR("Suspended after SPR order, new initialisation via INI and HIA possible"), - SuspendedBank("Suspended by bank, new initialisation via INI and HIA is not possible, suspension can only be revoked by the bank"), -} - -object EbicsAdministrative { - fun HEV(cfg: NexusEbicsConfig): ByteArray { - return XmlBuilder.toBytes("ebicsHEVRequest") { - attr("xmlns", "http://www.ebics.org/H000") - el("HostID", cfg.host.ebicsHostId) - } - } - - fun parseHEV(doc: Document): EbicsResponse<List<VersionNumber>> { - return XmlDestructor.parse(doc, "ebicsHEVResponse") { - val technicalCode = one("SystemReturnCode") { - EbicsReturnCode.lookup(one("ReturnCode").text()) - } - val versions = map("VersionNumber") { - VersionNumber(text().toFloat(), attr("ProtocolVersion")) - } - EbicsResponse( - technicalCode = technicalCode, - bankCode = EbicsReturnCode.EBICS_OK, - content = versions - ) - } - } - - private fun XmlDestructor.ebicsOrder(type: String): EbicsOrder = - EbicsOrder.V3( - type = type, - service = opt("ServiceName")?.text(), - scope = opt("Scope")?.text(), - option = opt("ServiceOption")?.text(), - container = opt("Container")?.attr("containerType"), - message = opt("MsgName")?.text(), - version = opt("MsgName")?.optAttr("version"), - ) - - fun parseHKD(stream: InputStream): HKD { - fun XmlDestructor.order(): EbicsOrder { - val type = one("AdminOrderType").text() - return opt("Service") { - ebicsOrder(type) - } ?: EbicsOrder.V3(type) - } - return XmlDestructor.parse(stream, "HKDResponseOrderData") { - val partnerInfo = one("PartnerInfo") { - val name = one("AddressInfo").opt("Name")?.text() - val accounts = map("AccountInfo") { - var currency = attr("Currency") - lateinit var iban: String - lateinit var bic: String - each("AccountNumber") { - if (attr("international") == "true") { - iban = text() - } - } - each("BankCode") { - if (attr("international") == "true") { - bic = text() - } - } - AccountInfo(currency, iban, bic) - } - val orders = map("OrderInfo") { - OrderInfo( - order = order(), - description = one("Description").text() - ) - } - PartnerInfo(name, accounts, orders) - } - val usersInfo = map("UserInfo") { - val (id, status) = one("UserID") { - val id = text() - val status = when (val status = attr("Status")) { - "1" -> UserStatus.Ready - "2" -> UserStatus.New - "3" -> UserStatus.INI - "4" -> UserStatus.HIA - "5" -> UserStatus.Initialised - "6" -> UserStatus.SuspendedFailedAttempts - // 7 is not applicable per spec - "8" -> UserStatus.SuspendedSPR - "9" -> UserStatus.SuspendedBank - else -> throw Exception("Unknown user statte $status") - } - Pair(id, status) - } - val permissions = map("Permission") { order() } - UserInfo(id, status, permissions) - } - HKD(partnerInfo, usersInfo) - } - } - - fun parseHAA(stream: InputStream): HAA { - return XmlDestructor.parse(stream, "HAAResponseOrderData") { - val orders = map("Service") { - ebicsOrder("BTD") - } - HAA(orders) - } - } -} diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt @@ -1,349 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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/> - */ -package tech.libeufin.nexus.ebics - -import org.w3c.dom.Document -import tech.libeufin.common.crypto.CryptoUtil -import tech.libeufin.common.decodeBase64 -import tech.libeufin.common.encodeBase64 -import tech.libeufin.common.encodeHex -import tech.libeufin.common.encodeUpHex -import tech.libeufin.nexus.* -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter - - -fun Instant.xmlDate(): String = - DateTimeFormatter.ISO_DATE.withZone(ZoneId.of("UTC")).format(this) -fun Instant.xmlDateTime(): String = - DateTimeFormatter.ISO_OFFSET_DATE_TIME.withZone(ZoneId.of("UTC")).format(this) - -/** EBICS protocol for business transactions */ -class EbicsBTS( - val cfg: NexusEbicsConfig, - val bankKeys: BankPublicKeysFile, - val clientKeys: ClientPrivateKeysFile, - val order: EbicsOrder -) { - /* ----- Download ----- */ - - fun downloadInitialization(startDate: Instant?, endDate: Instant?): ByteArray { - val nonce = getNonce(128) - return signedRequest { - el("header") { - attr("authenticate", "true") - el("static") { - el("HostID", cfg.host.ebicsHostId) - el("Nonce", nonce.encodeHex()) - el("Timestamp", Instant.now().xmlDateTime()) - el("PartnerID", cfg.host.ebicsPartnerId) - el("UserID", cfg.host.ebicsUserId) - // SystemID - // Product - el("OrderDetails") { - when (order) { - is EbicsOrder.V2_5 -> { - el("OrderType", order.type) - el("OrderAttribute", order.attribute) - el("StandardOrderParams") { - if (startDate != null) { - el("DateRange") { - el("Start", startDate.xmlDate()) - el("End", (endDate ?: Instant.now()).xmlDate()) - } - } - } - } - is EbicsOrder.V3 -> { - el("AdminOrderType", order.type) - if (order.type == "BTD") { - el("BTDOrderParams") { - service(order) - if (startDate != null) { - el("DateRange") { - el("Start", startDate.xmlDate()) - el("End", (endDate ?: Instant.now()).xmlDate()) - } - } - } - } else { - el("StandardOrderParams") - } - } - } - } - bankDigest() - } - el("mutable/TransactionPhase", "Initialisation") - } - el("AuthSignature") - el("body") - } - } - - fun downloadTransfer( - nbSegment: Int, - segmentNumber: Int, - transactionId: String - ): ByteArray { - return signedRequest { - el("header") { - attr("authenticate", "true") - el("static") { - el("HostID", cfg.host.ebicsHostId) - el("TransactionID", transactionId) - } - el("mutable") { - el("TransactionPhase", "Transfer") - el("SegmentNumber") { - attr("lastSegment", if (nbSegment == segmentNumber) "true" else "false") - text(segmentNumber.toString()) - } - } - } - el("AuthSignature") - el("body") - } - } - - fun downloadReceipt( - transactionId: String, - success: Boolean - ): ByteArray { - return signedRequest { - el("header") { - attr("authenticate", "true") - el("static") { - el("HostID", cfg.host.ebicsHostId) - el("TransactionID", transactionId) - } - el("mutable") { - el("TransactionPhase", "Receipt") - } - } - el("AuthSignature") - el("body/TransferReceipt") { - attr("authenticate", "true") - el("ReceiptCode", if (success) "0" else "1") - } - } - } - - /* ----- Upload ----- */ - - fun uploadInitialization(uploadData: PreparedUploadData): ByteArray { - val nonce = getNonce(128) - return signedRequest { - el("header") { - attr("authenticate", "true") - el("static") { - el("HostID", cfg.host.ebicsHostId) - el("Nonce", nonce.encodeUpHex()) - el("Timestamp", Instant.now().xmlDateTime()) - el("PartnerID", cfg.host.ebicsPartnerId) - el("UserID", cfg.host.ebicsUserId) - // SystemID - // Product - el("OrderDetails") { - when (order) { - is EbicsOrder.V2_5 -> { - // TODO - } - is EbicsOrder.V3 -> { - el("AdminOrderType", order.type) - el("BTUOrderParams") { - service(order) - el("SignatureFlag", "true") - } - } - } - } - bankDigest() - el("NumSegments", uploadData.segments.size.toString()) - - } - el("mutable/TransactionPhase", "Initialisation") - } - el("AuthSignature") - el("body") { - el("DataTransfer") { - el("DataEncryptionInfo") { - attr("authenticate", "true") - el("EncryptionPubKeyDigest") { - attr("Version", "E002") - attr("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256") - text(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).encodeBase64()) - } - el("TransactionKey", uploadData.transactionKey.encodeBase64()) - } - el("SignatureData") { - attr("authenticate", "true") - text(uploadData.userSignatureDataEncrypted) - } - el("DataDigest") { - attr("SignatureVersion", "A006") - text(uploadData.dataDigest.encodeBase64()) - } - } - } - } - } - - fun uploadTransfer( - transactionId: String, - uploadData: PreparedUploadData, - segmentNumber: Int - ): ByteArray { - return signedRequest { - el("header") { - attr("authenticate", "true") - el("static") { - el("HostID", cfg.host.ebicsHostId) - el("TransactionID", transactionId) - } - el("mutable") { - el("TransactionPhase", "Transfer") - el("SegmentNumber") { - attr("lastSegment", if (uploadData.segments.size == segmentNumber) "true" else "false") - text(segmentNumber.toString()) - } - } - } - el("AuthSignature") - el("body/DataTransfer/OrderData", uploadData.segments[segmentNumber-1]) - } - } - - /* ----- Helpers ----- */ - - /** Generate a signed ebicsRequest */ - private fun signedRequest(lambda: XmlBuilder.() -> Unit): ByteArray { - val doc = XmlBuilder.toDom("ebicsRequest", "urn:org:ebics:${order.schema}") { - attr("http://www.w3.org/2000/xmlns/", "xmlns", "urn:org:ebics:${order.schema}") - attr("http://www.w3.org/2000/xmlns/", "xmlns:ds", "http://www.w3.org/2000/09/xmldsig#") - attr("Version", order.schema) - attr("Revision", "1") - lambda() - } - XMLUtil.signEbicsDocument( - doc, - clientKeys.authentication_private_key - ) - return XMLUtil.convertDomToBytes(doc) - } - - private fun XmlBuilder.bankDigest() { - el("BankPubKeyDigests") { - el("Authentication") { - attr("Version", "X002") - attr("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256") - text(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key).encodeBase64()) - } - el("Encryption") { - attr("Version", "E002") - attr("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256") - text(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).encodeBase64()) - } - // Signature - } - el("SecurityMedium", "0000") - } - - private fun XmlBuilder.service(order: EbicsOrder.V3) { - el("Service") { - el("ServiceName", order.service!!) - if (order.scope != null) { - el("Scope", order.scope) - } - if (order.option != null) { - el("ServiceOption", order.option) - } - if (order.container != null) { - el("Container") { - attr("containerType", order.container) - } - } - el("MsgName") { - if (order.version != null) - attr("version", order.version) - text(order.message!!) - } - } - } - - companion object { - fun parseResponse(doc: Document): EbicsResponse<BTSResponse> { - return XmlDestructor.parse(doc, "ebicsResponse") { - var transactionID: String? = null - var numSegments: Int? = null - lateinit var technicalCode: EbicsReturnCode - lateinit var bankCode: EbicsReturnCode - var orderID: String? = null - var segmentNumber: Int? = null - var segment: ByteArray? = null - var dataEncryptionInfo: DataEncryptionInfo? = null - one("header", signed = true) { - one("static") { - transactionID = opt("TransactionID")?.text() - numSegments = opt("NumSegments")?.text()?.toInt() - } - one("mutable") { - segmentNumber = opt("SegmentNumber")?.text()?.toInt() - orderID = opt("OrderID")?.text() - technicalCode = EbicsReturnCode.lookup(one("ReturnCode").text()) - } - } - one("body") { - opt("DataTransfer") { - segment = one("OrderData").base64() - dataEncryptionInfo = opt("DataEncryptionInfo", signed = true) { - DataEncryptionInfo( - one("TransactionKey").base64(), - one("EncryptionPubKeyDigest").base64() - ) - } - } - bankCode = EbicsReturnCode.lookup(one("ReturnCode", signed = true).text()) - } - EbicsResponse( - bankCode = bankCode, - technicalCode = technicalCode, - content = BTSResponse( - transactionID = transactionID, - orderID = orderID, - segment = segment, - dataEncryptionInfo = dataEncryptionInfo, - numSegments = numSegments, - segmentNumber = segmentNumber - ) - ) - } - } - } -} - -class BTSResponse( - val transactionID: String?, - val orderID: String?, - val dataEncryptionInfo: DataEncryptionInfo?, - val segment: ByteArray?, - val segmentNumber: Int?, - val numSegments: Int? -) -\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt @@ -1,441 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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/> - */ - -package tech.libeufin.nexus.ebics - -import io.ktor.client.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.utils.io.jvm.javaio.* -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.withContext -import org.w3c.dom.Document -import org.xml.sax.SAXException -import tech.libeufin.common.* -import tech.libeufin.common.crypto.CryptoUtil -import tech.libeufin.nexus.* -import tech.libeufin.nexus.db.Database -import java.io.InputStream -import java.io.SequenceInputStream -import java.security.interfaces.RSAPrivateCrtKey -import java.time.Instant -import java.util.* - -internal val logger: Logger = LoggerFactory.getLogger("libeufin-ebics") - -/** Supported documents that can be downloaded via EBICS */ -enum class SupportedDocument { - PAIN_002, - PAIN_002_LOGS, - CAMT_053, - CAMT_052, - CAMT_054 -} - -/** EBICS related errors */ -sealed class EbicsError(msg: String, cause: Throwable? = null): Exception(msg, cause) { - /** Network errors */ - class Network(msg: String, cause: Throwable): EbicsError(msg, cause) - /** Http errors */ - class HTTP(msg: String, val status: HttpStatusCode): EbicsError(msg) - /** EBICS protocol & XML format error */ - class Protocol(msg: String, cause: Throwable? = null): EbicsError(msg, cause) - /** EBICS protocol & XML format error */ - class Code(msg: String, val technicalCode: EbicsReturnCode, val bankCode: EbicsReturnCode): EbicsError(msg) -} - -/** POST an EBICS request [msg] to [bankUrl] returning a parsed XML response */ -suspend fun HttpClient.postToBank( - bankUrl: String, - msg: ByteArray, - phase: String, - stepLogger: StepLogger -): Document { - stepLogger.logRequest(msg) - val res = try { - post(urlString = bankUrl) { - contentType(ContentType.Text.Xml) - setBody(msg) - } - } catch (e: Exception) { - throw EbicsError.Network("$phase: failed to contact bank", e) - } - - if (res.status != HttpStatusCode.OK) { - stepLogger.logFailure(res) - throw EbicsError.HTTP("$phase: bank HTTP error: ${res.status}", res.status) - } - try { - val bodyStream = res.bodyAsChannel().toInputStream() - val loggedStream = stepLogger.logResponse(bodyStream) - return XMLUtil.parseIntoDom(loggedStream) - } catch (e: SAXException) { - throw EbicsError.Protocol("$phase: invalid XML bank response", e) - } catch (e: Exception) { - throw EbicsError.Network("$phase: failed read bank response", e) - } -} - -/** POST an EBICS BTS request [xmlReq] using [client] returning a validated and parsed XML response */ -suspend fun EbicsBTS.postBTS( - client: HttpClient, - xmlReq: ByteArray, - phase: String, - stepLogger: StepLogger -): BTSResponse { - val doc = client.postToBank(cfg.host.baseUrl, xmlReq, phase, stepLogger) - try { - XMLUtil.verifyEbicsDocument( - doc, - bankKeys.bank_authentication_public_key - ) - } catch (e: Exception) { - throw EbicsError.Protocol("$phase ${order.description()}: invalid signature", e) - } - val response = try { - EbicsBTS.parseResponse(doc) - } catch (e: Exception) { - throw EbicsError.Protocol("$phase ${order.description()}: invalid ebics response", e) - } - logger.debug { - buildString { - append(phase) - response.content.transactionID?.let { - append(" for ") - append(it) - } - append(": ") - append(response.technicalCode) - append(" & ") - append(response.bankCode) - } - } - return response.okOrFail(phase) -} - -/** High level EBICS client */ -class EbicsClient( - val cfg: NexusConfig, - val client: HttpClient, - val db: Database, - val ebicsLogger: EbicsLogger, - val clientKeys: ClientPrivateKeysFile, - val bankKeys: BankPublicKeysFile -) { - /** - * 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. - */ - suspend fun <T> download( - order: EbicsOrder, - startDate: Instant? = null, - endDate: Instant? = null, - peek: Boolean = false, - processing: suspend (InputStream) -> T, - ): T { - val description = order.description() - logger.debug { - buildString { - append("Download order ") - append(description) - if (startDate != null) { - append(" from ") - append(startDate) - if (endDate != null) { - append(" to ") - append(endDate) - } - } - } - } - val impl = EbicsBTS(cfg.ebics, bankKeys, clientKeys, order) - - // Close interrupted - val interruptedLog = ebicsLogger.tx("INTD") - while (true) { - val tId = db.ebics.first() - if (tId == null) break - val xml = impl.downloadReceipt(tId, false) - try { - impl.postBTS(client, xml, "Closing interrupted transaction ${tId}", interruptedLog.step(tId)) - } catch (e: Exception) { - when (e) { - // Transaction already closed or expired - EBICS protocol error - is EbicsError.Code if e.technicalCode == EbicsReturnCode.EBICS_TX_UNKNOWN_TXID -> {} - // Transaction already closed or expired - HTTP protocol error for non compliant banks - is EbicsError.HTTP if e.status == HttpStatusCode.BadRequest -> {} - // Unexpected error - else -> throw e - } - logger.debug("${e.fmt()}") - } - db.ebics.remove(tId) - } - - val txLog = ebicsLogger.tx(order) - - // We need to run the logic in a non-cancelable context because we need to send - // a receipt for each open download transaction, otherwise we'll be stuck in an - // error loop until the pending transaction timeout. - val (tId, initContent) = withContext(NonCancellable) { - // Init phase - val initReq = impl.downloadInitialization(startDate, endDate) - val initContent = impl.postBTS(client, initReq, "Download init $description", txLog.step("init")) - val tId = requireNotNull(initContent.transactionID) { - "Download init $description: missing transaction ID" - } - db.ebics.register(tId) - Pair(tId, initContent) - } - val howManySegments = requireNotNull(initContent.numSegments) { - "Download init $description: missing num segments" - } - val firstSegment = requireNotNull(initContent.segment) { - "Download init $description: missing OrderData" - } - val dataEncryptionInfo = requireNotNull(initContent.dataEncryptionInfo) { - "Download init $description: missing EncryptionInfo" - } - - // Transfer phase - val segments = mutableListOf(firstSegment) - for (x in 2 .. howManySegments) { - val transReq = impl.downloadTransfer(x, howManySegments, tId) - val transResp = impl.postBTS(client, transReq, "Download transfer $description", txLog.step("transfer$x")) - val segment = requireNotNull(transResp.segment) { - "Download transfer: missing encrypted segment" - } - segments.add(segment) - } - - - // Decompress encrypted chunks - val payloadStream = try { - decryptAndDecompressPayload( - clientKeys.encryption_private_key, - dataEncryptionInfo, - segments - ) - } catch (e: Exception) { - throw EbicsError.Protocol("invalid chunks", e) - } - - val container = when (order) { - is EbicsOrder.V2_5 -> "rax" // TODO infer ? - is EbicsOrder.V3 -> order.container ?: "xml" - } - val loggedStream = txLog.payload(payloadStream, container) - - // Run business logic - val res = runCatching { - processing(loggedStream) - } - - // First send a proper EBICS transaction receipt - val xml = impl.downloadReceipt(tId, res.isSuccess && !peek) - impl.postBTS(client, xml, "Download receipt $description", txLog.step("receipt")) - runCatching { db.ebics.remove(tId) } - // Then throw business logic exception if any - return res.getOrThrow() - } - - /** - * Performs an EBICS upload transaction of [order] using [payload]. - * - * It conducts init -> upload phases. - * - * Returns upload orderID - */ - suspend fun upload( - order: EbicsOrder, - payload: ByteArray, - ): String { - val description = order.description(); - logger.debug { "Upload order $description" } - val txLog = ebicsLogger.tx(order) - val impl = EbicsBTS(cfg.ebics, bankKeys, clientKeys, order) - val preparedPayload = prepareUploadPayload(cfg.ebics, clientKeys, bankKeys, payload) - - // Init phase - val initXml = impl.uploadInitialization(preparedPayload) - val initResp = impl.postBTS(client, initXml, "Upload init $description", txLog.step("init")) - val tId = requireNotNull(initResp.transactionID) { - "Upload init $description: missing transaction ID" - } - val orderId = requireNotNull(initResp.orderID) { - "Upload init $description: missing order ID" - } - - txLog.payload(payload, "xml") - - // Transfer phase - for (i in 1..preparedPayload.segments.size) { - val transferXml = impl.uploadTransfer(tId, preparedPayload, i) - impl.postBTS(client, transferXml, "Upload transfer $description", txLog.step("transfer$i")) - } - return orderId - } -} - -suspend fun HEV( - client: HttpClient, - cfg: NexusEbicsConfig, - ebicsLogger: EbicsLogger -): List<VersionNumber> { - logger.info("Doing administrative request HEV") - val txLog = ebicsLogger.tx("HEV") - val req = EbicsAdministrative.HEV(cfg) - val xml = client.postToBank(cfg.host.baseUrl, req, "HEV", txLog.step()) - return EbicsAdministrative.parseHEV(xml).okOrFail("HEV") -} - -suspend fun keyManagement( - cfg: NexusEbicsConfig, - privs: ClientPrivateKeysFile, - client: HttpClient, - ebicsLogger: EbicsLogger, - order: EbicsKeyMng.Order -): EbicsResponse<InputStream?> { - logger.info("Doing key request $order") - val txLog = ebicsLogger.tx(order.name) - val ebics3 = when (cfg.dialect) { - // TODO GLS needs EBICS 2.5 for key management - Dialect.gls -> false - else -> true - } - val req = EbicsKeyMng(cfg, privs, ebics3).request(order) - val xml = client.postToBank(cfg.host.baseUrl, req, order.name, txLog.step()) - return EbicsKeyMng.parseResponse(xml, privs.encryption_private_key) -} - -class PreparedUploadData( - val transactionKey: ByteArray, - val userSignatureDataEncrypted: String, - val dataDigest: ByteArray, - val segments: List<String> -) - -/** Signs, encrypts and format EBICS BTS payload */ -fun prepareUploadPayload( - cfg: NexusEbicsConfig, - clientKeys: ClientPrivateKeysFile, - bankKeys: BankPublicKeysFile, - payload: ByteArray, -): PreparedUploadData { - val payloadDigest = CryptoUtil.digestEbicsOrderA006(payload) - val innerSignedEbicsXml = XmlBuilder.toBytes("UserSignatureData") { - attr("xmlns", "http://www.ebics.org/S002") - el("OrderSignatureData") { - el("SignatureVersion", "A006") - el("SignatureValue", CryptoUtil.signEbicsA006( - payloadDigest, - clientKeys.signature_private_key, - ).encodeBase64()) - el("PartnerID", cfg.host.ebicsPartnerId) - el("UserID", cfg.host.ebicsUserId) - } - } - // Generate ephemeral transaction key - val (transactionKey, encryptedTransactionKey) = CryptoUtil.genEbicsE002Key(bankKeys.bank_encryption_public_key) - // Compress and encrypt order signature - val orderSignature = CryptoUtil.encryptEbicsE002( - transactionKey, - innerSignedEbicsXml.inputStream().deflate() - ).encodeBase64() - // Compress and encrypt payload - val encrypted = CryptoUtil.encryptEbicsE002( - transactionKey, - payload.inputStream().deflate() - ) - // Chunks of 1MB and encode segments - val segments = encrypted.encodeBase64().chunked(1000000) - - return PreparedUploadData( - encryptedTransactionKey, - orderSignature, - payloadDigest, - segments - ) -} - -/** Decrypts and decompresses EBICS BTS payload */ -fun decryptAndDecompressPayload( - clientEncryptionKey: RSAPrivateCrtKey, - encryptionInfo: DataEncryptionInfo, - segments: List<ByteArray> -): InputStream { - val transactionKey = CryptoUtil.decryptEbicsE002Key(clientEncryptionKey, encryptionInfo.transactionKey) - return SequenceInputStream(Collections.enumeration(segments.map { it.inputStream() })) // Aggregate - .run { - CryptoUtil.decryptEbicsE002( - transactionKey, - this - ) - }.inflate() -} - -/** Generate a secure random nonce of [size] bytes */ -fun getNonce(size: Int): ByteArray { - return ByteArray(size / 8).secureRand() -} - -private val EBICS_ID_ALPHABET = ('A'..'Z') + ('0'..'9') - -fun randEbicsId(): String { - return List(34) { EBICS_ID_ALPHABET.random() }.joinToString("") -} - -class DataEncryptionInfo( - val transactionKey: ByteArray, - val bankPubDigest: ByteArray -) - -class EbicsResponse<T>( - val technicalCode: EbicsReturnCode, - val bankCode: EbicsReturnCode, - internal val content: T -) { - /** Checks that return codes are both EBICS_OK */ - fun ok(): T? { - return if (technicalCode.kind() != EbicsReturnCode.Kind.Error && - bankCode.kind() != EbicsReturnCode.Kind.Error) { - content - } else { - null - } - } - - /** Checks that return codes are both EBICS_OK or throw an exception */ - fun okOrFail(phase: String): T { - if (technicalCode.kind() == EbicsReturnCode.Kind.Error) { - throw EbicsError.Code("$phase has technical error: $technicalCode", technicalCode, bankCode) - } else if (bankCode.kind() == EbicsReturnCode.Kind.Error) { - throw EbicsError.Code("$phase has bank error: $bankCode", technicalCode, bankCode) - } else { - return content - } - } -} -\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsConstants.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsConstants.kt @@ -1,108 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024 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/> - */ - -package tech.libeufin.nexus.ebics - - -// TODO import missing using a script -@Suppress("SpellCheckingInspection") -enum class EbicsReturnCode(val code: String) { - EBICS_OK("000000"), - EBICS_DOWNLOAD_POSTPROCESS_DONE("011000"), - EBICS_DOWNLOAD_POSTPROCESS_SKIPPED("011001"), - EBICS_TX_SEGMENT_NUMBER_UNDERRUN("011101"), - EBICS_AUTHENTICATION_FAILED("061001"), - EBICS_INVALID_REQUEST("061002"), - EBICS_INTERNAL_ERROR("061099"), - EBICS_TX_RECOVERY_SYNC("061101"), - EBICS_AUTHORISATION_ORDER_IDENTIFIER_FAILED("090003"), - EBICS_INVALID_ORDER_DATA_FORMAT("090004"), - EBICS_NO_DOWNLOAD_DATA_AVAILABLE("090005"), - - // Transaction administration - EBICS_INVALID_USER_OR_USER_STATE("091002"), - EBICS_USER_UNKNOWN("091003"), - EBICS_INVALID_USER_STATE("091004"), - EBICS_INVALID_ORDER_IDENTIFIER("091005"), - EBICS_UNSUPPORTED_ORDER_TYPE("091006"), - EBICS_INVALID_XML("091010"), - EBICS_TX_MESSAGE_REPLAY("091103"), - EBICS_TX_SEGMENT_NUMBER_EXCEEDED("091104"), - EBICS_TX_UNKNOWN_TXID("091101"), - EBICS_INVALID_REQUEST_CONTENT("091113"), - EBICS_PROCESSING_ERROR("091116"), - - // Key-Management errors - EBICS_KEYMGMT_UNSUPPORTED_VERSION_SIGNATURE("091201"), - EBICS_KEYMGMT_UNSUPPORTED_VERSION_AUTHENTICATION("091202"), - EBICS_KEYMGMT_UNSUPPORTED_VERSION_ENCRYPTION("091203"), - EBICS_KEYMGMT_KEYLENGTH_ERROR_SIGNATURE("091204"), - EBICS_KEYMGMT_KEYLENGTH_ERROR_AUTHENTICATION("091205"), - EBICS_KEYMGMT_KEYLENGTH_ERROR_ENCRYPTION("091206"), - EBICS_X509_CERTIFICATE_EXPIRED("091208"), - EBICS_X509_CERTIFICATE_NOT_VALID_YET("091209"), - EBICS_X509_WRONG_KEY_USAGE("091210"), - EBICS_X509_WRONG_ALGORITHM("091211"), - EBICS_X509_INVALID_THUMBPRINT("091212"), - EBICS_X509_CTL_INVALID("091213"), - EBICS_X509_UNKNOWN_CERTIFICATE_AUTHORITY("091214"), - EBICS_X509_INVALID_POLICY("091215"), - EBICS_X509_INVALID_BASIC_CONSTRAINTS("091216"), - EBICS_ONLY_X509_SUPPORT("091217"), - EBICS_KEYMGMT_DUPLICATE_KEY("091218"), - EBICS_CERTIFICATE_VALIDATION_ERROR("091219"), - - // Pre-erification errors - EBICS_SIGNATURE_VERIFICATION_FAILED("091301"), - EBICS_ACCOUNT_AUTHORISATION_FAILED("091302"), - EBICS_AMOUNT_CHECK_FAILED("091303"), - EBICS_SIGNER_UNKNOWN("091304"), - EBICS_INVALID_SIGNER_STATE("091305"), - EBICS_DUPLICATE_SIGNATURE("091306"); - - enum class Kind { - Information, - Note, - Warning, - Error - } - - fun kind(): Kind { - return when (val errorClass = code.substring(0..1)) { - "00" -> Kind.Information - "01" -> Kind.Note - "03" -> Kind.Warning - "06", "09" -> Kind.Error - else -> throw Exception("Unknown EBICS status code error class: $errorClass") - } - } - - companion object { - fun lookup(code: String): EbicsReturnCode { - for (x in entries) { - if (x.code == code) { - return x - } - } - throw Exception( - "Unknown EBICS status code: $code" - ) - } - } -} -\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsKeyMng.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsKeyMng.kt @@ -1,202 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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/> - */ - -package tech.libeufin.nexus.ebics - -import org.w3c.dom.Document -import tech.libeufin.common.crypto.CryptoUtil -import tech.libeufin.common.decodeBase64 -import tech.libeufin.common.deflate -import tech.libeufin.common.encodeBase64 -import tech.libeufin.common.encodeUpHex -import tech.libeufin.nexus.* -import tech.libeufin.nexus.ebics.EbicsKeyMng.Order.* -import java.io.InputStream -import java.security.interfaces.RSAPrivateCrtKey -import java.security.interfaces.RSAPublicKey -import java.time.Instant - -/** EBICS protocol for key management */ -class EbicsKeyMng( - private val cfg: NexusEbicsConfig, - private val clientKeys: ClientPrivateKeysFile, - private val ebics3: Boolean -) { - private val schema = if (ebics3) "H005" else "H004" - - enum class Order { - INI, - HIA, - HPB - } - - fun request(order: Order): ByteArray { - val (name, securityMedium, orderAttribute) = when (order) { - INI, HIA -> Triple("ebicsUnsecuredRequest", "0200", "DZNNN") - HPB -> Triple("ebicsNoPubKeyDigestsRequest", "0000", "DZHNN") - } - val data = when (order) { - INI -> XMLOrderData("SignaturePubKeyOrderData", "http://www.ebics.org/S00${if (ebics3) 2 else 1}") { - el("SignaturePubKeyInfo") { - RSAKeyXml(clientKeys.signature_private_key) - el("SignatureVersion", "A006") - } - } - HIA -> XMLOrderData("HIARequestOrderData", "urn:org:ebics:$schema") { - el("AuthenticationPubKeyInfo") { - RSAKeyXml(clientKeys.authentication_private_key) - el("AuthenticationVersion", "X002") - } - el("EncryptionPubKeyInfo") { - RSAKeyXml(clientKeys.encryption_private_key) - el("EncryptionVersion", "E002") - } - } - HPB -> null - } - val sign = order == HPB - val doc = XmlBuilder.toDom(name, "urn:org:ebics:$schema") { - attr("http://www.w3.org/2000/xmlns/", "xmlns", "urn:org:ebics:$schema") - attr("http://www.w3.org/2000/xmlns/", "xmlns:ds", "http://www.w3.org/2000/09/xmldsig#") - attr("Version", schema) - attr("Revision", "1") - el("header") { - attr("authenticate", "true") - el("static") { - el("HostID", cfg.host.ebicsHostId) - if (order == HPB) { - el("Nonce", getNonce(128).encodeUpHex()) - el("Timestamp", Instant.now().xmlDateTime()) - } - el("PartnerID", cfg.host.ebicsPartnerId) - el("UserID", cfg.host.ebicsUserId) - el("OrderDetails") { - if (ebics3) { - el("AdminOrderType", order.name) - } else { - el("OrderType", order.name) - el("OrderAttribute", orderAttribute) - } - } - el("SecurityMedium", securityMedium) - } - el("mutable") - } - if (sign) el("AuthSignature") - el("body") { - if (data != null) el("DataTransfer/OrderData", data) - } - } - if (sign) XMLUtil.signEbicsDocument(doc, clientKeys.authentication_private_key) - return XMLUtil.convertDomToBytes(doc) - } - - private fun XmlBuilder.RSAKeyXml(key: RSAPrivateCrtKey) { - if (ebics3) { - val cert = CryptoUtil.X509CertificateFromRSAPrivate(key, cfg.account.name) - el("ds:X509Data") { - el("ds:X509Certificate", cert.encoded.encodeBase64()) - } - } else { - el("PubKeyValue") { - el("ds:RSAKeyValue") { - el("ds:Modulus", key.modulus.encodeBase64()) - el("ds:Exponent", key.publicExponent.encodeBase64()) - } - } - } - } - - private fun XMLOrderData(name: String, schema: String, build: XmlBuilder.() -> Unit): String { - return XmlBuilder.toBytes(name) { - attr("xmlns:ds", "http://www.w3.org/2000/09/xmldsig#") - attr("xmlns", schema) - build() - el("PartnerID", cfg.host.ebicsPartnerId) - el("UserID", cfg.host.ebicsUserId) - }.inputStream().deflate().encodeBase64() - } - - companion object { - fun parseResponse(doc: Document, clientEncryptionKey: RSAPrivateCrtKey): EbicsResponse<InputStream?> { - return XmlDestructor.parse(doc, "ebicsKeyManagementResponse") { - lateinit var technicalCode: EbicsReturnCode - lateinit var bankCode: EbicsReturnCode - var payload: InputStream? = null - one("header", signed = true) { - one("mutable") { - technicalCode = EbicsReturnCode.lookup(one("ReturnCode").text()) - } - } - one("body") { - bankCode = EbicsReturnCode.lookup(one("ReturnCode", signed = true).text()) - payload = opt("DataTransfer") { - val descriptionInfo = one("DataEncryptionInfo", signed = true) { - DataEncryptionInfo( - one("TransactionKey").base64(), - one("EncryptionPubKeyDigest").base64() - ) - } - val chunk = one("OrderData").base64() - decryptAndDecompressPayload( - clientEncryptionKey, - descriptionInfo, - listOf(chunk) - ) - } - } - EbicsResponse( - technicalCode = technicalCode, - bankCode, - content = payload - ) - } - } - - fun parseHpbOrder(data: InputStream): Pair<RSAPublicKey, RSAPublicKey> { - return XmlDestructor.parse(data, "HPBResponseOrderData") { - val authPub = one("AuthenticationPubKeyInfo") { - val version = one("AuthenticationVersion").text() - require(version == "X002") { "Expected authentication version X002 got unsupported $version" } - rsaPubKey() - } - val encPub = one("EncryptionPubKeyInfo") { - val version = one("EncryptionVersion").text() - require(version == "E002") { "Expected encryption version E002 got unsupported $version" } - rsaPubKey() - } - Pair(authPub, encPub) - } - } - } -} - -fun XmlDestructor.rsaPubKey(): RSAPublicKey { - val cert = opt("X509Data")?.one("X509Certificate")?.text()?.decodeBase64() - return if (cert != null) { - CryptoUtil.RSAPublicFromCertificate(cert) - } else { - one("PubKeyValue").one("RSAKeyValue") { - CryptoUtil.RSAPublicFromComponents( - one("Modulus").base64(), - one("Exponent").base64(), - ) - } - } -} -\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsLogger.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsLogger.kt @@ -1,145 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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/> - */ - -package tech.libeufin.nexus.ebics - -import tech.libeufin.common.* -import tech.libeufin.nexus.ebics.EbicsOrder -import java.io.* -import java.nio.file.* -import java.time.* -import java.time.format.DateTimeFormatter -import kotlin.io.* -import kotlin.io.path.* -import io.ktor.client.statement.* - -/** Log EBICS transactions steps and payload if [path] is not null */ -class EbicsLogger(private val dir: Path?) { - - init { - if (dir != null) { - try { - // Create logging directory if missing - dir.createDirectories() - } catch (e: Exception) { - throw Exception("Failed to init EBICS debug logging directory", e) - } - logger.info("Logging to '$dir'") - } - } - - /** Create a new [name] EBICS transaction logger */ - fun tx(name: String): TxLogger { - if (dir == null) return TxLogger(null) - val utcDateTime = Instant.now().atOffset(ZoneOffset.UTC) - val txDir = dir - // yyyy-MM-dd per day directory - .resolve(utcDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE)) - // HH:mm:ss.SSS-name per transaction directory - .resolve("${utcDateTime.format(TIME_WITH_MS)}-$name") - txDir.createDirectories() - return TxLogger(txDir) - } - - /** Create a new [order] EBICS transaction logger */ - fun tx(order: EbicsOrder): TxLogger { - if (dir == null) return TxLogger(null) - return tx(order.description()) - } - - companion object { - private val TIME_WITH_MS = DateTimeFormatter.ofPattern("HH:mm:ss.SSS") - } -} - -/** Log EBICS transaction steps and payload */ -class TxLogger internal constructor( - private val dir: Path? -) { - /** Create a new [name] EBICS transaction step logger*/ - fun step(name: String? = null) = StepLogger(dir, name) - - /** Log a [stream] EBICS transaction payload of [type] */ - fun payload(stream: InputStream, type: String = "xml"): InputStream { - if (dir == null) return stream - return payload(stream.readBytes(), type).inputStream() - } - - /** Log a [content] EBICS transaction payload of [type] */ - fun payload(content: ByteArray, type: String = "xml"): ByteArray { - if (dir == null) return content - val type = type.lowercase() - if (type == "zip") { - val payloadDir = dir.resolve("payload") - payloadDir.createDirectory() - content.inputStream().unzipEach { fileName, xmlContent -> - xmlContent.use { - Files.copy(it, payloadDir.resolve(fileName)) - } - } - } else { - dir.resolve("payload.$type").writeBytes(content, StandardOpenOption.CREATE_NEW) - } - return content - } -} - -/** Log EBICS transaction protocol step */ -class StepLogger internal constructor( - private val dir: Path?, - private val name: String? -) { - private val prefix = if (name != null) "$name-" else "" - - /** Log a protocol step [request] */ - fun logRequest(request: ByteArray) { - if (dir != null) { - dir.resolve("${prefix}request.xml") - .writeBytes(request, StandardOpenOption.CREATE_NEW) - } - } - - /** Log a protocol step failure */ - suspend fun logFailure(res: HttpResponse) { - if (dir != null) { - // TODO reduce allocation - // TODO silent io error - val bytes = buildString { - append("${res.version} ${res.status}\n") - for ((k, vs) in res.headers.entries()) { - for (v in vs) { - append("${k}: ${v}\n") - } - } - append('\n') - }.toByteArray() + res.readRawBytes() - dir.resolve("${prefix}failure") - .writeBytes(bytes, StandardOpenOption.CREATE_NEW) - } - } - - /** Log a protocol step [response] */ - fun logResponse(response: InputStream): InputStream { - if (dir == null) return response - val bytes = response.readBytes() - dir.resolve("${prefix}response.xml") - .writeBytes(bytes, StandardOpenOption.CREATE_NEW) - return bytes.inputStream() - } -} -\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt @@ -1,225 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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/> - */ -package tech.libeufin.nexus.ebics - -sealed class EbicsOrder(val schema: String) { - data class V2_5( - val type: String, - val attribute: String - ): EbicsOrder("H004") - data class V3( - val type: String, - val service: String? = null, - val scope: String? = null, - val message: String? = null, - val version: String? = null, - val container: String? = null, - val option: String? = null - ): EbicsOrder("H005") { - companion object { - val WSS_PARAMS = V3( - type = "BTD", - service = "OTH", - scope = "DE", - message = "wssparam" - ) - val HAC = V3(type = "HAC") - val HKD = V3(type = "HKD") - val HAA = V3(type = "HAA") - } - } - - fun description(): String = buildString { - when (this@EbicsOrder) { - is V2_5 -> { - append(type) - append('-') - append(attribute) - } - is V3 -> { - append(type) - for (part in sequenceOf(service, scope, option, container)) { - if (part != null) { - append('-') - append(part) - } - } - if (message != null) { - append('-') - append(message) - if (version != null) { - append('.') - append(version) - } - } - } - } - } - - fun doc(): OrderDoc? { - return when (this) { - is V2_5 -> { - when (this.type) { - "HAC" -> OrderDoc.acknowledgement - "Z01" -> OrderDoc.status - "Z52" -> OrderDoc.report - "Z53" -> OrderDoc.statement - "Z54" -> OrderDoc.notification - else -> null - } - } - is V3 -> { - when (this.type) { - "HAC" -> OrderDoc.acknowledgement - "BTD" -> when (this.message) { - "pain.002" -> OrderDoc.status - "camt.052" -> OrderDoc.report - "camt.053" -> OrderDoc.statement - "camt.054" -> OrderDoc.notification - else -> null - } - else -> null - } - } - } - } - - /** Check if two EBICS order match ignoring the message version */ - fun match(other: EbicsOrder): Boolean = when (this) { - is V2_5 -> other is V2_5 - && type == other.type - && attribute == other.attribute - is V3 -> other is V3 - && type == other.type - && service == other.service - && scope == other.scope - && message == other.message - && container == other.container - && option == other.option - } -} - -infix fun Collection<EbicsOrder>.select(other: Collection<EbicsOrder>): List<EbicsOrder> - = this.flatMap { filter -> other.filter { order -> filter.match(order) } } - -enum class OrderDoc { - /// EBICS acknowledgement - CustomerAcknowledgement HAC pain.002 - acknowledgement, - /// Payment status - CustomerPaymentStatusReport pain.002 - status, - /// Account intraday reports - BankToCustomerAccountReport camt.052 - report, - /// Account statements - BankToCustomerStatement camt.053 - statement, - /// Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054 - notification; - - fun shortDescription(): String = when (this) { - acknowledgement -> "EBICS acknowledgement" - status -> "Payment status" - report -> "Account intraday reports" - statement -> "Account statements" - notification -> "Debit & credit notifications" - } - - fun fullDescription(): String = when (this) { - acknowledgement -> "EBICS acknowledgement - CustomerAcknowledgement HAC pain.002" - status -> "Payment status - CustomerPaymentStatusReport pain.002" - report -> "Account intraday reports - BankToCustomerAccountReport camt.052" - statement -> "Account statements - BankToCustomerStatement camt.053" - notification -> "Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054" - } -} - -/** Supported EBICS standard */ -enum class Standard { - /// Swiss Payment Standards - SIX, - /// German Banking Industry Committee - GBIC; - - fun downloadDoc(doc: OrderDoc): List<EbicsOrder> = when (this) { - SIX -> when (doc) { - OrderDoc.acknowledgement -> listOf(EbicsOrder.V3.HAC) - OrderDoc.status -> listOf(EbicsOrder.V3("BTD", "PSR", "CH", "pain.002", "10", "ZIP")) - OrderDoc.report -> listOf(EbicsOrder.V3("BTD", "STM", "CH", "camt.052", "08", "ZIP")) - OrderDoc.statement -> listOf(EbicsOrder.V3("BTD", "EOP", "CH", "camt.053", "08", "ZIP")) - OrderDoc.notification -> listOf(EbicsOrder.V3("BTD", "REP", "CH", "camt.054", "08", "ZIP")) - } - GBIC -> when (doc) { - OrderDoc.acknowledgement -> listOf(EbicsOrder.V3.HAC) - OrderDoc.status -> listOf( - EbicsOrder.V3("BTD", "REP", "DE", "pain.002", null, "ZIP", "SCI"), - EbicsOrder.V3("BTD", "REP", "DE", "pain.002", null, "ZIP", "SCT") - ) - OrderDoc.report -> listOf(EbicsOrder.V3("BTD", "STM", "DE", "camt.052", null, "ZIP")) - OrderDoc.statement -> listOf(EbicsOrder.V3("BTD", "EOP", "DE", "camt.053", null, "ZIP")) - OrderDoc.notification -> listOf( - EbicsOrder.V3("BTD", "STM", "DE", "camt.054", null, "ZIP"), - EbicsOrder.V3("BTD", "STM", "DE", "camt.054", null, "ZIP", "SCI") - ) - } - } - - fun directDebit(): EbicsOrder = when (this) { - SIX -> EbicsOrder.V3("BTU", "MCT", "CH", "pain.001", "09") - GBIC -> EbicsOrder.V3("BTU", "SCT", null, "pain.001") - } - - fun instantDirectDebit(): EbicsOrder? = when (this) { - SIX -> null - GBIC -> EbicsOrder.V3("BTU", "SCI", "DE", "pain.001") - } -} - -/** Supported bank dialects */ -enum class Dialect { - valiant, - postfinance, - gls, - maerki_baumann; - - fun standard(): Standard = when (this) { - valiant, postfinance, maerki_baumann -> Standard.SIX - gls -> Standard.GBIC - } - - fun downloadDoc(doc: OrderDoc): List<EbicsOrder> { - if (this == maerki_baumann) throw IllegalArgumentException("Maerki Baumann does not have EBICS access") - return this.standard().downloadDoc(doc) - } - - fun directDebit(): EbicsOrder { - if (this == maerki_baumann) throw IllegalArgumentException("Maerki Baumann does not have EBICS access") - return this.standard().directDebit() - } - - fun instantDirectDebit(): EbicsOrder? { - if (this == maerki_baumann) throw IllegalArgumentException("Maerki Baumann does not have EBICS access") - return this.standard().instantDirectDebit() - } - - /** All orders required for a dialect implementation to work */ - fun downloadOrders(): Set<EbicsOrder> = ( - // Administrative orders - sequenceOf(EbicsOrder.V3.HAA, EbicsOrder.V3.HKD) - // and documents orders - + OrderDoc.entries.flatMap { downloadDoc(it) } - ).toSet() -} -\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsWS.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsWS.kt @@ -1,206 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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/> - */ - -package tech.libeufin.nexus.ebics - -import io.ktor.client.* -import io.ktor.client.plugins.websocket.* -import io.ktor.client.request.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.* -import io.ktor.websocket.* -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.* -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.* -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import tech.libeufin.common.* - -private val wsLog: Logger = LoggerFactory.getLogger("libeufin-ebics-ws") - -@Serializable -data class WssParams( - val URL: String, - val TOKEN: String, - val OTT: String, - val VALIDITY: String, - val PARTNERID: String, - val USERID: String? = null, -) - -@Serializable -data class WssNotificationClass( - val NAME: String, - val VERS: String, - val TIMESTAMP: String, -) - -@Serializable -data class WssNotificationBTF( - val SERVICE: String, - val SCOPE: String? = null, - val OPTION: String? = null, - val CONTTYPE: String? = null, - val MSGNAME: String, - val VARIANT: String? = null, - val VERSION: String? = null, - val FORMAT: String? = null, -) - -@Serializable -data class WssNewData( - val MCLASS: List<WssNotificationClass>, - val PARTNERID: String, - val USERID: String? = null, - val BTF: List<WssNotificationBTF>, - val ORDERTYPE: List<String>? = null -): WssNotification - -@Serializable -data class WssInfo( - val LANG: String, - val FREE: String -) - -@Serializable -data class WssGeneralInfo( - val MCLASS: List<WssNotificationClass>, - val INFO: List<WssInfo> -): WssNotification - -@Serializable(with = WssNotification.Serializer::class) -sealed interface WssNotification { - companion object Serializer : JsonContentPolymorphicSerializer<WssNotification>(WssNotification::class) { - override fun selectDeserializer(element: JsonElement) = when { - "INFO" in element.jsonObject -> WssGeneralInfo.serializer() - else -> WssNewData.serializer() - } - } -} - -/** Download EBICS real-time notifications websocket params */ -suspend fun EbicsClient.wssParams(): WssParams = - download(EbicsOrder.V3.WSS_PARAMS) { stream -> - Json.decodeFromStream(stream) - } - -/** Receive a JSON message from a websocket session */ -private suspend inline fun <reified T> DefaultClientWebSocketSession.receiveJson(): T { - val frame = incoming.receive() - val content = frame.readBytes() - val msg = Json.decodeFromStream(kotlinx.serialization.serializer<T>(), content.inputStream()) - return msg -} - -/** Connect to the EBICS real-time notifications websocket */ -suspend fun WssParams.connect(client: HttpClient, lambda: suspend (WssNotification) -> Unit) { - val client = client.config { - install(WebSockets) { - contentConverter = KotlinxWebsocketSerializationConverter(Json) - } - } - // TODO check PARTNERID and USERID match conf ? - val credentials = buildString { - // Username - append(PARTNERID) - if (USERID != null) { - append('_') - append(USERID) - } - // Password - append(':') - append(TOKEN) - }.encodeBase64() - - client.wss(URL.replace("https://", "wss://"), request = { - headers { - append(HttpHeaders.Authorization, "Basic $credentials") - } - }) { - while (true) { - wsLog.trace("wait for ws msg") - // TODO use receiveDeserialized from ktor when it works - val msg = receiveJson<WssNotification>() - wsLog.trace("received: {}", msg) - lambda(msg) - } - } -} - -suspend fun listenForNotification(client: EbicsClient): ReceiveChannel<List<EbicsOrder>>? { - val channel = Channel<List<EbicsOrder>>() - val backoff = ExpoBackoffDecorr( - 30 * 1000, // 30 seconds - 30 * 60 * 1000 // 30 min - ) - kotlin.concurrent.thread(isDaemon = true) { - runBlocking { - while (true) { - try { - // Try to get params - val params = try { - client.wssParams() - } catch (e: EbicsError) { - if ( - // Expected EBICS error - (e is EbicsError.Code && e.technicalCode == EbicsReturnCode.EBICS_INVALID_ORDER_IDENTIFIER) || - // Netzbon HTTP error - (e is EbicsError.HTTP && e.status == HttpStatusCode.BadRequest) - ) { - // Failure is expected if this wss is not supported - wsLog.info("Real-time EBICS notifications is not supported") - return@runBlocking - } else throw e - } - wsLog.info("Listening to real-time EBICS notifications") - wsLog.trace("{}", params) - params.connect(client.client) { msg -> - backoff.reset() - when (msg) { - is WssGeneralInfo -> { - for (info in msg.INFO) { - wsLog.info("info: {}", info.FREE) - } - } - is WssNewData -> { - val orders = msg.BTF.map { - EbicsOrder.V3( - type = "BTD", - service = it.SERVICE, - scope = it.SCOPE, - message = it.MSGNAME, - version = it.VERSION, - container = it.CONTTYPE, - option = it.OPTION - ) - } - channel.send(orders) - } - } - } - } catch (e: Exception) { - e.fmtLog(wsLog) - delay(backoff.next()) - } - } - } - } - return channel -} -\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/helpers.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/helpers.kt @@ -19,26 +19,10 @@ package tech.libeufin.nexus -import io.ktor.client.* -import io.ktor.client.plugins.* -import io.ktor.client.engine.mock.MockEngine import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter -/** Use for unit testing */ -var MOCK_ENGINE: MockEngine? = null - -/** Create an HTTP client for EBICS requests */ -fun httpClient(): HttpClient = MOCK_ENGINE?.let { - HttpClient(it) -} ?: HttpClient { - install(HttpTimeout) { - // It can take a lot of time for the bank to generate documents - socketTimeoutMillis = 5 * 60 * 1000 - } -} - fun Instant.fmtDate(): String = DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneId.of("UTC")).format(this) diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt @@ -20,6 +20,7 @@ package tech.libeufin.nexus.iso20022 import tech.libeufin.common.* import tech.libeufin.nexus.* +import tech.libeufin.ebics.XmlDestructor import java.io.InputStream import java.time.Instant import java.time.ZoneOffset diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/hac.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/hac.kt @@ -19,6 +19,7 @@ package tech.libeufin.nexus.iso20022 import tech.libeufin.nexus.* +import tech.libeufin.ebics.XmlDestructor import java.io.InputStream import java.time.Instant import java.time.ZoneOffset diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/pain001.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/pain001.kt @@ -20,7 +20,7 @@ package tech.libeufin.nexus.iso20022 import tech.libeufin.common.* import tech.libeufin.nexus.* -import tech.libeufin.nexus.ebics.* +import tech.libeufin.ebics.XmlBuilder import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/pain002.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/pain002.kt @@ -19,6 +19,7 @@ package tech.libeufin.nexus.iso20022 import tech.libeufin.nexus.* +import tech.libeufin.ebics.XmlDestructor import java.io.InputStream private fun fmtMsg(code: String?, description: String?, reasons: List<Reason>) = buildString { diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/test/TxCheck.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/test/TxCheck.kt @@ -1,94 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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/> - */ - -package tech.libeufin.nexus.test - -import io.ktor.client.* -import tech.libeufin.common.* -import tech.libeufin.nexus.ebics.* -import tech.libeufin.nexus.* -import org.slf4j.Logger -import org.slf4j.LoggerFactory - -private val logger: Logger = LoggerFactory.getLogger("libeufin-nexus") - -data class TxCheckResult( - var concurrentFetchAndFetch: Boolean = false, - var concurrentFetchAndSubmit: Boolean = false, - var concurrentSubmitAndSubmit: Boolean = false, - var idempotentClose: Boolean = false -) - -/** - * Test EBICS implementation's transactions semantic: - * - Can two fetch transactions run concurrently ? - * - Can a fetch & submit transactions run concurrently ? - * - Can two submit transactions run concurrently ? - * - Is closing a submit transaction idempotent - */ -suspend fun txCheck( - client: HttpClient, - cfg: NexusEbicsConfig, - clientKeys: ClientPrivateKeysFile, - bankKeys: BankPublicKeysFile, - fetchOrder: EbicsOrder, - submitOrder: EbicsOrder -): TxCheckResult { - val result = TxCheckResult() - val fetch = EbicsBTS(cfg, bankKeys, clientKeys, fetchOrder) - val submit = EbicsBTS(cfg, bankKeys, clientKeys, submitOrder) - val ebicsLogger = EbicsLogger(null).tx("test").step("step") - - suspend fun EbicsBTS.close(id: String, phase: String, ebicsLogger: StepLogger) { - val xml = downloadReceipt(id, false) - postBTS(client, xml, phase, ebicsLogger) - } - - val firstTxId = fetch.postBTS(client, fetch.downloadInitialization(null, null), "Init first fetch", ebicsLogger) - .transactionID!! - try { - try { - val id = fetch.postBTS(client, fetch.downloadInitialization(null, null), "Init second fetch", ebicsLogger).transactionID!! - result.concurrentFetchAndFetch = true - fetch.close(id, "Init second fetch", ebicsLogger) - } catch (e: EbicsError.Code) {} - - var paylod = prepareUploadPayload(cfg, clientKeys, bankKeys, ByteArray(2000000).rand()) - try { - val submitId = submit.postBTS(client, submit.uploadInitialization(paylod), "Init first submit", ebicsLogger). transactionID!! - result.concurrentFetchAndSubmit = true - submit.postBTS(client, submit.uploadTransfer(submitId, paylod, 1), "Submit first upload", ebicsLogger) - try { - submit.postBTS(client, submit.uploadInitialization(paylod), "Init second submit", ebicsLogger) - result.concurrentSubmitAndSubmit = true - } catch (e: EbicsError.Code) {} - } catch (e: EbicsError.Code) {} - } finally { - fetch.close(firstTxId, "Close first fetch", ebicsLogger) - } - - try { - fetch.close(firstTxId, "Close first fetch a second time", ebicsLogger) - result.idempotentClose = true - } catch (e: Exception) { - logger.debug { e.fmt() } - } - - return result -} -\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/xml.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/xml.kt @@ -1,368 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020-2025 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/> - */ - -package tech.libeufin.nexus - -import tech.libeufin.common.decodeBase64 -import org.w3c.dom.Document -import org.w3c.dom.Node -import org.w3c.dom.NodeList -import org.w3c.dom.Element -import org.xml.sax.InputSource -import java.io.InputStream -import java.io.StringWriter -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.util.UUID -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.security.PrivateKey -import java.security.PublicKey -import javax.xml.XMLConstants -import javax.xml.crypto.* -import javax.xml.crypto.dom.DOMURIReference -import javax.xml.crypto.dsig.* -import javax.xml.crypto.dsig.dom.DOMSignContext -import javax.xml.crypto.dsig.dom.DOMValidateContext -import javax.xml.crypto.dsig.spec.C14NMethodParameterSpec -import javax.xml.crypto.dsig.spec.TransformParameterSpec -import javax.xml.parsers.DocumentBuilderFactory -import javax.xml.transform.OutputKeys -import javax.xml.transform.TransformerFactory -import javax.xml.transform.dom.DOMSource -import javax.xml.transform.stream.StreamResult -import javax.xml.stream.XMLOutputFactory -import javax.xml.stream.XMLStreamWriter -import javax.xml.xpath.XPath -import javax.xml.xpath.XPathConstants -import javax.xml.xpath.XPathFactory - -interface XmlBuilder { - fun el(path: String, lambda: XmlBuilder.() -> Unit = {}) - fun el(path: String, content: String) { - el(path) { - text(content) - } - } - fun attr(namespace: String, name: String, value: String) - fun attr(name: String, value: String) - fun text(content: String) - - companion object { - fun toBytes(root: String, f: XmlBuilder.() -> Unit): ByteArray { - val factory = XMLOutputFactory.newFactory() - val stream = StringWriter() - val writer = factory.createXMLStreamWriter(stream) - /** - * NOTE: commenting out because it wasn't obvious how to output the - * "standalone = 'yes' directive". Manual forge was therefore preferred. - */ - stream.write("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>") - XmlStreamBuilder(writer).el(root) { - this.f() - } - writer.writeEndDocument() - return stream.buffer.toString().toByteArray() - } - - fun toDom(root: String, schema: String?, f: XmlBuilder.() -> Unit): Document { - val factory = DocumentBuilderFactory.newInstance() - factory.isNamespaceAware = true - val builder = factory.newDocumentBuilder() - val doc = builder.newDocument() - doc.xmlVersion = "1.0" - doc.xmlStandalone = true - val root = doc.createElementNS(schema, root) - doc.appendChild(root) - XmlDOMBuilder(doc, schema, root).f() - doc.normalize() - return doc - } - } -} - -private class XmlStreamBuilder(private val w: XMLStreamWriter): XmlBuilder { - override fun el(path: String, lambda: XmlBuilder.() -> Unit) { - path.splitToSequence('/').forEach { - w.writeStartElement(it) - } - lambda() - path.splitToSequence('/').forEach { - w.writeEndElement() - } - } - - override fun attr(namespace: String, name: String, value: String) { - w.writeAttribute(namespace, name, value) - } - - override fun attr(name: String, value: String) { - w.writeAttribute(name, value) - } - - override fun text(content: String) { - w.writeCharacters(content) - } -} - -private class XmlDOMBuilder(private val doc: Document, private val schema: String?, private var node: Element): XmlBuilder { - override fun el(path: String, lambda: XmlBuilder.() -> Unit) { - val current = node - path.splitToSequence('/').forEach { - val new = doc.createElementNS(schema, it) - node.appendChild(new) - node = new - } - lambda() - node = current - } - - override fun attr(namespace: String, name: String, value: String) { - node.setAttributeNS(namespace, name, value) - } - - override fun attr(name: String, value: String) { - node.setAttribute(name, value) - } - - override fun text(content: String) { - node.appendChild(doc.createTextNode(content)) - } -} - -private fun Element.childrenByTag(tag: String, signed: Boolean): Sequence<Element> = sequence { - for (i in 0..childNodes.length) { - val el = childNodes.item(i) - if (el is Element - && el.localName == tag - && (!signed || el.getAttribute("authenticate") == "true")) { - yield(el) - } - } -} - -class XmlDestructor internal constructor(private val el: Element) { - fun each(path: String, signed: Boolean = false, f: XmlDestructor.() -> Unit) { - el.childrenByTag(path, signed).forEach { - f(XmlDestructor(it)) - } - } - - fun <T> map(path: String, signed: Boolean = false, f: XmlDestructor.() -> T): List<T> { - return el.childrenByTag(path, signed).map { - f(XmlDestructor(it)) - }.toList() - } - - fun one(tag: String, signed: Boolean = false): XmlDestructor { - val children = el.childrenByTag(tag, signed).iterator() - if (!children.hasNext()) { - throw Exception("expected unique '${el.tagName}.$tag', got none") - } - val child = children.next() - if (children.hasNext()) { - throw Exception("expected unique '${el.tagName}.$tag', got ${children.asSequence().count() + 1}") - } - return XmlDestructor(child) - } - fun opt(tag: String, signed: Boolean = false): XmlDestructor? { - val children = el.childrenByTag(tag, signed).iterator() - if (!children.hasNext()) { - return null - } - val child = children.next() - if (children.hasNext()) { - throw Exception("expected optional '${el.tagName}.$tag', got ${children.asSequence().count() + 1}") - } - return XmlDestructor(child) - } - - fun <T> one(path: String, signed: Boolean = false, f: XmlDestructor.() -> T): T = f(one(path, signed)) - fun <T> opt(path: String, signed: Boolean = false, f: XmlDestructor.() -> T): T? = opt(path, signed)?.run(f) - - fun uuid(): UUID = UUID.fromString(text()) - fun text(): String = el.textContent - fun base64(): ByteArray = el.textContent.decodeBase64() - fun bool(): Boolean = el.textContent.toBoolean() - fun float(): Float = el.textContent.toFloat() - fun date(): LocalDate = LocalDate.parse(text(), DateTimeFormatter.ISO_DATE) - fun dateTime(): LocalDateTime = LocalDateTime.parse(text(), DateTimeFormatter.ISO_DATE_TIME) - inline fun <reified T : Enum<T>> enum(): T = java.lang.Enum.valueOf(T::class.java, text()) - - fun optAttr(index: String): String? { - val attr = el.getAttribute(index) - if (attr == "") { - return null - } else { - return attr - } - } - fun attr(index: String): String { - val attr = optAttr(index) - if (attr == null) { - throw Exception("missing attribute '$index' at '${el.tagName}'") - } - return attr - } - - - companion object { - fun <T> parse(xml: String, root: String, f: XmlDestructor.() -> T): T { - val inputStream = ByteArrayInputStream(xml.toByteArray()) - return parse(inputStream, root, f) - } - - fun <T> parse(xml: InputStream, root: String, f: XmlDestructor.() -> T): T { - val doc = XMLUtil.parseIntoDom(xml) - return parse(doc, root, f) - } - - fun <T> parse(doc: Document, root: String, f: XmlDestructor.() -> T): T { - if (doc.documentElement.localName != root) { - throw Exception("expected root '$root' got '${doc.documentElement.localName}'") - } - val destr = XmlDestructor(doc.documentElement) - return f(destr) - } - } -} - - -/** - * This URI dereferencer allows handling the resource reference used for - * XML signatures in EBICS. - */ -private class EbicsSigUriDereferencer : URIDereferencer { - override fun dereference(myRef: URIReference?, myCtx: XMLCryptoContext?): Data { - if (myRef !is DOMURIReference) - throw Exception("invalid type") - if (myRef.uri != "#xpointer(//*[@authenticate='true'])") - throw Exception("invalid EBICS XML signature URI: '${myRef.uri}'") - val xp: XPath = XPathFactory.newInstance().newXPath() - val nodeSet = xp.compile("//*[@authenticate='true']/descendant-or-self::node()").evaluate( - myRef.here.ownerDocument, XPathConstants.NODESET - ) - if (nodeSet !is NodeList) - throw Exception("invalid type") - if (nodeSet.length <= 0) { - throw Exception("no nodes to sign") - } - val nodeList = ArrayList<Node>() - for (i in 0 until nodeSet.length) { - val node = nodeSet.item(i) - nodeList.add(node) - } - return NodeSetData { nodeList.iterator() } - } -} - -/** - * Helpers for dealing with XML in EBICS. - */ -object XMLUtil { - fun convertDomToBytes(document: Document): ByteArray { - val w = ByteArrayOutputStream() - val transformer = TransformerFactory.newInstance().newTransformer() - transformer.setOutputProperty(OutputKeys.STANDALONE, "yes") - transformer.transform(DOMSource(document), StreamResult(w)) - return w.toByteArray() - } - - /** Parse [xml] into a XML DOM */ - fun parseIntoDom(xml: InputStream): Document { - val factory = DocumentBuilderFactory.newInstance().apply { - // Enable secure processing - setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) - // Disable all external access - setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "") - setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "") - isNamespaceAware = true - } - val builder = factory.newDocumentBuilder() - return xml.use { - builder.parse(InputSource(it)) - } - } - - /** Sign an EBICS document with the authentication and identity signature */ - fun signEbicsDocument( - doc: Document, - signingPriv: PrivateKey - ) { - val authSigNode = XPathFactory.newInstance().newXPath() - .evaluate("/*[1]/*[local-name()='AuthSignature']", doc, XPathConstants.NODE) - if (authSigNode !is Node) - throw java.lang.Exception("sign: no AuthSignature") - val fac = XMLSignatureFactory.getInstance("DOM") - val c14n = fac.newTransform(CanonicalizationMethod.INCLUSIVE, null as TransformParameterSpec?) - val ref: Reference = - fac.newReference( - "#xpointer(//*[@authenticate='true'])", - fac.newDigestMethod(DigestMethod.SHA256, null), - listOf(c14n), - null, - null - ) - val canon: CanonicalizationMethod = - fac.newCanonicalizationMethod(CanonicalizationMethod.INCLUSIVE, null as C14NMethodParameterSpec?) - val signatureMethod = fac.newSignatureMethod("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", null) - val si: SignedInfo = fac.newSignedInfo(canon, signatureMethod, listOf(ref)) - val sig: XMLSignature = fac.newXMLSignature(si, null) - val dsc = DOMSignContext(signingPriv, authSigNode) - dsc.defaultNamespacePrefix = "ds" - dsc.uriDereferencer = EbicsSigUriDereferencer() - dsc.setProperty("javax.xml.crypto.dsig.cacheReference", true) - sig.sign(dsc) - val innerSig = authSigNode.firstChild - while (innerSig.hasChildNodes()) { - authSigNode.appendChild(innerSig.firstChild) - } - authSigNode.removeChild(innerSig) - } - - /** Check an EBICS document signature */ - fun verifyEbicsDocument( - doc: Document, - signingPub: PublicKey - ) { - // Find SignedInfo - val sigInfos = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "SignedInfo"); - if (sigInfos.length == 0) { - throw Exception("missing SignedInfo") - } else if (sigInfos.length != 1) { - throw Exception("many SignedInfo") - } - val sigInfo = sigInfos.item(0) - - // Rename AuthSignature - val authSig = sigInfo.parentNode - doc.renameNode(authSig, XMLSignature.XMLNS, "${sigInfo.prefix}:Signature") - - // Check signature - val fac = XMLSignatureFactory.getInstance("DOM") - val dvc = DOMValidateContext(signingPub, authSig) - dvc.setProperty("javax.xml.crypto.dsig.cacheReference", true) - dvc.uriDereferencer = EbicsSigUriDereferencer() - val sig = fac.unmarshalXMLSignature(dvc) - if (!sig.validate(dvc)) { - throw Exception("bank signature did not verify") - } - } -} -\ No newline at end of file diff --git a/libeufin-nexus/src/test/kotlin/CliTest.kt b/libeufin-nexus/src/test/kotlin/CliTest.kt @@ -22,6 +22,7 @@ import com.github.ajalt.clikt.testing.test import tech.libeufin.common.crypto.CryptoUtil import tech.libeufin.common.asUtf8 import tech.libeufin.nexus.* +import tech.libeufin.ebics.* import tech.libeufin.nexus.cli.LibeufinNexus import java.io.ByteArrayOutputStream import java.io.PrintStream @@ -46,91 +47,6 @@ fun CliktCommand.testErr(cmd: String, msg: String) { } class CliTest { - /** Test error format related to the keying process */ - @Test - fun keys() { - val cmds = listOf("ebics-submit", "ebics-fetch") - val allCmds = listOf("ebics-submit", "ebics-fetch", "ebics-setup") - val conf = "conf/test.conf" - val nexusCfg = nexusConfig(Path(conf)) - val cfg = nexusCfg.ebics - val clientKeysPath = cfg.clientPrivateKeysPath - val bankKeysPath = cfg.bankPublicKeysPath - clientKeysPath.parent!!.createParentDirectories() - clientKeysPath.parent!!.toFile().setWritable(true) - bankKeysPath.parent!!.createDirectories() - - // Missing client keys - clientKeysPath.deleteIfExists() - for (cmd in cmds) { - nexusCmd.testErr("$cmd -c $conf", "Missing client private keys file at '$clientKeysPath', run 'libeufin-nexus ebics-setup' first") - } - // Empty client file - clientKeysPath.createFile() - for (cmd in allCmds) { - nexusCmd.testErr("$cmd -c $conf", "Could not decode client private keys at '$clientKeysPath': Expected start of the object '{', but had 'EOF' instead at path: $\nJSON input: ") - } - // Bad client json - clientKeysPath.writeText("CORRUPTION", Charsets.UTF_8) - for (cmd in allCmds) { - nexusCmd.testErr("$cmd -c $conf", "Could not decode client private keys at '$clientKeysPath': Unexpected JSON token at offset 0: Expected start of the object '{', but had 'C' instead at path: $\nJSON input: CORRUPTION") - } - // Missing permission - clientKeysPath.toFile().setReadable(false) - if (!clientKeysPath.isReadable()) { // Skip if root - for (cmd in allCmds) { - nexusCmd.testErr("$cmd -c $conf", "Could not read client private keys at '$clientKeysPath': permission denied") - } - } - // Unfinished client - persistClientKeys(generateNewKeys(), clientKeysPath) - for (cmd in cmds) { - nexusCmd.testErr("$cmd -c $conf", "Unsubmitted client private keys, run 'libeufin-nexus ebics-setup' first") - } - - // Missing bank keys - persistClientKeys(generateNewKeys().apply { - submitted_hia = true - submitted_ini = true - }, clientKeysPath) - bankKeysPath.deleteIfExists() - for (cmd in cmds) { - nexusCmd.testErr("$cmd -c $conf", "Missing bank public keys at '$bankKeysPath', run 'libeufin-nexus ebics-setup' first") - } - // Empty bank file - bankKeysPath.createFile() - for (cmd in allCmds) { - nexusCmd.testErr("$cmd -c $conf", "Could not decode bank public keys at '$bankKeysPath': Expected start of the object '{', but had 'EOF' instead at path: $\nJSON input: ") - } - // Bad bank json - bankKeysPath.writeText("CORRUPTION", Charsets.UTF_8) - for (cmd in allCmds) { - nexusCmd.testErr("$cmd -c $conf", "Could not decode bank public keys at '$bankKeysPath': Unexpected JSON token at offset 0: Expected start of the object '{', but had 'C' instead at path: $\nJSON input: CORRUPTION") - } - // Missing permission - bankKeysPath.toFile().setReadable(false) - if (!bankKeysPath.isReadable()) { // Skip if root - for (cmd in allCmds) { - nexusCmd.testErr("$cmd -c $conf", "Could not read bank public keys at '$bankKeysPath': permission denied") - } - } - // Unfinished bank - persistBankKeys(BankPublicKeysFile( - bank_authentication_public_key = CryptoUtil.genRSAPublic(2048), - bank_encryption_public_key = CryptoUtil.genRSAPublic(2048), - accepted = false - ), bankKeysPath) - for (cmd in cmds) { - nexusCmd.testErr("$cmd -c $conf", "Unaccepted bank public keys, run 'libeufin-nexus ebics-setup' until accepting the bank keys") - } - - // Missing permission - clientKeysPath.deleteIfExists() - clientKeysPath.parent!!.toFile().setWritable(false) - if (!clientKeysPath.parent!!.isWritable()) { // Skip if root - nexusCmd.testErr("ebics-setup -c $conf", "Could not write client private keys at '$clientKeysPath': permission denied on '${clientKeysPath.parent}'") - } - } /** Test server check */ @Test diff --git a/libeufin-nexus/src/test/kotlin/DatabaseTest.kt b/libeufin-nexus/src/test/kotlin/DatabaseTest.kt @@ -23,11 +23,11 @@ import tech.libeufin.common.db.* import tech.libeufin.nexus.AccountType import tech.libeufin.nexus.NexusIngestConfig import tech.libeufin.nexus.iso20022.* -import tech.libeufin.nexus.ebics.* import tech.libeufin.nexus.cli.* import tech.libeufin.nexus.db.* import tech.libeufin.nexus.db.PaymentDAO.* import tech.libeufin.nexus.db.InitiatedDAO.* +import tech.libeufin.ebics.* import java.time.Instant import java.util.UUID; import kotlin.test.* diff --git a/libeufin-nexus/src/test/kotlin/EbicsTest.kt b/libeufin-nexus/src/test/kotlin/EbicsTest.kt @@ -25,10 +25,10 @@ import io.ktor.http.* import io.ktor.http.content.* import org.junit.Test import tech.libeufin.nexus.cli.LibeufinNexus -import tech.libeufin.nexus.ebics.* import tech.libeufin.nexus.* import tech.libeufin.common.* import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.ebics.* import kotlin.io.path.* import kotlin.test.* import java.security.interfaces.RSAPrivateCrtKey @@ -479,7 +479,7 @@ class EbicsTest { // Mainly tests that the function does not throw any error. @Test fun keysPdf() = conf { config -> - val pdf = generateKeysPdf(clientKeys, config.ebics) + val pdf = generateKeysPdf(clientKeys, config.ebics.host) Path("/tmp/libeufin-nexus-test-keys.pdf").writeBytes(pdf) } diff --git a/libeufin-nexus/src/test/kotlin/Iso20022Test.kt b/libeufin-nexus/src/test/kotlin/Iso20022Test.kt @@ -20,8 +20,8 @@ import org.junit.Test import tech.libeufin.common.* import tech.libeufin.nexus.* -import tech.libeufin.nexus.ebics.* import tech.libeufin.nexus.iso20022.* +import tech.libeufin.ebics.* import kotlin.io.path.* import kotlin.test.* import java.time.Instant diff --git a/libeufin-nexus/src/test/kotlin/Keys.kt b/libeufin-nexus/src/test/kotlin/Keys.kt @@ -1,97 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024 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/> - */ - -import org.junit.Test -import tech.libeufin.common.crypto.CryptoUtil -import tech.libeufin.common.fmtChunkByTwo -import tech.libeufin.nexus.* -import kotlin.io.path.Path -import kotlin.io.path.deleteIfExists -import kotlin.io.path.notExists -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class PublicKeys { - - // Tests intermittent spaces in public keys fingerprint. - @Test - fun splitTest() { - assertEquals("0099887766".fmtChunkByTwo(), "00 99 88 77 66") // even - assertEquals("ZZYYXXWWVVU".fmtChunkByTwo(), "ZZ YY XX WW VV U") // odd - } - - // Tests loading the bank public keys from disk. - @Test - fun loadBankKeys() { - // artificially creating the keys. - val fileContent = BankPublicKeysFile( - accepted = true, - bank_authentication_public_key = CryptoUtil.genRSAPublic(2028), - bank_encryption_public_key = CryptoUtil.genRSAPublic(2028) - ) - // storing them on disk. - persistBankKeys(fileContent, Path("/tmp/nexus-tests-bank-keys.json")) - // loading them and check that values are the same. - val fromDisk = loadBankKeys(Path("/tmp/nexus-tests-bank-keys.json")) - assertNotNull(fromDisk) - assertTrue { - fromDisk.accepted && - fromDisk.bank_encryption_public_key == fileContent.bank_encryption_public_key && - fromDisk.bank_authentication_public_key == fileContent.bank_authentication_public_key - } - } - @Test - fun loadNotFound() { - assertNull(loadBankKeys(Path("/tmp/highly-unlikely-to-be-found.json"))) - } -} -class PrivateKeys { - val f = Path("/tmp/nexus-privs-test.json") - init { - f.deleteIfExists() - } - - /** - * Tests whether loading keys from disk yields the same - * values that were stored to the file. - */ - @Test - fun load() { - assert(f.notExists()) - persistClientKeys(clientKeys, f) // Artificially storing this to the file. - val fromDisk = loadClientKeys(f) // loading it via the tested routine. - assertNotNull(fromDisk) - // Checking the values from disk match the initial object. - assertTrue { - clientKeys.authentication_private_key == fromDisk.authentication_private_key && - clientKeys.encryption_private_key == fromDisk.encryption_private_key && - clientKeys.signature_private_key == fromDisk.signature_private_key && - clientKeys.submitted_ini == fromDisk.submitted_ini && - clientKeys.submitted_hia == fromDisk.submitted_hia - } - } - - // Testing failure on file not found. - @Test - fun loadNotFound() { - assertNull(loadClientKeys(Path("/tmp/highly-unlikely-to-be-found.json"))) - } -} -\ No newline at end of file diff --git a/libeufin-nexus/src/test/kotlin/MySerializers.kt b/libeufin-nexus/src/test/kotlin/MySerializers.kt @@ -1,47 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024 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/> - */ - -import org.junit.Test -import tech.libeufin.common.Base32Crockford -import tech.libeufin.common.crypto.CryptoUtil -import tech.libeufin.nexus.ClientPrivateKeysFile -import tech.libeufin.nexus.JSON -import kotlin.test.assertEquals - -class MySerializers { - // Testing deserialization of RSA private keys. - @Test - fun rsaPrivDeserialization() { - val s = Base32Crockford.encode(CryptoUtil.genRSAPrivate(2048).encoded) - val a = Base32Crockford.encode(CryptoUtil.genRSAPrivate(2048).encoded) - val e = Base32Crockford.encode(CryptoUtil.genRSAPrivate(2048).encoded) - val obj = JSON.decodeFromString<ClientPrivateKeysFile>(""" - { - "signature_private_key": "$s", - "authentication_private_key": "$a", - "encryption_private_key": "$e", - "submitted_ini": true, - "submitted_hia": true - } - """.trimIndent()) - assertEquals(obj.signature_private_key, CryptoUtil.loadRSAPrivate(Base32Crockford.decode(s))) - assertEquals(obj.authentication_private_key, CryptoUtil.loadRSAPrivate(Base32Crockford.decode(a))) - assertEquals(obj.encryption_private_key, CryptoUtil.loadRSAPrivate(Base32Crockford.decode(e))) - } -} -\ No newline at end of file diff --git a/libeufin-nexus/src/test/kotlin/RegistrationTest.kt b/libeufin-nexus/src/test/kotlin/RegistrationTest.kt @@ -23,8 +23,8 @@ import tech.libeufin.common.db.* import tech.libeufin.nexus.* import tech.libeufin.nexus.cli.* import tech.libeufin.nexus.db.* -import tech.libeufin.nexus.ebics.* import tech.libeufin.nexus.iso20022.* +import tech.libeufin.ebics.* import java.nio.file.Files import java.time.Instant import java.util.UUID diff --git a/libeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt b/libeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -23,7 +23,7 @@ import io.ktor.server.testing.* import org.junit.Test import tech.libeufin.common.* import tech.libeufin.nexus.cli.registerOutgoingPayment -import tech.libeufin.nexus.ebics.randEbicsId +import tech.libeufin.ebics.randEbicsId import java.time.Instant import kotlin.test.* diff --git a/libeufin-nexus/src/test/kotlin/WsTest.kt b/libeufin-nexus/src/test/kotlin/WsTest.kt @@ -1,186 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2024 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/> -*/ - -import io.ktor.http.HttpHeaders -import io.ktor.serialization.kotlinx.* -import io.ktor.server.application.* -import io.ktor.server.routing.* -import io.ktor.server.testing.* -import io.ktor.server.websocket.* -import io.ktor.websocket.* -import kotlinx.coroutines.channels.ClosedReceiveChannelException -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.decodeFromJsonElement -import kotlinx.serialization.json.encodeToJsonElement -import tech.libeufin.nexus.ebics.* -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs - -class WsTest { - // WSS params example from the spec - val PARAMS_EXAMPLE = """ - { - "URL": "https://bankmitwebsocket.de", - "TOKEN": "550e8400-e29b-11d4-a716-446655440000", - "OTT": "N", - "VALIDITY": "2019-03-21T10:35:22Z", - "PARTNERID": "K1234567", - "USERID": "USER4711" - } - """ - // Authorization header example from the spec - val AUTH_EXAMPLE = "Basic SzEyMzQ1NjdfVVNFUjQ3MTE6NTUwZTg0MDAtZTI5Yi0xMWQ0LWE3MTYtNDQ2NjU1NDQwMDAw" - // Notifications examples from the spec - val NOTIFICATION_EXAMPLES = sequenceOf( - """ - { - "MCLASS": [ - { - "NAME": "EBICS-HAA", - "VERS": "1.0", - "TIMESTAMP": "2019-05-13T12:21:50Z" - } - ], - "PARTNERID": "K1234567", - "USERID": "USER471", - "BTF": [ - { - "SERVICE": "REP", - "SCOPE": "DE", - "CONTTYPE": "ZIP", - "MSGNAME": "camt.054" - } - ], - "ORDERTYPE": [ - "C5N" - ] - } - """, """ - { - "MCLASS": [ - { - "NAME": "EBICS-HAA", - "VERS": "1.0", - "TIMESTAMP": "2019-05-13T12:21:53Z" - } - ], - "PARTNERID": "K1234567", - "USERID": "USER471", - "BTF": [ - { - "SERVICE": "REP", - "SCOPE": "DE", - "CONTTYPE": "ZIP", - "MSGNAME": "camt.052" - }, - { - "SERVICE": "REP", - "SCOPE": "DE", - "OPTION": "SCI", - "CONTTYPE": "ZIP", - "MSGNAME": "pain.002" - } - ], - "ORDERTYPE": [ - "C52", - "CIZ" - ] - } - """, """ - { - "MCLASS": [ - { - "NAME": "INFO", - "VERS": "1.0", - "TIMESTAMP": "2019-03-25T12:25:34Z" - } - ], - "INFO": [ - { - "LANG": "EN", - "FREE": " The EBICS-Service is limited on 30.03.2019 from 10:00 a.m. - 11:00a.m. due to maintenance work " - } - ] - } - """ - ) - - /** Test JSON serialization roudtrip */ - inline fun <reified B> roundtrip(raw: String): B { - val json: JsonObject = Json.decodeFromString(raw) - val decoded: B = Json.decodeFromJsonElement(json) - val encoded = Json.encodeToJsonElement(decoded) - assertEquals(json, encoded) - return decoded - } - - /** Test our serialization implementation works with spec examples */ - @Test - fun serialization() { - roundtrip<WssParams>(PARAMS_EXAMPLE) - for (raw in NOTIFICATION_EXAMPLES) { - roundtrip<WssNotification>(raw) - } - } - - /** Test our implementation works with spec examples */ - @Test - fun wss() { - val params: WssParams = Json.decodeFromString(PARAMS_EXAMPLE) - - testApplication { - externalServices { - hosts(params.URL.replace("https://", "wss://")) { - install(WebSockets) { - contentConverter = KotlinxWebsocketSerializationConverter(Json) - } - routing { - webSocket("/") { - assertEquals(AUTH_EXAMPLE, call.request.headers[HttpHeaders.Authorization]) - // Send all examples - for (example in NOTIFICATION_EXAMPLES) { - send(example) - } - close(CloseReason(CloseReason.Codes.NORMAL, "Test done")) - } - } - } - } - var count = 0 - try { - params.connect(client) { msg -> - count++ - // Check message number and type - assert(count <= 3) - if (count == 3) { - assertIs<WssGeneralInfo>(msg) - } else { - assertIs<WssNewData>(msg) - } - } - } catch (e: ClosedReceiveChannelException) { - // Expected - } - // Check receive all messages - assertEquals(3, count) - } - } -} -\ No newline at end of file diff --git a/libeufin-nexus/src/test/kotlin/XmlCombinatorsTest.kt b/libeufin-nexus/src/test/kotlin/XmlCombinatorsTest.kt @@ -1,101 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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/> - */ - -import org.w3c.dom.Document -import org.junit.Test -import tech.libeufin.nexus.* -import tech.libeufin.common.asUtf8 -import kotlin.test.assertEquals - -class XmlCombinatorsTest { - fun testBuilder(expected: String, root: String, builder: XmlBuilder.() -> Unit): Document { - val toBytes = XmlBuilder.toBytes(root, builder) - val toDom = XmlBuilder.toDom(root, null, builder) - //assertEquals(expected, toString) TODO fix empty tag being closed only with toString - assertEquals(expected, XMLUtil.convertDomToBytes(toDom).asUtf8()) - return toDom - } - - @Test - fun testWithModularity() { - fun module(base: XmlBuilder) { - base.el("module") - } - testBuilder( - "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><root><module/></root>", - "root" - ) { - module(this) - } - } - - @Test - fun testWithIterable() { - testBuilder( - "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><iterable><endOfDocument><e1><e11>111</e11></e1><e2><e22>222</e22></e2><e3><e33>333</e33></e3><e4><e44>444</e44></e4><e5><e55>555</e55></e5><e6><e66>666</e66></e6><e7><e77>777</e77></e7><e8><e88>888</e88></e8><e9><e99>999</e99></e9><e10><e1010>101010</e1010></e10></endOfDocument></iterable>", - "iterable" - ) { - el("endOfDocument") { - for (i in 1..10) - el("e$i/e$i$i", "$i$i$i") - } - } - } - - @Test - fun testBasicXmlBuilding() { - testBuilder( - "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><ebicsRequest version=\"H004\"><a><b><c attribute-of=\"c\"><d><e><f nested=\"true\"><g><h/></g></f></e></d></c></b></a><one_more/></ebicsRequest>", - "ebicsRequest" - ) { - attr("version", "H004") - el("a/b/c") { - attr("attribute-of", "c") - el("d/e/f") { - attr("nested", "true") - el("g/h") - } - } - el("one_more") - } - } - - @Test - fun signed() { - val trapped = XmlBuilder.toDom("document", "urn:org:ebics:test") { - el("order") { - text("not signed") - } - el("order") { - attr("authenticate", "true") - text("signed") - } - el("order") { - attr("authenticate", "false") - text("not signed 2") - } - } - XmlDestructor.parse(trapped, "document") { - assertEquals(3, map("order") { text() }.size) - one("order", signed = true) { - assertEquals("signed", text()) - } - } - } -} diff --git a/libeufin-nexus/src/test/kotlin/XmlUtilTest.kt b/libeufin-nexus/src/test/kotlin/XmlUtilTest.kt @@ -1,72 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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/> - */ - -import kotlin.test.* -import org.junit.Test -import tech.libeufin.common.crypto.CryptoUtil -import tech.libeufin.common.decodeBase64 -import tech.libeufin.nexus.XMLUtil -import java.security.KeyPairGenerator - -class XmlUtilTest { - - @Test - fun basicSigningTest() { - val doc = XMLUtil.parseIntoDom(""" - <myMessage xmlns:ebics="urn:org:ebics:H004"> - <ebics:AuthSignature /> - <foo authenticate="true">Hello World</foo> - </myMessage> - """.trimIndent().toByteArray().inputStream()) - val kpg = KeyPairGenerator.getInstance("RSA") - kpg.initialize(2048) - val pair = kpg.genKeyPair() - val otherPair = kpg.genKeyPair() - XMLUtil.signEbicsDocument(doc, pair.private) - XMLUtil.verifyEbicsDocument(doc, pair.public) - assertFails { XMLUtil.verifyEbicsDocument(doc, otherPair.public) } - } - - @Test - fun multiAuthSigningTest() { - val doc = XMLUtil.parseIntoDom(""" - <myMessage xmlns:ebics="urn:org:ebics:H004"> - <ebics:AuthSignature /> - <foo authenticate="true">Hello World</foo> - <bar authenticate="true">Another one!</bar> - </myMessage> - """.trimIndent().toByteArray().inputStream()) - val kpg = KeyPairGenerator.getInstance("RSA") - kpg.initialize(2048) - val pair = kpg.genKeyPair() - XMLUtil.signEbicsDocument(doc, pair.private) - XMLUtil.verifyEbicsDocument(doc, pair.public) - } - - @Test - fun testRefSignature() { - val classLoader = ClassLoader.getSystemClassLoader() - val docText = classLoader.getResourceAsStream("signature1/doc.xml") - val doc = XMLUtil.parseIntoDom(docText) - val keyStream = classLoader.getResourceAsStream("signature1/public_key.txt") - val keyBytes = keyStream.decodeBase64().readAllBytes() - val key = CryptoUtil.loadRSAPublic(keyBytes) - XMLUtil.verifyEbicsDocument(doc, key) - } -} -\ No newline at end of file diff --git a/libeufin-nexus/src/test/kotlin/helpers.kt b/libeufin-nexus/src/test/kotlin/helpers.kt @@ -27,12 +27,13 @@ import kotlinx.coroutines.runBlocking import tech.libeufin.common.* import tech.libeufin.common.db.dbInit import tech.libeufin.common.db.pgDataSource +import tech.libeufin.ebics.generateNewKeys +import tech.libeufin.ebics.randEbicsId import tech.libeufin.nexus.* import tech.libeufin.nexus.cli.registerIncomingPayment import tech.libeufin.nexus.cli.registerOutgoingPayment import tech.libeufin.nexus.db.Database import tech.libeufin.nexus.db.InitiatedPayment -import tech.libeufin.nexus.ebics.randEbicsId import tech.libeufin.nexus.iso20022.* import java.time.Instant import kotlin.io.path.Path diff --git a/settings.gradle b/settings.gradle @@ -2,5 +2,6 @@ rootProject.name = 'libeufin' include("libeufin-bank") include("libeufin-nexus") include("libeufin-common") +include("libeufin-ebics") include("libeufin-ebisync") include("testbench") \ No newline at end of file diff --git a/testbench/build.gradle b/testbench/build.gradle @@ -20,6 +20,8 @@ dependencies { implementation(project(":libeufin-common")) implementation(project(":libeufin-bank")) implementation(project(":libeufin-nexus")) + implementation(project(":libeufin-ebics")) + implementation(project(":libeufin-ebisync")) implementation("com.github.ajalt.clikt:clikt:$clikt_version") diff --git a/testbench/conf/cli.conf b/testbench/conf/cli.conf @@ -0,0 +1,26 @@ +[nexus-ebics] +CURRENCY = CHF +BANK_DIALECT = postfinance +HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb +BANK_PUBLIC_KEYS_FILE = /tmp/libeufin-test/bank-keys.json +CLIENT_PRIVATE_KEYS_FILE = /tmp/libeufin-test/client-keys.json +IBAN = CH7789144474425692816 +HOST_ID = PFEBICS +USER_ID = PFC00563 +PARTNER_ID = PFC00563 +BIC = BIC +NAME = myname + +[ebisync] +BANK_PUBLIC_KEYS_FILE = /tmp/libeufin-test/bank-keys.json +CLIENT_PRIVATE_KEYS_FILE = /tmp/libeufin-test/client-keys.json +HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb +HOST_ID = PFEBICS +USER_ID = PFC00563 +PARTNER_ID = PFC00563 + +[libeufin-nexusdb-postgres] +CONFIG = postgres:///libeufincheck + +[ebisyncdb-postgres] +CONFIG = postgres:///libeufincheck +\ No newline at end of file diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt @@ -24,6 +24,7 @@ import com.github.ajalt.clikt.core.Context import com.github.ajalt.clikt.core.ProgramResult import com.github.ajalt.clikt.core.main import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.types.enum import com.github.ajalt.clikt.testing.* import io.ktor.client.* import io.ktor.client.engine.cio.* @@ -32,14 +33,20 @@ import kotlinx.coroutines.* import kotlinx.serialization.Serializable import tech.libeufin.common.* import tech.libeufin.nexus.* -import tech.libeufin.nexus.cli.* +import tech.libeufin.nexus.cli.LibeufinNexus +import tech.libeufin.ebisync.cli.LibeufinEbiSync +import tech.libeufin.ebics.* import java.time.Instant import kotlin.io.path.* import org.jline.terminal.* import org.jline.reader.* import org.jline.reader.impl.history.* +enum class Component { Nexus, Ebisync } + val nexusCmd = LibeufinNexus() +val ebisyncCmd = LibeufinEbiSync() + val client = HttpClient(CIO) var thread: Thread? = null var deferred: CompletableDeferred<CliktCommandTestResult> = CompletableDeferred() @@ -91,6 +98,7 @@ private val WORDS_REGEX = Regex("\\s+") class Cli : CliktCommand() { override fun help(context: Context) = "Run integration tests on banks provider" + val component by argument().enum<Component>() val platform by argument() override fun run() { @@ -113,19 +121,47 @@ class Cli : CliktCommand() { val conf = Path("test/$platform/ebics.conf") conf.writeText( """$simpleCfg + ${simpleCfg.replace("[nexus-ebics]", "[ebisync]").replace("[nexus-setup]", "[ebisync-setup]")} [paths] LIBEUFIN_NEXUS_HOME = test/$platform + EBISYNC_HOME = test/$platform [nexus-fetch] FREQUENCY = 1h CHECKPOINT_TIME_OF_DAY = 16:52 - [nexus-submit] - FREQUENCY = 1m + [ebisync-fetch] + FREQUENCY = 1h + CHECKPOINT_TIME_OF_DAY = 16:52 [libeufin-nexusdb-postgres] CONFIG = postgres:///libeufintestbench + + [ebisyncdb-postgres] + CONFIG = postgres:///libeufintestbench + + [ebisync-fetch] + DESTINATION = azure-blob-storage + AZURE_API_URL = http://localhost:10000/devstoreaccount1/ + AZURE_ACCOUNT_NAME = devstoreaccount1 + AZURE_ACCOUNT_KEY = Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== + AZURE_CONTAINER = test """) + + // Prepare shell + val terminal = TerminalBuilder.builder().system(true).build()//.signalHandler(Terminal.SignalHandler.SIG_IGN).build() + val history = DefaultHistory() + val reader = LineReaderBuilder.builder().terminal(terminal).history(history).build(); + + terminal.handle(Terminal.Signal.INT) { + thread?.let { + thread = null + it.interrupt() + } ?: run { + kotlin.system.exitProcess(0) + } + } + val cfg = nexusConfig(conf) // Check if platform is known @@ -163,120 +199,109 @@ class Cli : CliktCommand() { val payto = benchCfg.payto[currency] ?: dummyPayto val recoverDoc = "report statement notification" - // Prepare shell - val terminal = TerminalBuilder.builder().system(true).build()//.signalHandler(Terminal.SignalHandler.SIG_IGN).build() - val history = DefaultHistory() - val reader = LineReaderBuilder.builder().terminal(terminal).history(history).build(); - - terminal.handle(Terminal.Signal.INT) { - thread?.let { - thread = null - it.interrupt() - } ?: run { - kotlin.system.exitProcess(0) - } - } - runBlocking { step("Init ${kind.name}") - - assert(nexusCmd.run("dbinit $flags")) - val cmds = buildMap { - fun putCmd(name: String, step: String, lambda: suspend (List<String>) -> Unit) { - put(name, Pair(step, lambda)) - } - fun put(name: String, step: String, lambda: suspend () -> Unit) { - putCmd(name, step, { - lambda() - Unit - }) - } - fun put(name: String, step: String, args: String) { - put(name, step, { - nexusCmd.run(args) - Unit - }) - } - fun putArgs(name: String, step: String, parser: (List<String>) -> String) { - putCmd(name, step, { args: List<String> -> - nexusCmd.run(parser(args)) - Unit - }) - } - put("reset-db", "Reset DB", "dbinit -r $flags") - put("recover", "Recover old transactions", "ebics-fetch $ebicsFlags --pinned-start 2024-01-01 $recoverDoc") - put("fetch", "Fetch all documents", "ebics-fetch $ebicsFlags") - put("fetch-wait", "Fetch all documents", "ebics-fetch $debugFlags") - put("checkpoint", "Run a transient checkpoint", "ebics-fetch $ebicsFlags --checkpoint") - put("peek", "Run a transient peek", "ebics-fetch $ebicsFlags --peek") - put("ack", "Fetch CustomerAcknowledgement", "ebics-fetch $ebicsFlags acknowledgement") - put("status", "Fetch CustomerPaymentStatusReport", "ebics-fetch $ebicsFlags status") - put("report", "Fetch BankToCustomerAccountReport", "ebics-fetch $ebicsFlags report") - put("notification", "Fetch BankToCustomerDebitCreditNotification", "ebics-fetch $ebicsFlags notification") - put("statement", "Fetch BankToCustomerStatement", "ebics-fetch $ebicsFlags statement") - put("list-incoming", "List incoming transaction", "list incoming $flags") - put("list-outgoing", "List outgoing transaction", "list outgoing $flags") - put("list-initiated", "List initiated payments", "list initiated $flags") - put("list-ack", "List initiated payments pending manual submission acknowledgement", "list initiated $flags --awaiting-ack") - put("wss", "Listen to notification over websocket", "testing wss $debugFlags") - put("submit", "Submit pending transactions", "ebics-submit $ebicsFlags") - put("submit-wait", "Submit pending transaction", "ebics-submit $debugFlags") - put("export", "Export pending batches as pain001 messages", "manual export $flags payments.zip") - putArgs("import", "Import xml files in root directory") { - buildString { - append("manual import $flags ") - for (file in Path("..").listDirectoryEntries()) { - if (file.extension == "xml") { - append(file) - append(" ") + val (setup, cmds) = when (component) { + Component.Ebisync -> { + assert(ebisyncCmd.run("dbinit $flags")) + val cmds = buildCmds(ebisyncCmd) { + put("reset-db", "Reset DB", "dbinit -r $flags") + put("recover", "Recover old transactions", "fetch $ebicsFlags --pinned-start 2024-01-01") + put("fetch", "Fetch all documents", "fetch $ebicsFlags") + put("fetch-wait", "Fetch all documents", "fetch $debugFlags") + put("checkpoint", "Run a transient checkpoint", "fetch $ebicsFlags --checkpoint") + put("peek", "Run a transient peek", "fetch $ebicsFlags --peek") + put("reset-keys", "Reset EBICS keys") { + if (kind.test) { + clientKeysPath.deleteIfExists() } + bankKeysPath.deleteIfExists() + Unit } } + Pair(suspend { ebisyncCmd.run("setup $debugFlags") }, cmds) } - put("setup", "Setup", "ebics-setup $debugFlags") - putArgs("status", "Set batch or transaction status") { - "manual status $flags " + it.joinToString(" ") - } - put("reset-keys", "Reset EBICS keys") { - if (kind.test) { - clientKeysPath.deleteIfExists() - } - bankKeysPath.deleteIfExists() - Unit - } - put("tx", "Initiate a new transaction") { - val now = Instant.now() - nexusCmd.run("initiate-payment $flags --amount=$currency:0.1 --subject \"single $now\" \"$payto\"") - Unit - } - put("txs", "Initiate four new transactions") { - val now = Instant.now() - repeat(4) { - nexusCmd.run("initiate-payment $flags --amount=$currency:${(10.0+it)/100} --subject \"multi $it $now\" \"$payto\"") + Component.Nexus -> { + assert(nexusCmd.run("dbinit $flags")) + val cmds = buildCmds(nexusCmd) { + put("reset-db", "Reset DB", "dbinit -r $flags") + put("recover", "Recover old transactions", "ebics-fetch $ebicsFlags --pinned-start 2024-01-01 $recoverDoc") + put("fetch", "Fetch all documents", "ebics-fetch $ebicsFlags") + put("fetch-wait", "Fetch all documents", "ebics-fetch $debugFlags") + put("checkpoint", "Run a transient checkpoint", "ebics-fetch $ebicsFlags --checkpoint") + put("peek", "Run a transient peek", "ebics-fetch $ebicsFlags --peek") + put("ack", "Fetch CustomerAcknowledgement", "ebics-fetch $ebicsFlags acknowledgement") + put("status", "Fetch CustomerPaymentStatusReport", "ebics-fetch $ebicsFlags status") + put("report", "Fetch BankToCustomerAccountReport", "ebics-fetch $ebicsFlags report") + put("notification", "Fetch BankToCustomerDebitCreditNotification", "ebics-fetch $ebicsFlags notification") + put("statement", "Fetch BankToCustomerStatement", "ebics-fetch $ebicsFlags statement") + put("list-incoming", "List incoming transaction", "list incoming $flags") + put("list-outgoing", "List outgoing transaction", "list outgoing $flags") + put("list-initiated", "List initiated payments", "list initiated $flags") + put("list-ack", "List initiated payments pending manual submission acknowledgement", "list initiated $flags --awaiting-ack") + put("wss", "Listen to notification over websocket", "testing wss $debugFlags") + put("submit", "Submit pending transactions", "ebics-submit $ebicsFlags") + put("submit-wait", "Submit pending transaction", "ebics-submit $debugFlags") + put("export", "Export pending batches as pain001 messages", "manual export $flags payments.zip") + putArgs("import", "Import xml files in root directory") { + buildString { + append("manual import $flags ") + for (file in Path("..").listDirectoryEntries()) { + if (file.extension == "xml") { + append(file) + append(" ") + } + } + } + } + putArgs("status", "Set batch or transaction status") { + "manual status $flags " + it.joinToString(" ") + } + put("reset-keys", "Reset EBICS keys") { + if (kind.test) { + clientKeysPath.deleteIfExists() + } + bankKeysPath.deleteIfExists() + Unit + } + put("tx", "Initiate a new transaction") { + val now = Instant.now() + nexusCmd.run("initiate-payment $flags --amount=$currency:0.1 --subject \"single $now\" \"$payto\"") + Unit + } + put("txs", "Initiate four new transactions") { + val now = Instant.now() + repeat(4) { + nexusCmd.run("initiate-payment $flags --amount=$currency:${(10.0+it)/100} --subject \"multi $it $now\" \"$payto\"") + } + } + put("tx-bad-name", "Initiate a new transaction with a bad name") { + val badPayto = URLBuilder().takeFrom(payto) + badPayto.parameters["receiver-name"] = "John Smith" + val now = Instant.now() + nexusCmd.run("initiate-payment $flags --amount=$currency:0.21 --subject \"bad name $now\" \"$badPayto\"") + Unit + } + put("tx-bad-iban", "Initiate a new transaction to a bad IBAN") { + val badPayto = URLBuilder().takeFrom("payto://iban/XX18500105173385245165") + badPayto.parameters["receiver-name"] = "John Smith" + val now = Instant.now() + nexusCmd.run("initiate-payment $flags --amount=$currency:0.22 --subject \"bad iban $now\" \"$badPayto\"") + Unit + } + put("tx-dummy-iban", "Initiate a new transaction to a dummy IBAN") { + val now = Instant.now() + nexusCmd.run("initiate-payment $flags --amount=$currency:0.23 --subject \"dummy iban $now\" \"$dummyPayto\"") + Unit + } + put("tx-check", "Check transaction semantic", "testing tx-check $flags") } + Pair(suspend { nexusCmd.run("ebics-setup $debugFlags") }, cmds) } - put("tx-bad-name", "Initiate a new transaction with a bad name") { - val badPayto = URLBuilder().takeFrom(payto) - badPayto.parameters["receiver-name"] = "John Smith" - val now = Instant.now() - nexusCmd.run("initiate-payment $flags --amount=$currency:0.21 --subject \"bad name $now\" \"$badPayto\"") - Unit - } - put("tx-bad-iban", "Initiate a new transaction to a bad IBAN") { - val badPayto = URLBuilder().takeFrom("payto://iban/XX18500105173385245165") - badPayto.parameters["receiver-name"] = "John Smith" - val now = Instant.now() - nexusCmd.run("initiate-payment $flags --amount=$currency:0.22 --subject \"bad iban $now\" \"$badPayto\"") - Unit - } - put("tx-dummy-iban", "Initiate a new transaction to a dummy IBAN") { - val now = Instant.now() - nexusCmd.run("initiate-payment $flags --amount=$currency:0.23 --subject \"dummy iban $now\" \"$dummyPayto\"") - Unit - } - put("tx-check", "Check transaction semantic", "testing tx-check $flags") } + + while (true) { // Automatic setup if (host != null) { @@ -286,7 +311,7 @@ class Cli : CliktCommand() { msg("Manual setup is required for non test environment") } else if (clientKeys == null || !clientKeys.submitted_ini || !clientKeys.submitted_hia || bankKeys == null || !bankKeys.accepted) { step("Run EBICS setup") - if (!nexusCmd.run("ebics-setup $debugFlags")) { + if (!setup()) { clientKeys = loadClientKeys(clientKeysPath) if (kind.test) { if (clientKeys == null || !clientKeys.submitted_ini || !clientKeys.submitted_hia) { @@ -320,10 +345,15 @@ class Cli : CliktCommand() { "exit" -> break "?", "help" -> { println("Commands:") + println(" setup - Setup") for ((name, cmd) in cmds) { println(" $name - ${cmd.first}") } } + "setup" -> { + step("Setup") + setup() + } else -> err("Unknown command '$cmdArg'") } } @@ -336,3 +366,38 @@ fun main(args: Array<String>) { setupSecurityProperties() Cli().main(args) } + +typealias Cmds = Map<String, Pair<String, suspend (List<String>) -> Unit>> + +data class CmdsBuilder( + private val cmd: CliktCommand, + val map: MutableMap<String, Pair<String, suspend (List<String> +) -> Unit>>) { + fun putCmd(name: String, step: String, lambda: suspend (List<String>) -> Unit) { + map.put(name, Pair(step, lambda)) + } + fun put(name: String, step: String, lambda: suspend () -> Unit) { + putCmd(name, step, { + lambda() + Unit + }) + } + fun put(name: String, step: String, args: String) { + put(name, step, { + cmd.run(args) + Unit + }) + } + fun putArgs(name: String, step: String, parser: (List<String>) -> String) { + putCmd(name, step, { args: List<String> -> + cmd.run(parser(args)) + Unit + }) + } +} + +fun buildCmds(cmd: CliktCommand, actions: CmdsBuilder.() -> Unit): Cmds { + val builder = CmdsBuilder(cmd, mutableMapOf()) + builder.actions() + return builder.map +} +\ No newline at end of file diff --git a/testbench/src/test/kotlin/CliTest.kt b/testbench/src/test/kotlin/CliTest.kt @@ -0,0 +1,137 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023-2025 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/> + */ + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.testing.test +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.common.asUtf8 +import tech.libeufin.nexus.* +import tech.libeufin.ebics.* +import tech.libeufin.nexus.cli.LibeufinNexus +import tech.libeufin.ebisync.cli.LibeufinEbiSync +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import kotlin.io.path.* +import kotlin.test.Test +import kotlin.test.assertEquals + +val nexusCmd = LibeufinNexus() +val ebisyncCmd = LibeufinEbiSync() + +fun CliktCommand.testErr(cmd: String, msg: String) { + val prevOut = System.err + val tmpOut = ByteArrayOutputStream() + System.setErr(PrintStream(tmpOut)) + val result = test(cmd) + System.setErr(prevOut) + val tmpStr = tmpOut.asUtf8() + println(tmpStr) + assertEquals(1, result.statusCode, "'$cmd' should have failed") + val line = tmpStr.substringAfterLast(" - ").trimEnd('\n') + println(line) + assertEquals(msg, line) +} + +class CliTest { + /** Test error format related to the keying process */ + @Test + fun keys() { + val nexusCmds = listOf("ebics-submit", "ebics-fetch") + val nexusAllCmds = listOf("ebics-submit", "ebics-fetch", "ebics-setup") + val ebiSyncCmds = listOf("fetch") + val ebiSyncAllCmds = listOf("fetch", "setup") + val conf = "conf/cli.conf" + val nexusCfg = nexusConfig(Path(conf)) + val cfg = nexusCfg.ebics + val clientKeysPath = cfg.clientPrivateKeysPath + val bankKeysPath = cfg.bankPublicKeysPath + clientKeysPath.parent!!.createParentDirectories() + clientKeysPath.parent!!.toFile().setWritable(true) + bankKeysPath.parent!!.createDirectories() + + fun checkCmds(msg: String) { + for (cmd in listOf("ebics-submit", "ebics-fetch")) { + nexusCmd.testErr("$cmd -c $conf", msg.replace("SETUPCMD", "libeufin-nexus ebics-setup")) + } + for (cmd in listOf("fetch")) { + ebisyncCmd.testErr("$cmd -c $conf", msg.replace("SETUPCMD", "libeufin-ebisync setup")) + } + } + fun checkAllCmds(msg: String) { + for (cmd in listOf("ebics-submit", "ebics-fetch", "ebics-setup")) { + nexusCmd.testErr("$cmd -c $conf", msg) + } + for (cmd in listOf("fetch", "setup")) { + ebisyncCmd.testErr("$cmd -c $conf", msg) + } + } + + // Missing client keys + clientKeysPath.deleteIfExists() + checkCmds("Missing client private keys file at '$clientKeysPath', run 'SETUPCMD' first") + // Empty client file + clientKeysPath.createFile() + checkAllCmds("Could not decode client private keys at '$clientKeysPath': Expected start of the object '{', but had 'EOF' instead at path: $\nJSON input: ") + // Bad client json + clientKeysPath.writeText("CORRUPTION", Charsets.UTF_8) + checkAllCmds("Could not decode client private keys at '$clientKeysPath': Unexpected JSON token at offset 0: Expected start of the object '{', but had 'C' instead at path: $\nJSON input: CORRUPTION") + // Missing permission + clientKeysPath.toFile().setReadable(false) + if (!clientKeysPath.isReadable()) { // Skip if root + checkAllCmds("Could not read client private keys at '$clientKeysPath': permission denied") + } + // Unfinished client + persistClientKeys(generateNewKeys(), clientKeysPath) + checkCmds("Unsubmitted client private keys, run 'SETUPCMD' first") + + // Missing bank keys + persistClientKeys(generateNewKeys().apply { + submitted_hia = true + submitted_ini = true + }, clientKeysPath) + bankKeysPath.deleteIfExists() + checkCmds("Missing bank public keys at '$bankKeysPath', run 'SETUPCMD' first") + // Empty bank file + bankKeysPath.createFile() + checkAllCmds("Could not decode bank public keys at '$bankKeysPath': Expected start of the object '{', but had 'EOF' instead at path: $\nJSON input: ") + // Bad bank json + bankKeysPath.writeText("CORRUPTION", Charsets.UTF_8) + checkAllCmds("Could not decode bank public keys at '$bankKeysPath': Unexpected JSON token at offset 0: Expected start of the object '{', but had 'C' instead at path: $\nJSON input: CORRUPTION") + // Missing permission + bankKeysPath.toFile().setReadable(false) + if (!bankKeysPath.isReadable()) { // Skip if root + checkAllCmds("Could not read bank public keys at '$bankKeysPath': permission denied") + } + // Unfinished bank + persistBankKeys(BankPublicKeysFile( + bank_authentication_public_key = CryptoUtil.genRSAPublic(2048), + bank_encryption_public_key = CryptoUtil.genRSAPublic(2048), + accepted = false + ), bankKeysPath) + checkCmds("Unaccepted bank public keys, run 'SETUPCMD' until accepting the bank keys") + + // Missing permission + clientKeysPath.deleteIfExists() + clientKeysPath.parent!!.toFile().setWritable(false) + if (!clientKeysPath.parent!!.isWritable()) { // Skip if root + nexusCmd.testErr("ebics-setup -c $conf", "Could not write client private keys at '$clientKeysPath': permission denied on '${clientKeysPath.parent}'") + ebisyncCmd.testErr("setup -c $conf", "Could not write client private keys at '$clientKeysPath': permission denied on '${clientKeysPath.parent}'") + } + } +} +\ No newline at end of file diff --git a/testbench/src/test/kotlin/Iso20022Test.kt b/testbench/src/test/kotlin/Iso20022Test.kt @@ -19,8 +19,8 @@ import org.junit.Test import tech.libeufin.nexus.* -import tech.libeufin.nexus.ebics.* import tech.libeufin.nexus.iso20022.* +import tech.libeufin.ebics.* import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.Path