diff options
author | MS <ms@taler.net> | 2023-10-18 15:40:21 +0200 |
---|---|---|
committer | MS <ms@taler.net> | 2023-10-18 15:40:40 +0200 |
commit | e670ab6a3ab5bf2851eed8f4fb58405967b812f0 (patch) | |
tree | 40600b668c290b7aa7f4cc956df9b53c1c4c178e | |
parent | 4502e55c148c4803bb8b2204052f51f03d3ab8ab (diff) | |
download | libeufin-e670ab6a3ab5bf2851eed8f4fb58405967b812f0.tar.gz libeufin-e670ab6a3ab5bf2851eed8f4fb58405967b812f0.tar.bz2 libeufin-e670ab6a3ab5bf2851eed8f4fb58405967b812f0.zip |
Importing Nexus from refactoring branch.
-rw-r--r-- | nexus/build.gradle | 118 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 762 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics.kt | 384 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt | 261 | ||||
-rw-r--r-- | nexus/src/main/resources/logback.xml | 23 | ||||
-rw-r--r-- | nexus/src/test/kotlin/Common.kt | 50 | ||||
-rw-r--r-- | nexus/src/test/kotlin/ConfigLoading.kt | 35 | ||||
-rw-r--r-- | nexus/src/test/kotlin/Ebics.kt | 122 | ||||
-rw-r--r-- | nexus/src/test/kotlin/Keys.kt | 88 | ||||
-rw-r--r-- | nexus/src/test/kotlin/MySerializers.kt | 32 | ||||
-rw-r--r-- | settings.gradle | 2 | ||||
-rw-r--r-- | util/src/main/kotlin/Ebics.kt | 8 |
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) { |