summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMS <ms@taler.net>2023-10-18 15:40:21 +0200
committerMS <ms@taler.net>2023-10-18 15:40:40 +0200
commite670ab6a3ab5bf2851eed8f4fb58405967b812f0 (patch)
tree40600b668c290b7aa7f4cc956df9b53c1c4c178e
parent4502e55c148c4803bb8b2204052f51f03d3ab8ab (diff)
downloadlibeufin-e670ab6a3ab5bf2851eed8f4fb58405967b812f0.tar.gz
libeufin-e670ab6a3ab5bf2851eed8f4fb58405967b812f0.tar.bz2
libeufin-e670ab6a3ab5bf2851eed8f4fb58405967b812f0.zip
Importing Nexus from refactoring branch.
-rw-r--r--nexus/build.gradle118
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt762
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics.kt384
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt261
-rw-r--r--nexus/src/main/resources/logback.xml23
-rw-r--r--nexus/src/test/kotlin/Common.kt50
-rw-r--r--nexus/src/test/kotlin/ConfigLoading.kt35
-rw-r--r--nexus/src/test/kotlin/Ebics.kt122
-rw-r--r--nexus/src/test/kotlin/Keys.kt88
-rw-r--r--nexus/src/test/kotlin/MySerializers.kt32
-rw-r--r--settings.gradle2
-rw-r--r--util/src/main/kotlin/Ebics.kt8
12 files changed, 1880 insertions, 5 deletions
diff --git a/nexus/build.gradle b/nexus/build.gradle
new file mode 100644
index 00000000..b6717712
--- /dev/null
+++ b/nexus/build.gradle
@@ -0,0 +1,118 @@
+plugins {
+ id 'kotlin'
+ id 'java'
+ id 'application'
+ id 'org.jetbrains.kotlin.jvm'
+ id "com.github.johnrengelman.shadow" version "5.2.0"
+ id 'org.jetbrains.kotlin.plugin.serialization' version '1.7.22'
+}
+
+sourceSets {
+ main.java.srcDirs = ['src/main/kotlin']
+}
+
+task installToPrefix(type: Copy) {
+ dependsOn(installShadowDist)
+ from("build/install/nexus-shadow") {
+ include("**/libeufin-nexus")
+ include("**/*.jar")
+ }
+ into "${project.findProperty('prefix') ?: '/tmp'}" // reads from -Pprefix=foo, defaults to /tmp
+}
+
+apply plugin: 'kotlin-kapt'
+
+sourceCompatibility = '11'
+targetCompatibility = '11'
+version = rootProject.version
+
+compileKotlin {
+ kotlinOptions {
+ jvmTarget = '11'
+ }
+}
+
+compileTestKotlin {
+ kotlinOptions {
+ jvmTarget = '11'
+ }
+}
+
+dependencies {
+ // Core language libraries
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1-native-mt'
+
+ // LibEuFin util library
+ implementation project(":util")
+
+ // Logging
+ implementation 'ch.qos.logback:logback-classic:1.4.5'
+
+ // XML parsing/binding and encryption
+ implementation "javax.xml.bind:jaxb-api:2.3.0"
+ implementation "org.glassfish.jaxb:jaxb-runtime:2.3.1"
+ implementation 'org.apache.santuario:xmlsec:2.2.2'
+
+ // Compression
+ implementation group: 'org.apache.commons', name: 'commons-compress', version: '1.21'
+
+ // Command line parsing
+ implementation('com.github.ajalt:clikt:2.8.0')
+
+ // Database connection driver
+ implementation group: 'org.xerial', name: 'sqlite-jdbc', version: '3.36.0.1'
+ implementation 'org.postgresql:postgresql:42.2.23.jre7'
+
+ // Ktor, an HTTP client and server library (no need for nexus-setup)
+ implementation "io.ktor:ktor-server-core:$ktor_version"
+ implementation "io.ktor:ktor-server-content-negotiation:$ktor_version"
+ implementation "io.ktor:ktor-server-status-pages:$ktor_version"
+ implementation "io.ktor:ktor-client-apache:$ktor_version"
+ implementation "io.ktor:ktor-client-auth:$ktor_version"
+ implementation "io.ktor:ktor-server-netty:$ktor_version"
+
+ // Brings the call-logging library too.
+ implementation "io.ktor:ktor-server-test-host:$ktor_version"
+ implementation "io.ktor:ktor-auth:$ktor_auth_version"
+
+ // PDF generation
+ implementation 'com.itextpdf:itext7-core:7.1.16'
+
+ // UNIX domain sockets support (used to connect to PostgreSQL)
+ implementation 'com.kohlschutter.junixsocket:junixsocket-core:2.6.2'
+
+ // Serialization
+ implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
+ implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1"
+
+ // Unit testing
+ // testImplementation 'junit:junit:4.13.2'
+ // From https://docs.gradle.org/current/userguide/java_testing.html#sec:java_testing_basics:
+ testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1'
+ testImplementation 'org.jetbrains.kotlin:kotlin-test:1.5.21'
+ testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:1.5.21'
+ testImplementation 'io.ktor:ktor-client-mock:2.2.4'
+ testImplementation 'com.kohlschutter.junixsocket:junixsocket-core:2.6.2'
+}
+
+test {
+ useJUnit()
+ failFast = true
+ testLogging.showStandardStreams = false
+}
+
+application {
+ mainClassName = "tech.libeufin.nexus.MainKt"
+ applicationName = "libeufin-nexus"
+ applicationDefaultJvmArgs = ['-Djava.net.preferIPv6Addresses=true']
+}
+
+jar {
+ manifest {
+ attributes "Main-Class": "tech.libeufin.nexus.MainKt"
+ }
+}
+
+run {
+ standardInput = System.in
+} \ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
new file mode 100644
index 00000000..abd5044e
--- /dev/null
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
@@ -0,0 +1,762 @@
+/*
+ * 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/>
+ */
+
+/**
+ * This file runs the main logic of nexus-setup. This tool is
+ * responsible for reading configuration values about an EBICS
+ * subscriber and preparing the key material for further communication
+ * with the bank.
+ */
+
+package tech.libeufin.nexus
+import ConfigSource
+import TalerConfig
+import TalerConfigError
+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 io.ktor.client.*
+import io.ktor.util.*
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.KSerializer
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import java.io.File
+import kotlin.system.exitProcess
+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 net.taler.wallet.crypto.Base32Crockford
+import org.slf4j.event.Level
+import tech.libeufin.nexus.ebics.*
+import tech.libeufin.util.*
+import tech.libeufin.util.ebics_h004.EbicsTypes
+import java.security.interfaces.RSAPrivateCrtKey
+import java.security.interfaces.RSAPublicKey
+import java.time.Instant
+import kotlin.reflect.typeOf
+
+val NEXUS_CONFIG_SOURCE = ConfigSource("libeufin-nexus", "libeufin-nexus")
+val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus.Main")
+val myJson = Json {
+ this.serializersModule = SerializersModule {
+ contextual(RSAPrivateCrtKey::class) { RSAPrivateCrtKeySerializer }
+ contextual(RSAPublicKey::class) { RSAPublicKeySerializer }
+ }
+}
+
+/**
+ * Keeps all the options of the ebics-setup subcommand. The
+ * caller has to handle TalerConfigError if values are missing.
+ * If even one of the fields could not be instantiated, then
+ * throws TalerConfigError.
+ */
+class EbicsSetupConfig(config: TalerConfig) {
+ // abstracts the section name.
+ private val ebicsSetupRequireString = { option: String ->
+ config.requireString("nexus-ebics", option)
+ }
+ // debug utility to inspect what was loaded.
+ fun _dump() {
+ this.javaClass.declaredFields.forEach {
+ println("cfg obj: ${it.name} -> ${it.get(this)}")
+ }
+ }
+ /**
+ * The bank's currency.
+ */
+ val currency = ebicsSetupRequireString("currency")
+ /**
+ * The bank base URL.
+ */
+ val hostBaseUrl = ebicsSetupRequireString("host_base_url")
+ /**
+ * The bank EBICS host ID.
+ */
+ val ebicsHostId = ebicsSetupRequireString("host_id")
+ /**
+ * EBICS user ID.
+ */
+ val ebicsUserId = ebicsSetupRequireString("user_id")
+ /**
+ * EBICS partner ID.
+ */
+ val ebicsPartnerId = ebicsSetupRequireString("partner_id")
+ /**
+ * EBICS system ID (is this optional?).
+ */
+ val ebicsSystemId = ebicsSetupRequireString("system_id")
+ /**
+ * Bank account name, as given by the bank. It
+ * can be an IBAN or even any alphanumeric value.
+ */
+ val accountNumber = ebicsSetupRequireString("account_number")
+ /**
+ * Filename where we store the bank public keys.
+ */
+ val bankPublicKeysFilename = ebicsSetupRequireString("bank_public_keys_file")
+ /**
+ * Filename where we store our private keys.
+ */
+ val clientPrivateKeysFilename = ebicsSetupRequireString("client_private_keys_file")
+ /**
+ * Filename where we store the bank account main information.
+ */
+ val bankAccountMetadataFilename = ebicsSetupRequireString("account_meta_data_file")
+ /**
+ * A name that identifies the EBICS and ISO20022 flavour
+ * that Nexus should honor in the communication with the
+ * bank.
+ */
+ val bankDialect: String = ebicsSetupRequireString("bank_dialect").run {
+ if (this != "postfinance") throw Exception("Only 'postfinance' dialect is supported.")
+ return@run this
+ }
+}
+
+/**
+ * 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.loadRsaPublicKey(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.loadRsaPrivateKey(bytes)
+ }
+}
+
+/**
+ * Structure of the file that holds the bank account
+ * metadata.
+ */
+@Serializable
+data class BankAccountMetadataFile(
+ val account_holder_iban: String,
+ val bank_code: String?,
+ val account_holder_name: String
+)
+
+/**
+ * Structure of the JSON file that contains the client
+ * private keys on disk.
+ */
+@Serializable
+data class ClientPrivateKeysFile(
+ // FIXME: centralize the @Contextual use.
+ @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
+)
+/**
+ * Writes the JSON content to disk. Used when we create or update
+ * keys and other metadata JSON content to disk. WARNING: this overrides
+ * silently what's found under the given location!
+ *
+ * @param obj the class representing the JSON content to store to disk.
+ * @param location where to store `obj`
+ * @return true in case of success, false otherwise.
+ */
+inline fun <reified T> syncJsonToDisk(obj: T, location: String): Boolean {
+ val fileContent = try {
+ myJson.encodeToString(obj)
+ } catch (e: Exception) {
+ logger.error("Could not encode the input '${typeOf<T>()}' to JSON, detail: ${e.message}")
+ return false
+ }
+ try {
+ File(location).writeText(fileContent)
+ } catch (e: Exception) {
+ logger.error("Could not write JSON content at $location, detail: ${e.message}")
+ return false
+ }
+ return true
+}
+fun generateNewKeys(): ClientPrivateKeysFile =
+ ClientPrivateKeysFile(
+ authentication_private_key = CryptoUtil.generateRsaKeyPair(2048).private,
+ encryption_private_key = CryptoUtil.generateRsaKeyPair(2048).private,
+ signature_private_key = CryptoUtil.generateRsaKeyPair(2048).private,
+ submitted_hia = false,
+ submitted_ini = false
+)
+/**
+ * Conditionally generates the client private keys and stores them
+ * to disk, if the file does not exist already. Does nothing if the
+ * file exists.
+ *
+ * @param filename keys file location
+ * @return true if the keys file existed already or its creation
+ * went through, false for any error.
+ */
+fun maybeCreatePrivateKeysFile(filename: String): Boolean {
+ val f = File(filename)
+ // NOT overriding any file at the wanted location.
+ if (f.exists()) {
+ logger.debug("Private key file found at: $filename.")
+ return true
+ }
+ val newKeys = generateNewKeys()
+ if (!syncJsonToDisk(newKeys, filename))
+ return false
+ logger.info("New client keys created at: $filename")
+ return true
+}
+
+/**
+ * Load the bank keys file from disk.
+ *
+ * @param location the keys file location.
+ * @return the internal JSON representation of the keys file,
+ * or null on failures.
+ */
+fun loadBankKeys(location: String): BankPublicKeysFile? {
+ val f = File(location)
+ if (!f.exists()) {
+ logger.error("Could not find the bank keys file at: $location")
+ return null
+ }
+ val fileContent = try {
+ f.readText() // read from disk.
+ } catch (e: Exception) {
+ logger.error("Could not read the bank keys file from disk, detail: ${e.message}")
+ return null
+ }
+ return try {
+ myJson.decodeFromString(fileContent) // Parse into JSON.
+ } catch (e: Exception) {
+ logger.error(e.message)
+ @OptIn(InternalAPI::class) // enables message below.
+ logger.error(e.rootCause?.message) // actual useful message mentioning failing fields
+ return null
+ }
+}
+
+/**
+ * Load the client keys file from disk.
+ *
+ * @param location the keys file location.
+ * @return the internal JSON representation of the keys file,
+ * or null on failures.
+ */
+fun loadPrivateKeysFromDisk(location: String): ClientPrivateKeysFile? {
+ val f = File(location)
+ if (!f.exists()) {
+ logger.error("Could not find the private keys file at: $location")
+ return null
+ }
+ val fileContent = try {
+ f.readText() // read from disk.
+ } catch (e: Exception) {
+ logger.error("Could not read private keys from disk, detail: ${e.message}")
+ return null
+ }
+ return try {
+ myJson.decodeFromString(fileContent) // Parse into JSON.
+ } catch (e: Exception) {
+ logger.error(e.message)
+ @OptIn(InternalAPI::class) // enables message below.
+ logger.error(e.rootCause?.message) // actual useful message mentioning failing fields
+ return null
+ }
+}
+
+/**
+ * Obtains the client private keys, regardless of them being
+ * created for the first time, or read from an existing file
+ * on disk.
+ *
+ * @param location path to the file that contains the keys.
+ * @return true if the operation succeeds, false otherwise.
+ */
+fun preparePrivateKeys(location: String): ClientPrivateKeysFile? {
+ if (!maybeCreatePrivateKeysFile(location)) {
+ logger.error("Could not create client keys at $location")
+ exitProcess(1)
+ }
+ return loadPrivateKeysFromDisk(location) // loads what found at location.
+}
+
+/**
+ * Expresses the type of keying message that the user wants
+ * to send to the bank.
+ */
+enum class KeysOrderType {
+ INI,
+ HIA,
+ HPB
+}
+
+/**
+ * @return the "this" string with a space every two characters.
+ */
+fun String.spaceEachTwo() =
+ buildString {
+ this@spaceEachTwo.forEachIndexed { pos, c ->
+ when {
+ (pos == 0) -> this.append(c)
+ (pos % 2 == 0) -> this.append(" $c")
+ else -> this.append(c)
+ }
+ }
+ }
+
+/**
+ * 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): Boolean {
+ val encHash = CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).toHexString()
+ val authHash = CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key).toHexString()
+ println("The bank has the following keys, type 'yes, accept' to accept them..\n")
+ println("Encryption key: ${encHash.spaceEachTwo()}")
+ println("Authentication key: ${authHash.spaceEachTwo()}")
+ val userResponse: String? = readlnOrNull()
+ if (userResponse == "yes, accept")
+ return true
+ return false
+}
+
+/**
+ * Parses the HPB response and stores the bank keys as "NOT accepted" to disk.
+ *
+ * @param cfg used to get the location of the bank keys file.
+ * @param bankKeys bank response to the HPB message.
+ * @return true if the keys were stored to disk (as "not accepted"),
+ * false if the storage failed or the content was invalid.
+ */
+private fun handleHpbResponse(
+ cfg: EbicsSetupConfig,
+ bankKeys: EbicsKeyManagementResponseContent
+): Boolean {
+ val hpbBytes = bankKeys.orderData // silences compiler.
+ if (hpbBytes == null) {
+ logger.error("HPB content not found in a EBICS response with successful return codes.")
+ return false
+ }
+ val hpbObj = try {
+ parseEbicsHpbOrder(hpbBytes)
+ }
+ catch (e: Exception) {
+ logger.error("HPB response content seems invalid.")
+ return false
+ }
+ val encPub = try {
+ CryptoUtil.loadRsaPublicKey(hpbObj.encryptionPubKey.encoded)
+ } catch (e: Exception) {
+ logger.error("Could not import bank encryption key from HPB response, detail: ${e.message}")
+ return false
+ }
+ val authPub = try {
+ CryptoUtil.loadRsaPublicKey(hpbObj.authenticationPubKey.encoded)
+ } catch (e: Exception) {
+ logger.error("Could not import bank authentication key from HPB response, detail: ${e.message}")
+ return false
+ }
+ val json = BankPublicKeysFile(
+ bank_authentication_public_key = authPub,
+ bank_encryption_public_key = encPub,
+ accepted = false
+ )
+ if (!syncJsonToDisk(json, cfg.bankPublicKeysFilename)) {
+ logger.error("Failed to persist the bank keys to disk at: ${cfg.bankPublicKeysFilename}")
+ return false
+ }
+ return true
+}
+
+/**
+ * Collects all the steps from generating the message, to
+ * sending it to the bank, and finally updating the state
+ * on disk according to the response.
+ *
+ * @param cfg handle to the configuration.
+ * @param privs bundle of all the private keys of the client.
+ * @param client the http client that requests to the bank.
+ * @param orderType INI or HIA.
+ * @param autoAcceptBankKeys only given in case of HPB. Expresses
+ * the --auto-accept-key CLI flag.
+ * @return true if the message fulfilled its purpose AND the state
+ * on disk was accordingly updated, or false otherwise.
+ */
+suspend fun doKeysRequestAndUpdateState(
+ cfg: EbicsSetupConfig,
+ privs: ClientPrivateKeysFile,
+ client: HttpClient,
+ orderType: KeysOrderType
+): Boolean {
+ val req = when(orderType) {
+ KeysOrderType.INI -> generateIniMessage(cfg, privs)
+ KeysOrderType.HIA -> generateHiaMessage(cfg, privs)
+ KeysOrderType.HPB -> generateHpbMessage(cfg, privs)
+ }
+ val xml = client.postToBank(cfg.hostBaseUrl, req)
+ if (xml == null) {
+ logger.error("Could not POST the ${orderType.name} message to the bank")
+ return false
+ }
+ val ebics = parseKeysMgmtResponse(privs.encryption_private_key, xml)
+ if (ebics == null) {
+ logger.error("Could not get any EBICS from the bank ${orderType.name} response ($xml).")
+ return false
+ }
+ if (ebics.technicalReturnCode != EbicsReturnCode.EBICS_OK) {
+ logger.error("EBICS ${orderType.name} failed with code: ${ebics.technicalReturnCode}")
+ return false
+ }
+ if (ebics.bankReturnCode != EbicsReturnCode.EBICS_OK) {
+ logger.error("EBICS ${orderType.name} reached the bank, but could not be fulfilled, error code: ${ebics.bankReturnCode}")
+ return false
+ }
+
+ when(orderType) {
+ KeysOrderType.INI -> privs.submitted_ini = true
+ KeysOrderType.HIA -> privs.submitted_hia = true
+ KeysOrderType.HPB -> return handleHpbResponse(cfg, ebics)
+ }
+ if (!syncJsonToDisk(privs, cfg.clientPrivateKeysFilename)) {
+ logger.error("Could not update the ${orderType.name} state on disk")
+ return false
+ }
+ return true
+}
+
+/**
+ * Abstracts (part of) the IBAN extraction from an HTD response.
+ */
+private fun maybeExtractIban(accountNumberList: List<EbicsTypes.AbstractAccountNumber>): String? =
+ accountNumberList.filterIsInstance<EbicsTypes.GeneralAccountNumber>().find { it.international }?.value
+
+/**
+ * Abstracts (part of) the BIC extraction from an HTD response.
+ */
+private fun maybeExtractBic(bankCodes: List<EbicsTypes.AbstractBankCode>): String? =
+ bankCodes.filterIsInstance<EbicsTypes.GeneralBankCode>().find { it.international }?.value
+
+/**
+ * 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: EbicsSetupConfig) {
+ val pdf = generateKeysPdf(privs, cfg)
+ val pdfFile = File("/tmp/libeufin-nexus-keys-${Instant.now().epochSecond}.pdf")
+ if (pdfFile.exists()) {
+ logger.error("PDF file exists already at: ${pdfFile.path}, not overriding it")
+ exitProcess(1)
+ }
+ try {
+ pdfFile.writeBytes(pdf)
+ } catch (e: Exception) {
+ logger.error("Could not write PDF to ${pdfFile}, detail: ${e.message}")
+ exitProcess(1)
+ }
+ println("PDF file with keys hex encoding created at: $pdfFile")
+}
+
+/**
+ * Mere collector of the steps to load and parse the config.
+ *
+ * @param configFile location of the configuration entry point.
+ * @return internal representation of the configuration.
+ */
+private fun extractConfig(configFile: String?): EbicsSetupConfig {
+ val config = TalerConfig(NEXUS_CONFIG_SOURCE)
+ try {
+ config.load(configFile)
+ } catch (e: Exception) {
+ logger.error("Could not load configuration from ${configFile}, detail: ${e.message}")
+ exitProcess(1)
+ }
+ // Checking the config.
+ val cfg = try {
+ EbicsSetupConfig(config)
+ } catch (e: TalerConfigError) {
+ logger.error(e.message)
+ exitProcess(1)
+ }
+ return cfg
+}
+
+private fun findIban(maybeList: List<EbicsTypes.AccountInfo>?): String? {
+ if (maybeList == null) {
+ logger.warn("Looking for IBAN: bank did not give any account list for us.")
+ return null
+ }
+ if (maybeList.size != 1) {
+ logger.warn("Looking for IBAN: bank gave account list, but it was not a singleton.")
+ return null
+ }
+ val accountNumberList = maybeList[0].accountNumberList
+ if (accountNumberList == null) {
+ logger.warn("Bank gave account list, but no IBAN list of found.")
+ return null
+ }
+ if (accountNumberList.size != 1) {
+ logger.warn("Bank gave account list, but IBAN list was not singleton.")
+ return null
+ }
+ return maybeExtractIban(accountNumberList)
+}
+private fun findBic(maybeList: List<EbicsTypes.AccountInfo>?): String? {
+ if (maybeList == null) {
+ logger.warn("Looking for BIC: bank did not give any account list for us.")
+ return null
+ }
+ if (maybeList.size != 1) {
+ logger.warn("Looking for BIC: bank gave account list, but it was not a singleton.")
+ return null
+ }
+ val bankCodeList = maybeList[0].bankCodeList
+ if (bankCodeList == null) {
+ logger.warn("Bank gave account list, but no BIC list of found.")
+ return null
+ }
+ if (bankCodeList.size != 1) {
+ logger.warn("Bank gave account list, but BIC list was not singleton.")
+ return null
+ }
+ return maybeExtractBic(bankCodeList)
+}
+
+/**
+ * CLI class implementing the "ebics-setup" subcommand.
+ */
+class EbicsSetup: CliktCommand() {
+ private val configFile by option(
+ "--config", "-c",
+ help = "set the configuration file"
+ )
+ private val checkFullConfig by option(
+ help = "checks config values of ALL the subcommands"
+ ).flag(default = false)
+ 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 the user confirmation"
+ ).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 showAssociatedAccounts by option(
+ help = "shows which bank accounts belong to the EBICS subscriber"
+ ).flag(default = false)
+
+ /**
+ * This function collects the main steps of setting up an EBICS access.
+ */
+ override fun run() {
+ val cfg = extractConfig(this.configFile)
+ if (checkFullConfig) {
+ throw NotImplementedError("--check-full-config flag not implemented")
+ }
+ // Config is sane. Go (maybe) making the private keys.
+ val privsMaybe = preparePrivateKeys(cfg.clientPrivateKeysFilename)
+ if (privsMaybe == null) {
+ logger.error("Private keys preparation failed.")
+ exitProcess(1)
+ }
+ val httpClient = HttpClient()
+ // Privs exist. Upload their pubs
+ val keysNotSub = !privsMaybe.submitted_ini || !privsMaybe.submitted_hia
+ runBlocking {
+ if ((!privsMaybe.submitted_ini) || forceKeysResubmission)
+ doKeysRequestAndUpdateState(cfg, privsMaybe, httpClient, KeysOrderType.INI).apply { if (!this) exitProcess(1) }
+ if ((!privsMaybe.submitted_hia) || forceKeysResubmission)
+ doKeysRequestAndUpdateState(cfg, privsMaybe, httpClient, KeysOrderType.HIA).apply { if (!this) exitProcess(1) }
+ }
+ // Reloading new state from disk if any upload (and therefore a disk write) actually took place
+ val haveSubmitted = forceKeysResubmission || keysNotSub
+ val privs = if (haveSubmitted) {
+ logger.info("Keys submitted to the bank, at ${cfg.hostBaseUrl}")
+ loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)
+ } else privsMaybe
+ if (privs == null) {
+ logger.error("Could not reload private keys from disk after submission")
+ exitProcess(1)
+ }
+ // Really both must be submitted here.
+ if ((!privs.submitted_hia) || (!privs.submitted_ini)) {
+ logger.error("Cannot continue with non-submitted client keys.")
+ exitProcess(1)
+ }
+ // Eject PDF if the keys were submitted for the first time, or the user asked.
+ if (keysNotSub || generateRegistrationPdf) makePdf(privs, cfg)
+ // Checking if the bank keys exist on disk.
+ val bankKeysFile = File(cfg.bankPublicKeysFilename)
+ if (!bankKeysFile.exists()) { // FIXME: should this also check the content validity?
+ val areKeysOnDisk = runBlocking {
+ doKeysRequestAndUpdateState(
+ cfg,
+ privs,
+ httpClient,
+ KeysOrderType.HPB
+ )
+ }
+ if (!areKeysOnDisk) {
+ logger.error("Could not download bank keys. Send client keys (and/or related PDF document with --generate-registration-pdf) to the bank.")
+ exitProcess(1)
+ }
+ logger.info("Bank keys stored at ${cfg.bankPublicKeysFilename}")
+ }
+ // bank keys made it to the disk, check if they're accepted.
+ val bankKeysMaybe = loadBankKeys(cfg.bankPublicKeysFilename)
+ if (bankKeysMaybe == null) {
+ logger.error("Although previous checks, could not load the bank keys file from: ${cfg.bankPublicKeysFilename}")
+ exitProcess(1)
+ }
+ /**
+ * The following block potentially updates the bank keys state
+ * on disk, if that's the first time that they become accepted.
+ * If so, finally reloads the bank keys file from disk.
+ */
+ val bankKeys = if (!bankKeysMaybe.accepted) {
+
+ if (autoAcceptKeys) bankKeysMaybe.accepted = true
+ else bankKeysMaybe.accepted = askUserToAcceptKeys(bankKeysMaybe)
+
+ if (!bankKeysMaybe.accepted) {
+ logger.error("Cannot continue without accepting the bank keys.")
+ exitProcess(1)
+ }
+
+ if (!syncJsonToDisk(bankKeysMaybe, cfg.bankPublicKeysFilename)) {
+ logger.error("Could not set bank keys as accepted on disk.")
+ exitProcess(1)
+ }
+ // Reloading after the disk write above.
+ loadBankKeys(cfg.bankPublicKeysFilename) ?: kotlin.run {
+ logger.error("Could not reload bank keys after disk write.")
+ exitProcess(1)
+ }
+ } else
+ bankKeysMaybe // keys were already accepted.
+
+ // Downloading the list of owned bank account(s).
+ val bankAccounts = runBlocking {
+ fetchBankAccounts(cfg, privs, bankKeys, httpClient)
+ }
+ if (bankAccounts == null) {
+ logger.error("Could not obtain the list of bank accounts from the bank.")
+ exitProcess(1)
+ }
+ logger.info("Subscriber's bank accounts fetched.")
+ // Now trying to extract whatever IBAN & BIC pair the bank gave in the response.
+ val foundIban: String? = findIban(bankAccounts.partnerInfo.accountInfoList)
+ val foundBic: String? = findBic(bankAccounts.partnerInfo.accountInfoList)
+ // _some_ IBAN & BIC _might_ have been found, compare it with the config.
+ if (foundIban == null)
+ logger.warn("Bank seems NOT to show any IBAN for our account.")
+ if (foundBic == null)
+ logger.warn("Bank seems NOT to show any BIC for our account.")
+ // Warn the user if instead one IBAN was found but that differs from the config.
+ if (foundIban != null && foundIban != cfg.accountNumber) {
+ logger.error("Bank has another IBAN for us: $foundIban, while config has: ${cfg.accountNumber}")
+ exitProcess(1)
+ }
+ // Users wants only _see_ the accounts, NOT checking values and returning here.
+ if (showAssociatedAccounts) {
+ println("Bank associates this account to the EBICS user ${cfg.ebicsUserId}: IBAN: $foundIban, BIC: $foundBic, Name: ${bankAccounts.userInfo.name}")
+ return
+ }
+ // No divergences were found, either because the config was right
+ // _or_ the bank didn't give any information. Setting the account
+ // metadata accordingly.
+ val accountMetaData = BankAccountMetadataFile(
+ account_holder_name = bankAccounts.userInfo.name ?: "Account holder name not given",
+ account_holder_iban = foundIban ?: run iban@ {
+ logger.warn("Bank did not show any IBAN for us, defaulting to the one we configured.")
+ return@iban cfg.accountNumber },
+ bank_code = foundBic ?: run bic@ {
+ logger.warn("Bank did not show any BIC for us, setting it as null.")
+ return@bic null }
+ )
+ if (!syncJsonToDisk(accountMetaData, cfg.bankAccountMetadataFilename)) {
+ logger.error("Failed to persist bank account meta-data at: ${cfg.bankAccountMetadataFilename}")
+ exitProcess(1)
+ }
+ println("setup ready")
+ }
+}
+
+/**
+ * Main CLI class that collects all the subcommands.
+ */
+class LibeufinNexusCommand : CliktCommand() {
+ init {
+ versionOption(getVersion())
+ subcommands(EbicsSetup())
+ }
+ override fun run() = Unit
+}
+
+fun main(args: Array<String>) {
+ LibeufinNexusCommand().main(args)
+} \ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics.kt
new file mode 100644
index 00000000..2a6f7b9f
--- /dev/null
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics.kt
@@ -0,0 +1,384 @@
+/*
+ * 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/>
+ */
+
+/**
+ * This file collects the EBICS helpers in the most version-independent way.
+ * It tries therefore to make the helpers reusable across the EBICS versions 2.x
+ * and 3.x.
+ */
+
+/**
+ * NOTE: it has been observed that even with a EBICS 3 server, it
+ * is still possible to exchange the keys via the EBICS 2.5 protocol.
+ * That is how this file does, but future versions should implement the
+ * EBICS 3 keying.
+ */
+
+package tech.libeufin.nexus.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 io.ktor.client.*
+import io.ktor.client.plugins.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import tech.libeufin.nexus.BankPublicKeysFile
+import tech.libeufin.nexus.ClientPrivateKeysFile
+import tech.libeufin.nexus.EbicsSetupConfig
+import tech.libeufin.util.*
+import java.io.ByteArrayOutputStream
+import java.security.interfaces.RSAPrivateCrtKey
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import java.util.*
+
+/**
+ * Decrypts and decompresses the business payload that was
+ * transported within an EBICS message from the bank
+ *
+ * @param clientEncryptionKey client private encryption key, used to decrypt
+ * the transaction key. The transaction key is the
+ * one actually used to encrypt the payload.
+ * @param encryptionInfo details related to the encrypted payload.
+ * @param chunks the several chunks that constitute the whole encrypted payload.
+ * @return the plain payload. Errors throw, so the caller must handle those.
+ *
+ */
+fun decryptAndDecompressPayload(
+ clientEncryptionKey: RSAPrivateCrtKey,
+ encryptionInfo: DataEncryptionInfo,
+ chunks: List<String>
+): ByteArray {
+ val buf = StringBuilder()
+ chunks.forEach { buf.append(it) }
+ val decoded = Base64.getDecoder().decode(buf.toString())
+ val er = CryptoUtil.EncryptionResult(
+ encryptionInfo.transactionKey,
+ encryptionInfo.bankPubDigest,
+ decoded
+ )
+ val dataCompr = CryptoUtil.decryptEbicsE002(
+ er,
+ clientEncryptionKey
+ )
+ return EbicsOrderUtil.decodeOrderData(dataCompr)
+}
+
+/**
+ * POSTs the EBICS message to the bank.
+ *
+ * @param URL where the bank serves EBICS requests.
+ * @param msg EBICS message as raw string.
+ * @return the raw bank response, if the request made it to the
+ * EBICS handler, or null otherwise.
+ */
+suspend fun HttpClient.postToBank(bankUrl: String, msg: String): String? {
+ val resp: HttpResponse = try {
+ this.post(urlString = bankUrl) {
+ expectSuccess = false // avoids exceptions on non-2xx statuses.
+ contentType(ContentType.Text.Xml)
+ setBody(msg)
+ }
+ }
+ catch (e: Exception) {
+ // hard error (network issue, invalid URL, ..)
+ tech.libeufin.nexus.logger.error("Could not POST to bank at: $bankUrl, detail: ${e.message}")
+ return null
+ }
+ // Bank was found, but the EBICS request wasn't served.
+ // Note: EBICS errors get _still_ 200 OK, so here the error
+ // _should_ not be related to EBICS. 404 for a wrong URL
+ // is one example.
+ if (resp.status != HttpStatusCode.OK) {
+ tech.libeufin.nexus.logger.error("Bank was found at $bankUrl, but EBICS wasn't served. Response status: ${resp.status}, body: ${resp.bodyAsText()}")
+ return null
+ }
+ return resp.bodyAsText()
+}
+
+/**
+ * Generate the PDF document with all the client public keys
+ * to be sent on paper to the bank.
+ */
+fun generateKeysPdf(
+ clientKeys: ClientPrivateKeysFile,
+ cfg: EbicsSetupConfig
+): 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.ebicsHostId}
+ User-ID: ${cfg.ebicsUserId}
+ Partner-ID: ${cfg.ebicsPartnerId}
+ ES version: A006
+ """.trimIndent()
+ )
+ )
+ }
+
+ fun writeKey(doc: Document, priv: RSAPrivateCrtKey) {
+ val pub = CryptoUtil.getRsaPublicFromPrivate(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()
+}
+
+/**
+ * POSTs raw EBICS XML to the bank and checks the two return codes:
+ * EBICS- and bank-technical.
+ *
+ * @param clientKeys client keys, used to sign the request.
+ * @param bankKeys bank keys, used to decrypt and validate the response.
+ * @param xmlBody raw EBICS request in XML.
+ * @param withEbics3 true in case the communication is EBICS 3, false otherwise.
+ * @param tolerateEbicsReturnCode EBICS technical return code that may be accepted
+ * instead of EBICS_OK. That is the case of EBICS_DOWNLOAD_POSTPROCESS_DONE
+ * along download receipt phases.
+ * @param tolerateBankReturnCode Business return code that may be accepted instead of
+ * EBICS_OK. Typically, EBICS_NO_DOWNLOAD_DATA_AVAILABLE is tolerated
+ * when asking for new incoming payments.
+ * @return the internal representation of an EBICS response IF both return codes
+ * were EBICS_OK, or null otherwise.
+ */
+suspend fun postEbicsAndCheckReturnCodes(
+ client: HttpClient,
+ cfg: EbicsSetupConfig,
+ bankKeys: BankPublicKeysFile,
+ xmlReq: String,
+ isEbics3: Boolean,
+ tolerateEbicsReturnCode: EbicsReturnCode? = null,
+ tolerateBankReturnCode: EbicsReturnCode? = null
+): EbicsResponseContent? {
+ val respXml = client.postToBank(cfg.hostBaseUrl, xmlReq)
+ if (respXml == null) {
+ tech.libeufin.nexus.logger.error("EBICS init phase failed. Aborting the HTD operation.")
+ return null
+ }
+ val respObj: EbicsResponseContent = parseAndValidateEbicsResponse(
+ bankKeys,
+ respXml,
+ isEbics3
+ ) ?: return null // helper logged the cause already.
+
+ var isEbicsCodeTolerated = false
+ if (tolerateEbicsReturnCode != null)
+ isEbicsCodeTolerated = respObj.technicalReturnCode == tolerateEbicsReturnCode
+
+ // EBICS communication error.
+ if ((respObj.technicalReturnCode != EbicsReturnCode.EBICS_OK) && (!isEbicsCodeTolerated)) {
+ tech.libeufin.nexus.logger.error("EBICS return code is ${respObj.technicalReturnCode}, failing.")
+ return null
+ }
+ var isBankCodeTolerated = false
+ if (tolerateBankReturnCode != null)
+ isBankCodeTolerated = respObj.bankReturnCode == tolerateBankReturnCode
+
+ // Business error, although EBICS itself was correct.
+ if ((respObj.bankReturnCode != EbicsReturnCode.EBICS_OK) && (!isBankCodeTolerated)) {
+ tech.libeufin.nexus.logger.error("Bank-technical return code is ${respObj.technicalReturnCode}, failing.")
+ return null
+ }
+ return respObj
+}
+/**
+ * Collects all the steps of an EBICS download transaction. Namely
+ * it conducts: init -> transfer -> receipt phases.
+ *
+ * @param client HTTP client for POSTing to the bank.
+ * @param cfg configuration handle.
+ * @param clientKeys client EBICS private keys.
+ * @param bankKeys bank EBICS public keys.
+ * @param reqXml raw EBICS XML request.
+ * @return the bank response as an XML string, or null if one
+ * error took place. NOTE: any return code other than
+ * EBICS_OK constitutes an error.
+ */
+suspend fun doEbicsDownload(
+ client: HttpClient,
+ cfg: EbicsSetupConfig,
+ clientKeys: ClientPrivateKeysFile,
+ bankKeys: BankPublicKeysFile,
+ reqXml: String,
+ isEbics3: Boolean
+): String? {
+ val initResp = postEbicsAndCheckReturnCodes(client, cfg, bankKeys, reqXml, isEbics3)
+ if (initResp == null) {
+ tech.libeufin.nexus.logger.error("Could not get past the EBICS init phase, failing.")
+ return null
+ }
+ val howManySegments = initResp.numSegments
+ if (howManySegments == null) {
+ tech.libeufin.nexus.logger.error("Init response lacks the quantity of segments, failing.")
+ return null
+ }
+ val ebicsChunks = mutableListOf<String>()
+ // Getting the chunk(s)
+ val firstDataChunk = initResp.orderDataEncChunk
+ if (firstDataChunk == null) {
+ tech.libeufin.nexus.logger.error("Could not get the first data chunk, although the EBICS_OK return code, failing.")
+ return null
+ }
+ val dataEncryptionInfo = initResp.dataEncryptionInfo ?: run {
+ tech.libeufin.nexus.logger.error("EncryptionInfo element not found, despite non empty payload, failing.")
+ return null
+ }
+ ebicsChunks.add(firstDataChunk)
+ val tId = initResp.transactionID
+ if (tId == null) {
+ tech.libeufin.nexus.logger.error("Transaction ID not found in the init response, cannot do transfer phase, failing.")
+ return null
+ }
+ // proceed with the transfer phase.
+ for (x in 2 .. howManySegments) {
+ // request segment number x.
+ val transReq = createEbics25TransferPhase(cfg, clientKeys, x, howManySegments, tId)
+ val transResp = postEbicsAndCheckReturnCodes(client, cfg, bankKeys, transReq, isEbics3)
+ if (transResp == null) {
+ tech.libeufin.nexus.logger.error("EBICS transfer segment #$x failed.")
+ return null
+ }
+ val chunk = transResp.orderDataEncChunk
+ if (chunk == null) {
+ tech.libeufin.nexus.logger.error("EBICS transfer phase lacks chunk #$x, failing.")
+ return null
+ }
+ ebicsChunks.add(chunk)
+ }
+ // all chunks gotten, shaping a meaningful response now.
+ val payloadBytes = decryptAndDecompressPayload(
+ clientKeys.encryption_private_key,
+ dataEncryptionInfo,
+ ebicsChunks
+ )
+ // payload reconstructed, ack to the bank.
+ val ackXml = createEbics25ReceiptPhase(cfg, clientKeys, tId)
+ val ackResp = postEbicsAndCheckReturnCodes(
+ client,
+ cfg,
+ bankKeys,
+ ackXml,
+ isEbics3,
+ tolerateEbicsReturnCode = EbicsReturnCode.EBICS_DOWNLOAD_POSTPROCESS_DONE
+ )
+ if (ackResp == null) {
+ tech.libeufin.nexus.logger.error("EBICS receipt phase failed.")
+ return null
+ }
+ // receipt phase OK, can now return the payload as an XML string.
+ return try {
+ payloadBytes.toString(Charsets.UTF_8)
+ } catch (e: Exception) {
+ logger.error("Could not get the XML string out of payload bytes.")
+ null
+ }
+}
+
+/**
+ * Parses the bank response from the raw XML and verifies
+ * the bank signature.
+ *
+ * @param bankKeys provides the bank auth pub, to verify the signature.
+ * @param responseStr raw XML response from the bank
+ * @param withEbics3 true if the communication is EBICS 3, false otherwise.
+ * @return libeufin internal representation of EBICS responses. Null
+ * in case of errors.
+ */
+fun parseAndValidateEbicsResponse(
+ bankKeys: BankPublicKeysFile,
+ responseStr: String,
+ withEbics3: Boolean
+): EbicsResponseContent? {
+ val responseDocument = try {
+ XMLUtil.parseStringIntoDom(responseStr)
+ } catch (e: Exception) {
+ tech.libeufin.nexus.logger.error("Bank response apparently invalid.")
+ return null
+ }
+ if (!XMLUtil.verifyEbicsDocument(
+ responseDocument,
+ bankKeys.bank_authentication_public_key,
+ withEbics3
+ )) {
+ tech.libeufin.nexus.logger.error("Bank signature did not verify.")
+ return null
+ }
+ if (withEbics3)
+ return ebics3toInternalRepr(responseStr)
+ return ebics25toInternalRepr(responseStr)
+} \ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt
new file mode 100644
index 00000000..361bdd95
--- /dev/null
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt
@@ -0,0 +1,261 @@
+/*
+ * 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/>
+ */
+
+/**
+ * This file contains helpers to construct EBICS 2.x requests.
+ */
+
+package tech.libeufin.nexus.ebics
+
+import io.ktor.client.*
+import tech.libeufin.nexus.BankPublicKeysFile
+import tech.libeufin.nexus.ClientPrivateKeysFile
+import tech.libeufin.nexus.EbicsSetupConfig
+import tech.libeufin.util.*
+import tech.libeufin.util.ebics_h004.*
+import java.security.interfaces.RSAPrivateCrtKey
+import java.time.ZoneId
+import java.util.*
+import javax.xml.datatype.DatatypeFactory
+
+/**
+ * Request EBICS (2.x) HTD to the bank. This message type
+ * gets the list of bank accounts that are owned by the EBICS
+ * client.
+ *
+ * @param cfg configuration handle
+ * @param client client EBICS keys.
+ * @param bankKeys bank EBICS keys.
+ * @param client HTTP client handle.
+ * @return internal representation of the HTD response, or
+ * null in case of errors.
+ */
+suspend fun fetchBankAccounts(
+ cfg: EbicsSetupConfig,
+ clientKeys: ClientPrivateKeysFile,
+ bankKeys: BankPublicKeysFile,
+ client: HttpClient
+): HTDResponseOrderData? {
+ val xmlReq = createEbics25DownloadInit(cfg, clientKeys, bankKeys, "HTD")
+ val xmlResp = doEbicsDownload(client, cfg, clientKeys, bankKeys, xmlReq, false)
+ if (xmlResp == null) {
+ logger.error("EBICS HTD transaction failed.")
+ return null
+ }
+ return try {
+ XMLUtil.convertStringToJaxb<HTDResponseOrderData>(xmlResp).value
+ } catch (e: Exception) {
+ logger.error("Could not parse the HTD payload, detail: ${e.message}")
+ return null
+ }
+}
+/**
+ * Creates a EBICS 2.5 download init. message. So far only used
+ * to fetch the PostFinance bank accounts.
+ */
+fun createEbics25DownloadInit(
+ cfg: EbicsSetupConfig,
+ clientKeys: ClientPrivateKeysFile,
+ bankKeys: BankPublicKeysFile,
+ orderType: String,
+ orderParams: EbicsOrderParams = EbicsStandardOrderParams()
+): String {
+ val nonce = getNonce(128)
+ val req = EbicsRequest.createForDownloadInitializationPhase(
+ cfg.ebicsUserId,
+ cfg.ebicsPartnerId,
+ cfg.ebicsHostId,
+ nonce,
+ DatatypeFactory.newInstance().newXMLGregorianCalendar(
+ GregorianCalendar(
+ TimeZone.getTimeZone(ZoneId.systemDefault())
+ )
+ ),
+ bankKeys.bank_encryption_public_key,
+ bankKeys.bank_authentication_public_key,
+ orderType,
+ makeOrderParams(orderParams)
+ )
+ val doc = XMLUtil.convertJaxbToDocument(req)
+ XMLUtil.signEbicsDocument(
+ doc,
+ clientKeys.authentication_private_key,
+ withEbics3 = false
+ )
+ return XMLUtil.convertDomToString(doc)
+}
+
+/**
+ * Creates raw XML for an EBICS receipt phase.
+ *
+ * @param cfg configuration handle.
+ * @param clientKeys user EBICS private keys.
+ * @param transactionId transaction ID of the EBICS communication that
+ * should receive this receipt.
+ * @return receipt request in XML.
+ */
+fun createEbics25ReceiptPhase(
+ cfg: EbicsSetupConfig,
+ clientKeys: ClientPrivateKeysFile,
+ transactionId: String
+): String {
+ val req = EbicsRequest.createForDownloadReceiptPhase(
+ transactionId,
+ cfg.ebicsHostId
+ )
+ val doc = XMLUtil.convertJaxbToDocument(req)
+ XMLUtil.signEbicsDocument(
+ doc,
+ clientKeys.authentication_private_key,
+ withEbics3 = false
+ )
+ return XMLUtil.convertDomToString(doc)
+}
+
+/**
+ * Creates raw XML for an EBICS transfer phase.
+ *
+ * @param cfg configuration handle.
+ * @param clientKeys user EBICS private keys.
+ * @param segNumber which segment we ask to the bank.
+ * @param totalSegments how many segments compose the whole EBICS transaction.
+ * @param transactionId ID of the EBICS transaction that transports all the segments.
+ * @return raw XML string of the request.
+ */
+fun createEbics25TransferPhase(
+ cfg: EbicsSetupConfig,
+ clientKeys: ClientPrivateKeysFile,
+ segNumber: Int,
+ totalSegments: Int,
+ transactionId: String
+): String {
+ val req = EbicsRequest.createForDownloadTransferPhase(
+ hostID = cfg.ebicsHostId,
+ segmentNumber = segNumber,
+ numSegments = totalSegments,
+ transactionID = transactionId
+ )
+ val doc = XMLUtil.convertJaxbToDocument(req)
+ XMLUtil.signEbicsDocument(
+ doc,
+ clientKeys.authentication_private_key,
+ withEbics3 = false
+ )
+ return XMLUtil.convertDomToString(doc)
+}
+
+/**
+ * Parses the raw XML that came from the bank into the Nexus representation.
+ *
+ * @param clientEncryptionKey client private encryption key, used to decrypt
+ * the transaction key.
+ * @param xml the bank raw XML response
+ * @return the internal representation of the XML response, or null if the parsing or the decryption failed.
+ * Note: it _is_ possible to successfully return the internal repr. of this response, where
+ * the payload is null. That's however still useful, because the returned type provides bank
+ * and EBICS return codes.
+ */
+fun parseKeysMgmtResponse(
+ clientEncryptionKey: RSAPrivateCrtKey,
+ xml: String
+): EbicsKeyManagementResponseContent? {
+ val jaxb = try {
+ XMLUtil.convertStringToJaxb<EbicsKeyManagementResponse>(xml)
+ } catch (e: Exception) {
+ tech.libeufin.nexus.logger.error("Could not parse the raw response from bank into JAXB.")
+ return null
+ }
+ var payload: ByteArray? = null
+ jaxb.value.body.dataTransfer?.dataEncryptionInfo.apply {
+ // non-null indicates that an encrypted payload should be found.
+ if (this != null) {
+ val encOrderData = jaxb.value.body.dataTransfer?.orderData?.value
+ if (encOrderData == null) {
+ tech.libeufin.nexus.logger.error("Despite a non-null DataEncryptionInfo, OrderData could not be found, can't decrypt any payload!")
+ return null
+ }
+ payload = decryptAndDecompressPayload(
+ clientEncryptionKey,
+ DataEncryptionInfo(this.transactionKey, this.encryptionPubKeyDigest.value),
+ listOf(encOrderData)
+ )
+ }
+ }
+ val bankReturnCode = EbicsReturnCode.lookup(jaxb.value.body.returnCode.value) // business error
+ val ebicsReturnCode = EbicsReturnCode.lookup(jaxb.value.header.mutable.returnCode) // ebics error
+ return EbicsKeyManagementResponseContent(ebicsReturnCode, bankReturnCode, payload)
+}
+
+/**
+ * Generates the INI message to upload the signature key.
+ *
+ * @param cfg handle to the configuration.
+ * @param clientKeys set of all the client keys.
+ * @return the raw EBICS INI message.
+ */
+fun generateIniMessage(cfg: EbicsSetupConfig, clientKeys: ClientPrivateKeysFile): String {
+ val iniRequest = EbicsUnsecuredRequest.createIni(
+ cfg.ebicsHostId,
+ cfg.ebicsUserId,
+ cfg.ebicsPartnerId,
+ clientKeys.signature_private_key
+ )
+ val doc = XMLUtil.convertJaxbToDocument(iniRequest)
+ return XMLUtil.convertDomToString(doc)
+}
+
+/**
+ * Generates the HIA message: uploads the authentication and
+ * encryption keys.
+ *
+ * @param cfg handle to the configuration.
+ * @param clientKeys set of all the client keys.
+ * @return the raw EBICS HIA message.
+ */
+fun generateHiaMessage(cfg: EbicsSetupConfig, clientKeys: ClientPrivateKeysFile): String {
+ val hiaRequest = EbicsUnsecuredRequest.createHia(
+ cfg.ebicsHostId,
+ cfg.ebicsUserId,
+ cfg.ebicsPartnerId,
+ clientKeys.authentication_private_key,
+ clientKeys.encryption_private_key
+ )
+ val doc = XMLUtil.convertJaxbToDocument(hiaRequest)
+ return XMLUtil.convertDomToString(doc)
+}
+
+/**
+ * Generates the HPB message: downloads the bank keys.
+ *
+ * @param cfg handle to the configuration.
+ * @param clientKeys set of all the client keys.
+ * @return the raw EBICS HPB message.
+ */
+fun generateHpbMessage(cfg: EbicsSetupConfig, clientKeys: ClientPrivateKeysFile): String {
+ val hpbRequest = EbicsNpkdRequest.createRequest(
+ cfg.ebicsHostId,
+ cfg.ebicsPartnerId,
+ cfg.ebicsUserId,
+ getNonce(128),
+ DatatypeFactory.newInstance().newXMLGregorianCalendar(GregorianCalendar())
+ )
+ val doc = XMLUtil.convertJaxbToDocument(hpbRequest)
+ XMLUtil.signEbicsDocument(doc, clientKeys.authentication_private_key)
+ return XMLUtil.convertDomToString(doc)
+} \ No newline at end of file
diff --git a/nexus/src/main/resources/logback.xml b/nexus/src/main/resources/logback.xml
new file mode 100644
index 00000000..b18b437e
--- /dev/null
+++ b/nexus/src/main/resources/logback.xml
@@ -0,0 +1,23 @@
+<!-- configuration scan="true" -->
+<configuration>
+ <appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
+ <target>System.err</target>
+ <encoder>
+ <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <logger name="tech.libeufin.nexus" level="ALL" additivity="false">
+ <appender-ref ref="STDERR" />
+ </logger>
+
+ <logger name="io.netty" level="WARN"/>
+ <logger name="ktor" level="WARN"/>
+ <logger name="Exposed" level="WARN"/>
+ <logger name="tech.libeufin.util" level="DEBUG"/>
+
+ <root level="WARN">
+ <appender-ref ref="STDERR"/>
+ </root>
+
+</configuration>
diff --git a/nexus/src/test/kotlin/Common.kt b/nexus/src/test/kotlin/Common.kt
new file mode 100644
index 00000000..b9284822
--- /dev/null
+++ b/nexus/src/test/kotlin/Common.kt
@@ -0,0 +1,50 @@
+import io.ktor.client.*
+import io.ktor.client.engine.mock.*
+import io.ktor.client.request.*
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.modules.SerializersModule
+import tech.libeufin.nexus.*
+import java.security.interfaces.RSAPrivateCrtKey
+
+val j = Json {
+ this.serializersModule = SerializersModule {
+ contextual(RSAPrivateCrtKey::class) { RSAPrivateCrtKeySerializer }
+ }
+}
+
+val config: EbicsSetupConfig = run {
+ val handle = TalerConfig(NEXUS_CONFIG_SOURCE)
+ handle.load()
+ EbicsSetupConfig(handle)
+}
+
+val clientKeys = generateNewKeys()
+
+// Gets an HTTP client whose requests are going to be served by 'handler'.
+fun getMockedClient(
+ handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData
+): HttpClient {
+ return HttpClient(MockEngine) {
+ followRedirects = false
+ engine {
+ addHandler {
+ request -> handler(request)
+ }
+ }
+ }
+}
+
+fun getPofiConfig(userId: String, partnerId: String) = """
+ [nexus-ebics]
+ CURRENCY = KUDOS
+ HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb
+ HOST_ID = PFEBICS
+ USER_ID = $userId
+ PARTNER_ID = $partnerId
+ SYSTEM_ID = not-used
+ ACCOUNT_NUMBER = not-used-yet
+ BANK_PUBLIC_KEYS_FILE = /tmp/enc-auth-keys.json
+ CLIENT_PRIVATE_KEYS_FILE = /tmp/my-private-keys.json
+ ACCOUNT_META_DATA_FILE = /tmp/ebics-meta.json
+ BANK_DIALECT = postfinance
+""".trimIndent() \ No newline at end of file
diff --git a/nexus/src/test/kotlin/ConfigLoading.kt b/nexus/src/test/kotlin/ConfigLoading.kt
new file mode 100644
index 00000000..1216a13a
--- /dev/null
+++ b/nexus/src/test/kotlin/ConfigLoading.kt
@@ -0,0 +1,35 @@
+import org.junit.Test
+import org.junit.jupiter.api.assertThrows
+import tech.libeufin.nexus.EbicsSetupConfig
+import tech.libeufin.nexus.NEXUS_CONFIG_SOURCE
+
+class ConfigLoading {
+ /**
+ * Tests that the default configuration has _at least_ the options
+ * that are expected by the memory representation of config.
+ */
+ @Test
+ fun loadRequiredValues() {
+ val handle = TalerConfig(NEXUS_CONFIG_SOURCE)
+ handle.load()
+ val cfg = EbicsSetupConfig(handle)
+ cfg._dump()
+ }
+
+ /**
+ * Tests that if the configuration lacks at least one option, then
+ * the config loader throws exception.
+ */
+ @Test
+ fun detectMissingValues() {
+ val handle = TalerConfig(NEXUS_CONFIG_SOURCE)
+ handle.loadFromString("""
+ [ebics-nexus]
+ # All the other defaults won't be loaded.
+ BANK_DIALECT = postfinance
+ """.trimIndent())
+ assertThrows<TalerConfigError> {
+ EbicsSetupConfig(handle)
+ }
+ }
+} \ No newline at end of file
diff --git a/nexus/src/test/kotlin/Ebics.kt b/nexus/src/test/kotlin/Ebics.kt
new file mode 100644
index 00000000..5fd7e32a
--- /dev/null
+++ b/nexus/src/test/kotlin/Ebics.kt
@@ -0,0 +1,122 @@
+import io.ktor.client.*
+import io.ktor.client.engine.mock.*
+import io.ktor.http.*
+import kotlinx.coroutines.runBlocking
+import org.junit.Ignore
+import org.junit.Test
+import tech.libeufin.nexus.*
+import tech.libeufin.nexus.ebics.*
+import tech.libeufin.util.XMLUtil
+import tech.libeufin.util.ebics_h004.EbicsUnsecuredRequest
+import java.io.File
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class Ebics {
+
+ // Checks XML is valid and INI.
+ @Test
+ fun iniMessage() {
+ val msg = generateIniMessage(config, clientKeys)
+ val ini = XMLUtil.convertStringToJaxb<EbicsUnsecuredRequest>(msg) // ensures is valid
+ assertEquals(ini.value.header.static.orderDetails.orderType, "INI") // ensures is INI
+ }
+
+ // Checks XML is valid and HIA.
+ @Test
+ fun hiaMessage() {
+ val msg = generateHiaMessage(config, clientKeys)
+ val ini = XMLUtil.convertStringToJaxb<EbicsUnsecuredRequest>(msg) // ensures is valid
+ assertEquals(ini.value.header.static.orderDetails.orderType, "HIA") // ensures is HIA
+ }
+
+ // Checks XML is valid and HPB.
+ @Test
+ fun hpbMessage() {
+ val msg = generateHpbMessage(config, clientKeys)
+ val ini = XMLUtil.convertStringToJaxb<EbicsUnsecuredRequest>(msg) // ensures is valid
+ assertEquals(ini.value.header.static.orderDetails.orderType, "HPB") // ensures is HPB
+ }
+ // POSTs an EBICS message to the mock bank. Tests
+ // the main branches: unreachable bank, non-200 status
+ // code, and 200.
+ @Test
+ fun postMessage() {
+ val client404 = getMockedClient {
+ respondError(HttpStatusCode.NotFound)
+ }
+ val clientNoResponse = getMockedClient {
+ throw Exception("Network issue.")
+ }
+ val clientOk = getMockedClient {
+ respondOk("Not EBICS anyway.")
+ }
+ runBlocking {
+ assertNull(client404.postToBank("http://ignored.example.com/", "ignored"))
+ assertNull(clientNoResponse.postToBank("http://ignored.example.com/", "ignored"))
+ assertNotNull(clientOk.postToBank("http://ignored.example.com/", "ignored"))
+ }
+ }
+
+ // Tests that internal repr. of keys lead to valid PDF.
+ // Mainly tests that the function does not throw any error.
+ @Test
+ fun keysPdf() {
+ val pdf = generateKeysPdf(clientKeys, config)
+ File("/tmp/libeufin-nexus-test-keys.pdf").writeBytes(pdf)
+ }
+}
+
+@Ignore // manual tests
+class PostFinance {
+ private fun prep(): EbicsSetupConfig {
+ val handle = TalerConfig(NEXUS_CONFIG_SOURCE)
+ val ebicsUserId = File("/tmp/pofi-ebics-user-id.txt").readText()
+ val ebicsPartnerId = File("/tmp/pofi-ebics-partner-id.txt").readText()
+ handle.loadFromString(getPofiConfig(ebicsUserId, ebicsPartnerId))
+ return EbicsSetupConfig(handle)
+ }
+ // Tests sending client keys to the PostFinance test platform.
+ @Test
+ fun postClientKeys() {
+ val cfg = prep()
+ runBlocking {
+ val httpClient = HttpClient()
+ assertTrue(doKeysRequestAndUpdateState(cfg, clientKeys, httpClient, KeysOrderType.INI))
+ assertTrue(doKeysRequestAndUpdateState(cfg, clientKeys, httpClient, KeysOrderType.HIA))
+ }
+ }
+
+ // Tests getting the PostFinance keys from their test platform.
+ @Test
+ fun getBankKeys() {
+ val cfg = prep()
+ val keys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)
+ assertNotNull(keys)
+ assertTrue(keys.submitted_ini)
+ assertTrue(keys.submitted_hia)
+ runBlocking {
+ assertTrue(doKeysRequestAndUpdateState(
+ cfg,
+ keys,
+ HttpClient(),
+ KeysOrderType.HPB
+ ))
+ }
+ }
+
+ // Tests the HTD message type.
+ @Test
+ fun fetchAccounts() {
+ val cfg = prep()
+ val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)
+ assertNotNull(clientKeys)
+ val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename)
+ assertNotNull(bankKeys)
+ val htd = runBlocking { fetchBankAccounts(cfg, clientKeys, bankKeys, HttpClient()) }
+ assertNotNull(htd)
+ println(htd.partnerInfo.accountInfoList?.size)
+ }
+} \ No newline at end of file
diff --git a/nexus/src/test/kotlin/Keys.kt b/nexus/src/test/kotlin/Keys.kt
new file mode 100644
index 00000000..db598d44
--- /dev/null
+++ b/nexus/src/test/kotlin/Keys.kt
@@ -0,0 +1,88 @@
+import org.junit.Test
+import tech.libeufin.nexus.*
+import tech.libeufin.util.CryptoUtil
+import java.io.File
+import kotlin.test.*
+
+class PublicKeys {
+
+ // Tests intermittent spaces in public keys fingerprint.
+ @Test
+ fun splitTest() {
+ assertEquals("0099887766".spaceEachTwo(), "00 99 88 77 66") // even
+ assertEquals("ZZYYXXWWVVU".spaceEachTwo(), "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.generateRsaKeyPair(2028).public,
+ bank_encryption_public_key = CryptoUtil.generateRsaKeyPair(2028).public
+ )
+ // storing them on disk.
+ assertTrue(syncJsonToDisk(fileContent, config.bankPublicKeysFilename))
+ // loading them and check that values are the same.
+ val fromDisk = loadBankKeys(config.bankPublicKeysFilename)
+ 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("/tmp/highly-unlikely-to-be-found.json"))
+ }
+}
+class PrivateKeys {
+ val f = File("/tmp/nexus-privs-test.json")
+ init {
+ if (f.exists())
+ f.delete()
+ }
+
+ // Testing write failure due to insufficient permissions.
+ @Test
+ fun createWrongPermissions() {
+ f.writeText("won't be overridden")
+ f.setReadOnly()
+ assertFalse(syncJsonToDisk(clientKeys, f.path))
+ }
+
+ // Testing keys file creation.
+ @Test
+ fun creation() {
+ assertFalse(f.exists())
+ maybeCreatePrivateKeysFile(f.path) // file doesn't exist, this must create.
+ j.decodeFromString<ClientPrivateKeysFile>(f.readText()) // reading and validating disk content.
+ }
+ /**
+ * Tests whether loading keys from disk yields the same
+ * values that were stored to the file.
+ */
+ @Test
+ fun load() {
+ assertFalse(f.exists())
+ assertTrue(syncJsonToDisk(clientKeys, f.path)) // Artificially storing this to the file.
+ val fromDisk = loadPrivateKeysFromDisk(f.path) // 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(loadPrivateKeysFromDisk("/tmp/highly-unlikely-to-be-found.json"))
+ }
+} \ No newline at end of file
diff --git a/nexus/src/test/kotlin/MySerializers.kt b/nexus/src/test/kotlin/MySerializers.kt
new file mode 100644
index 00000000..75dfb46f
--- /dev/null
+++ b/nexus/src/test/kotlin/MySerializers.kt
@@ -0,0 +1,32 @@
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.modules.SerializersModule
+import net.taler.wallet.crypto.Base32Crockford
+import org.junit.Test
+import tech.libeufin.nexus.ClientPrivateKeysFile
+import tech.libeufin.nexus.RSAPrivateCrtKeySerializer
+import tech.libeufin.util.CryptoUtil
+import java.security.interfaces.RSAPrivateCrtKey
+import kotlin.test.assertEquals
+
+class MySerializers {
+ // Testing deserialization of RSA private keys.
+ @Test
+ fun rsaPrivDeserialization() {
+ val s = Base32Crockford.encode(CryptoUtil.generateRsaKeyPair(2048).private.encoded)
+ val a = Base32Crockford.encode(CryptoUtil.generateRsaKeyPair(2048).private.encoded)
+ val e = Base32Crockford.encode(CryptoUtil.generateRsaKeyPair(2048).private.encoded)
+ val obj = j.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.loadRsaPrivateKey(Base32Crockford.decode(s)))
+ assertEquals(obj.authentication_private_key, CryptoUtil.loadRsaPrivateKey(Base32Crockford.decode(a)))
+ assertEquals(obj.encryption_private_key, CryptoUtil.loadRsaPrivateKey(Base32Crockford.decode(e)))
+ }
+} \ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
index 0cccd975..8c5fe595 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,4 +1,4 @@
rootProject.name = 'libeufin'
include("bank")
-// include("nexus")
+include("nexus")
include("util")
diff --git a/util/src/main/kotlin/Ebics.kt b/util/src/main/kotlin/Ebics.kt
index 837f49ba..80585f1c 100644
--- a/util/src/main/kotlin/Ebics.kt
+++ b/util/src/main/kotlin/Ebics.kt
@@ -99,7 +99,7 @@ data class EbicsClientSubscriberDetails(
/**
* @param size in bits
*/
-private fun getNonce(size: Int): ByteArray {
+fun getNonce(size: Int): ByteArray {
val sr = SecureRandom()
val ret = ByteArray(size / 8)
sr.nextBytes(ret)
@@ -120,7 +120,7 @@ private fun getXmlDate(d: ZonedDateTime): XMLGregorianCalendar {
)
}
-private fun makeOrderParams(orderParams: EbicsOrderParams): EbicsRequest.OrderParams {
+fun makeOrderParams(orderParams: EbicsOrderParams): EbicsRequest.OrderParams {
return when (orderParams) {
is EbicsStandardOrderParams -> {
EbicsRequest.StandardOrderParams().apply {
@@ -637,7 +637,7 @@ fun parseEbicsHpbOrder(orderDataRaw: ByteArray): HpbResponseData {
)
}
-private fun ebics3toInternalRepr(response: String): EbicsResponseContent {
+fun ebics3toInternalRepr(response: String): EbicsResponseContent {
// logger.debug("Converting bank resp to internal repr.: $response")
val resp: JAXBElement<Ebics3Response> = try {
XMLUtil.convertStringToJaxb(response)
@@ -674,7 +674,7 @@ private fun ebics3toInternalRepr(response: String): EbicsResponseContent {
)
}
-private fun ebics25toInternalRepr(response: String): EbicsResponseContent {
+fun ebics25toInternalRepr(response: String): EbicsResponseContent {
val resp: JAXBElement<EbicsResponse> = try {
XMLUtil.convertStringToJaxb(response)
} catch (e: Exception) {