commit d290f8620da57ccf881166fc1f165873f7100b42
parent b69d3c141ba465afd383946749dd1a9f1c034300
Author: MS <ms@taler.net>
Date: Fri, 20 Oct 2023 15:22:21 +0200
nexus: moving subcommand into own file
Diffstat:
3 files changed, 520 insertions(+), 482 deletions(-)
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
@@ -0,0 +1,2 @@
+package tech.libeufin.nexus
+
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt
@@ -0,0 +1,514 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2023 Stanisci and Dold.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.nexus
+
+import com.github.ajalt.clikt.core.CliktCommand
+import com.github.ajalt.clikt.parameters.options.flag
+import com.github.ajalt.clikt.parameters.options.option
+import io.ktor.client.*
+import kotlinx.coroutines.runBlocking
+import tech.libeufin.util.ebics_h004.EbicsTypes
+import java.io.File
+import kotlin.system.exitProcess
+import TalerConfig
+import TalerConfigError
+import kotlinx.serialization.encodeToString
+import tech.libeufin.nexus.ebics.*
+import tech.libeufin.util.*
+import java.time.Instant
+import kotlin.reflect.typeOf
+
+/**
+ * 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
+}
+
+/**
+ * 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.
+ */
+private 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.
+ */
+private 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
+
+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)
+}
+
+/**
+ * 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
+}
+
+/**
+ * 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")
+}
+
+/**
+ * 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")
+ }
+}
+\ 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
@@ -18,12 +18,10 @@
*/
/**
- * 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.
+ * This file collects all the CLI subcommands and runs
+ * them. The actual implementation of each subcommand is
+ * kept in their respective files.
*/
-
package tech.libeufin.nexus
import ConfigSource
import TalerConfig
@@ -210,60 +208,6 @@ data class BankPublicKeysFile(
@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.
@@ -324,429 +268,6 @@ fun loadPrivateKeysFromDisk(location: String): ClientPrivateKeysFile? {
}
/**
- * 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() {