diff options
Diffstat (limited to 'nexus/src/main/kotlin/tech/libeufin')
16 files changed, 1821 insertions, 1425 deletions
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt index 6b88362c..ca7b9116 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -27,8 +27,6 @@ import io.ktor.client.* import io.ktor.client.plugins.* import kotlinx.coroutines.* import tech.libeufin.common.* -import tech.libeufin.ebics.* -import tech.libeufin.ebics.ebics_h005.Ebics3Request import tech.libeufin.nexus.ebics.* import java.io.IOException import java.io.InputStream @@ -67,53 +65,6 @@ data class FetchContext( ) /** - * Downloads content via EBICS, according to the order params passed - * by the caller. - * - * @param T [Ebics2Request] for EBICS 2 or [Ebics3Request.OrderDetails.BTOrderParams] for EBICS 3 - * @param ctx [FetchContext] - * @param req contains the instructions for the download, namely - * which document is going to be downloaded from the bank. - * @return the [ByteArray] payload. On an empty response, the array - * length is zero. It returns null, if the bank assigned an - * error to the EBICS transaction. - */ -private suspend fun downloadHelper( - ctx: FetchContext, - lastExecutionTime: Instant? = null, - doc: SupportedDocument, - processing: (InputStream) -> Unit -) { - val isEbics3 = doc != SupportedDocument.PAIN_002_LOGS - val initXml = if (isEbics3) { - createEbics3DownloadInitialization( - ctx.cfg, - ctx.bankKeys, - ctx.clientKeys, - prepEbics3Document(doc, lastExecutionTime) - ) - } else { - val ebics2Req = prepEbics2Document(doc, lastExecutionTime) - createEbics25DownloadInit( - ctx.cfg, - ctx.clientKeys, - ctx.bankKeys, - ebics2Req.messageType, - ebics2Req.orderParams - ) - } - return ebicsDownload( - ctx.httpClient, - ctx.cfg, - ctx.clientKeys, - ctx.bankKeys, - initXml, - isEbics3, - processing - ) -} - -/** * Converts the 2-digits fraction value as given by the bank * (postfinance dialect), to the Taler 8-digit value (db representation). * @@ -322,7 +273,7 @@ private fun ingestDocuments( private suspend fun fetchDocuments( db: Database, ctx: FetchContext, - docs: List<Document> + docs: List<EbicsDocument> ): Boolean { val lastExecutionTime: Instant? = ctx.pinnedStart return docs.all { doc -> @@ -332,9 +283,18 @@ private suspend fun fetchDocuments( } else { logger.info("Fetching '${doc.fullDescription()}' from timestamp: $lastExecutionTime") } - val doc = doc.doc() // downloading the content - downloadHelper(ctx, lastExecutionTime, doc) { stream -> + val doc = doc.doc() + val order = downloadDocService(doc, doc == SupportedDocument.PAIN_002_LOGS) + ebicsDownload( + ctx.httpClient, + ctx.cfg, + ctx.clientKeys, + ctx.bankKeys, + order, + lastExecutionTime, + null + ) { stream -> val loggedStream = ctx.fileLogger.logFetch( stream, doc == SupportedDocument.PAIN_002_LOGS @@ -349,7 +309,7 @@ private suspend fun fetchDocuments( } } -enum class Document { +enum class EbicsDocument { /// EBICS acknowledgement - CustomerAcknowledgement HAC pain.002 acknowledgement, /// Payment status - CustomerPaymentStatusReport pain.002 @@ -394,10 +354,10 @@ class EbicsFetch: CliktCommand("Fetches EBICS files") { help = "This flag fetches only once from the bank and returns, " + "ignoring the 'frequency' configuration value" ).flag(default = false) - private val documents: Set<Document> by argument( + private val documents: Set<EbicsDocument> by argument( help = "Which documents should be fetched? If none are specified, all supported documents will be fetched", - helpTags = Document.entries.map { Pair(it.name, it.shortDescription()) }.toMap() - ).enum<Document>().multiple().unique() + helpTags = EbicsDocument.entries.map { Pair(it.name, it.shortDescription()) }.toMap() + ).enum<EbicsDocument>().multiple().unique() private val pinnedStart by option( help = "Constant YYYY-MM-DD date for the earliest document" + " to download (only consumed in --transient mode). The" + @@ -434,7 +394,7 @@ class EbicsFetch: CliktCommand("Fetches EBICS files") { null, FileLogger(ebicsLog) ) - val docs = if (documents.isEmpty()) Document.entries else documents.toList() + val docs = if (documents.isEmpty()) EbicsDocument.entries else documents.toList() if (transient) { logger.info("Transient mode: fetching once and returning.") val pinnedStartVal = pinnedStart diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt index a9e595f4..b0cbc299 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt @@ -23,9 +23,9 @@ import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.groups.* import com.github.ajalt.clikt.parameters.options.* import io.ktor.client.* +import io.ktor.client.plugins.* import tech.libeufin.common.* import tech.libeufin.common.crypto.* -import tech.libeufin.ebics.* import tech.libeufin.nexus.ebics.* import java.nio.file.* import java.time.Instant @@ -81,8 +81,8 @@ fun String.spaceEachTwo() = * @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() + val encHash = CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).encodeUpHex() + val authHash = CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key).encodeUpHex() println("The bank has the following keys:") println("Encryption key: ${encHash.spaceEachTwo()}") println("Authentication key: ${authHash.spaceEachTwo()}") @@ -92,43 +92,6 @@ private fun askUserToAcceptKeys(bankKeys: BankPublicKeysFile): Boolean { } /** - * 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. - */ -private fun handleHpbResponse( - cfg: EbicsSetupConfig, - bankKeys: EbicsKeyManagementResponseContent -) { - val hpbBytes = bankKeys.orderData // silences compiler. - if (hpbBytes == null) { - throw Exception("HPB content not found in a EBICS response with successful return codes.") - } - val hpbObj = try { - parseEbicsHpbOrder(hpbBytes.inputStream()) - } catch (e: Exception) { - throw Exception("HPB response content seems invalid", e) - } - val encPub = try { - CryptoUtil.loadRsaPublicKey(hpbObj.encryptionPubKey.encoded) - } catch (e: Exception) { - throw Exception("Could not import bank encryption key from HPB response", e) - } - val authPub = try { - CryptoUtil.loadRsaPublicKey(hpbObj.authenticationPubKey.encoded) - } catch (e: Exception) { - throw Exception("Could not import bank authentication key from HPB response", e) - } - val json = BankPublicKeysFile( - bank_authentication_public_key = authPub, - bank_encryption_public_key = encPub, - accepted = false - ) - persistBankKeys(json, cfg.bankPublicKeysFilename) -} - -/** * 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. @@ -147,37 +110,57 @@ suspend fun doKeysRequestAndUpdateState( orderType: KeysOrderType ) { logger.info("Doing key request ${orderType.name}") + val impl = Ebics3KeyMng(cfg, privs) val req = when(orderType) { - KeysOrderType.INI -> generateIniMessage(cfg, privs) - KeysOrderType.HIA -> generateHiaMessage(cfg, privs) - KeysOrderType.HPB -> generateHpbMessage(cfg, privs) + KeysOrderType.INI -> impl.INI() + KeysOrderType.HIA -> impl.HIA() + KeysOrderType.HPB -> impl.HPB() } - val xml = try { - client.postToBank(cfg.hostBaseUrl, req) - } catch (e: Exception) { - throw Exception("Could not POST the ${orderType.name} message to the bank at '${cfg.hostBaseUrl}'", e) - } - val ebics = parseKeysMgmtResponse(privs.encryption_private_key, xml) - if (ebics == null) { - throw Exception("Could not get any EBICS from the bank ${orderType.name} response ($xml).") - } - if (ebics.technicalReturnCode != EbicsReturnCode.EBICS_OK) { - throw Exception("EBICS ${orderType.name} failed with code: ${ebics.technicalReturnCode}") - } - if (ebics.bankReturnCode != EbicsReturnCode.EBICS_OK) { - throw Exception("EBICS ${orderType.name} reached the bank, but could not be fulfilled, error code: ${ebics.bankReturnCode}") + val xml = client.postToBank(cfg.hostBaseUrl, req, "$orderType") + val resp = Ebics3KeyMng.parseResponse(xml, privs.encryption_private_key) + + when (orderType) { + KeysOrderType.INI, KeysOrderType.HIA -> { + if (resp.technicalCode == EbicsReturnCode.EBICS_INVALID_USER_OR_USER_STATE) { + throw Exception("$orderType status code ${resp.technicalCode}: either your IDs are incorrect, or you already have keys registered with this bank") + } + } + KeysOrderType.HPB -> { + if (resp.technicalCode == EbicsReturnCode.EBICS_AUTHENTICATION_FAILED) { + throw Exception("$orderType status code ${resp.technicalCode}: could not download bank keys, send client keys (and/or related PDF document with --generate-registration-pdf) to the bank") + } + } } - + + val orderData = resp.okOrFail("${orderType.name}") when (orderType) { KeysOrderType.INI -> privs.submitted_ini = true KeysOrderType.HIA -> privs.submitted_hia = true - KeysOrderType.HPB -> return handleHpbResponse(cfg, ebics) + KeysOrderType.HPB -> { + val orderData = requireNotNull(orderData) { + "HPB: missing order data" + } + val (authPub, encPub) = Ebics3KeyMng.parseHpbOrder(orderData) + val bankKeys = BankPublicKeysFile( + bank_authentication_public_key = authPub, + bank_encryption_public_key = encPub, + accepted = false + ) + try { + persistBankKeys(bankKeys, cfg.bankPublicKeysFilename) + } catch (e: Exception) { + throw Exception("Could not update the ${orderType.name} state on disk", e) + } + } } - try { - persistClientKeys(privs, cfg.clientPrivateKeysFilename) - } catch (e: Exception) { - throw Exception("Could not update the ${orderType.name} state on disk", e) + if (orderType != KeysOrderType.HPB) { + try { + persistClientKeys(privs, cfg.clientPrivateKeysFilename) + } catch (e: Exception) { + throw Exception("Could not update the ${orderType.name} state on disk", e) + } } + } /** @@ -231,7 +214,12 @@ class EbicsSetup: CliktCommand("Set up the EBICS subscriber") { val cfg = extractEbicsConfig(common.config) // Config is sane. Go (maybe) making the private keys. val clientKeys = loadOrGenerateClientKeys(cfg.clientPrivateKeysFilename) - val httpClient = HttpClient() + val httpClient = HttpClient { + install(HttpTimeout) { + // It can take a lot of time for the bank to generate documents + socketTimeoutMillis = 5 * 60 * 1000 + } + } // Privs exist. Upload their pubs val keysNotSub = !clientKeys.submitted_ini if ((!clientKeys.submitted_ini) || forceKeysResubmission) @@ -244,16 +232,12 @@ class EbicsSetup: CliktCommand("Set up the EBICS subscriber") { // Checking if the bank keys exist on disk. var bankKeys = loadBankKeys(cfg.bankPublicKeysFilename) if (bankKeys == null) { - try { - doKeysRequestAndUpdateState( - cfg, - clientKeys, - httpClient, - KeysOrderType.HPB - ) - } catch (e: Exception) { - throw Exception("Could not download bank keys. Send client keys (and/or related PDF document with --generate-registration-pdf) to the bank", e) - } + doKeysRequestAndUpdateState( + cfg, + clientKeys, + httpClient, + KeysOrderType.HPB + ) logger.info("Bank keys stored at ${cfg.bankPublicKeysFilename}") bankKeys = loadBankKeys(cfg.bankPublicKeysFilename)!! } @@ -264,12 +248,12 @@ class EbicsSetup: CliktCommand("Set up the EBICS subscriber") { else bankKeys.accepted = askUserToAcceptKeys(bankKeys) if (!bankKeys.accepted) { - throw Exception("Cannot successfully finish the setup without accepting the bank keys.") + throw Exception("Cannot successfully finish the setup without accepting the bank keys") } try { persistBankKeys(bankKeys, cfg.bankPublicKeysFilename) } catch (e: Exception) { - throw Exception("Could not set bank keys as accepted on disk.", e) + throw Exception("Could not set bank keys as accepted on disk", e) } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt index d38ee64c..82c1a459 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt @@ -25,29 +25,11 @@ import com.github.ajalt.clikt.parameters.options.* import io.ktor.client.* import kotlinx.coroutines.* import tech.libeufin.common.* -import tech.libeufin.nexus.ebics.EbicsSideError -import tech.libeufin.nexus.ebics.EbicsSideException -import tech.libeufin.nexus.ebics.EbicsUploadException -import tech.libeufin.nexus.ebics.submitPain001 +import tech.libeufin.nexus.ebics.* import java.time.* import java.util.* /** - * Possible stages when an error may occur. These stages - * help to decide the retry policy. - */ -enum class NexusSubmissionStage { - pain, - ebics, - /** - * Includes both non-200 responses and network issues. - * They are both considered transient (non-200 responses - * can be fixed by changing and reloading the configuration). - */ - reachability -} - -/** * Groups useful parameters to submit pain.001 via EBICS. */ data class SubmissionContext( @@ -71,16 +53,6 @@ data class SubmissionContext( ) /** - * Expresses one error that occurred while submitting one pain.001 - * document via EBICS. - */ -class NexusSubmitException( - msg: String? = null, - cause: Throwable? = null, - val stage: NexusSubmissionStage -) : Exception(msg, cause) - -/** * Takes the initiated payment data as it was returned from the * database, sanity-checks it, gets the pain.001 from the helper * function and finally submits it via EBICS to the bank. @@ -113,38 +85,14 @@ private suspend fun submitInitiatedPayment( wireTransferSubject = payment.wireTransferSubject ) ctx.fileLogger.logSubmit(xml) - try { - return submitPain001( - xml, - ctx.cfg, - ctx.clientPrivateKeysFile, - ctx.bankPublicKeysFile, - ctx.httpClient - ) - } catch (early: EbicsSideException) { - val errorStage = when (early.sideEc) { - EbicsSideError.HTTP_POST_FAILED -> - NexusSubmissionStage.reachability // transient error - /** - * Any other [EbicsSideError] should be treated as permanent, - * as they involve invalid signatures or an unexpected response - * format. For this reason, they get the "ebics" stage assigned - * below, that will cause the payment as permanently failed and - * not to be retried. - */ - else -> - NexusSubmissionStage.ebics // permanent error - } - throw NexusSubmitException( - stage = errorStage, - cause = early - ) - } catch (permanent: EbicsUploadException) { - throw NexusSubmitException( - stage = NexusSubmissionStage.ebics, - cause = permanent - ) - } + return doEbicsUpload( + ctx.httpClient, + ctx.cfg, + ctx.clientPrivateKeysFile, + ctx.bankPublicKeysFile, + uploadPaymentService(), + xml + ) } /** @@ -157,43 +105,23 @@ private suspend fun submitInitiatedPayment( * @param clientKeys subscriber private keys. * @param bankKeys bank public keys. */ -private fun submitBatch( +private suspend fun submitBatch( ctx: SubmissionContext, db: Database, ) { logger.debug("Running submit at: ${Instant.now()}") - runBlocking { - db.initiatedPaymentsSubmittableGet(ctx.cfg.currency).forEach { - logger.debug("Submitting payment initiation with row ID: ${it.id}") - val submissionState = try { - val orderId = submitInitiatedPayment(ctx, it) - db.mem[orderId] = "Init" - DatabaseSubmissionState.success - } catch (e: NexusSubmitException) { - logger.error(e.message) - when (e.stage) { - /** - * Permanent failure: the pain.001 was invalid. For example a Payto - * URI was missing the receiver name, or the currency was wrong. Must - * not be retried. - */ - NexusSubmissionStage.pain -> DatabaseSubmissionState.permanent_failure - /** - * Transient failure: HTTP or network failed, either because one party - * was offline / unreachable, or because the bank URL is wrong. In both - * cases, the initiated payment stored in the database may still be correct, - * therefore we set this error as transient, and it'll be retried. - */ - NexusSubmissionStage.reachability -> DatabaseSubmissionState.transient_failure - /** - * As in the pain.001 case, there is a fundamental problem in the document - * being submitted, so it should not be retried. - */ - NexusSubmissionStage.ebics -> DatabaseSubmissionState.permanent_failure - } - } - db.initiatedPaymentSetSubmittedState(it.id, submissionState) + db.initiatedPaymentsSubmittableGet(ctx.cfg.currency).forEach { + logger.debug("Submitting payment initiation with row ID: ${it.id}") + val submissionState = try { + val orderId = submitInitiatedPayment(ctx, it) + db.mem[orderId] = "Init" + DatabaseSubmissionState.success + } catch (e: Exception) { + e.fmtLog(logger) + DatabaseSubmissionState.transient_failure + // TODO } + db.initiatedPaymentSetSubmittedState(it.id, submissionState) } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt index fb9c03ce..a9f44077 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -19,7 +19,6 @@ package tech.libeufin.nexus import tech.libeufin.common.* -import tech.libeufin.ebics.* import java.io.InputStream import java.net.URLEncoder import java.time.* @@ -76,14 +75,14 @@ fun createPain001( amount: TalerAmount, wireTransferSubject: String, creditAccount: IbanAccountMetadata -): String { +): ByteArray { val namespace = Pain001Namespaces( fullNamespace = "urn:iso:std:iso:20022:tech:xsd:pain.001.001.09", xsdFilename = "pain.001.001.09.ch.03.xsd" ) val zonedTimestamp = ZonedDateTime.ofInstant(initiationTimestamp, ZoneId.of("UTC")) val amountWithoutCurrency: String = getAmountNoCurrency(amount) - return constructXml("Document") { + return XmlBuilder.toBytes("Document") { attr("xmlns", namespace.fullNamespace) attr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance") attr("xsi:schemaLocation", "${namespace.fullNamespace} ${namespace.xsdFilename}") diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022CodeSets.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022CodeSets.kt new file mode 100644 index 00000000..bc2c7eae --- /dev/null +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022CodeSets.kt @@ -0,0 +1,330 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +// THIS FILE IS GENERATED, DO NOT EDIT + +package tech.libeufin.nexus + +enum class ExternalStatusReasonCode(val isoCode: String, val description: String) { + AB01("AbortedClearingTimeout", "Clearing process aborted due to timeout."), + AB02("AbortedClearingFatalError", "Clearing process aborted due to a fatal error."), + AB03("AbortedSettlementTimeout", "Settlement aborted due to timeout."), + AB04("AbortedSettlementFatalError", "Settlement process aborted due to a fatal error."), + AB05("TimeoutCreditorAgent", "Transaction stopped due to timeout at the Creditor Agent."), + AB06("TimeoutInstructedAgent", "Transaction stopped due to timeout at the Instructed Agent."), + AB07("OfflineAgent", "Agent of message is not online."), + AB08("OfflineCreditorAgent", "Creditor Agent is not online."), + AB09("ErrorCreditorAgent", "Transaction stopped due to error at the Creditor Agent."), + AB10("ErrorInstructedAgent", "Transaction stopped due to error at the Instructed Agent."), + AB11("TimeoutDebtorAgent", "Transaction stopped due to timeout at the Debtor Agent."), + AC01("IncorrectAccountNumber", "Account number is invalid or missing."), + AC02("InvalidDebtorAccountNumber", "Debtor account number invalid or missing"), + AC03("InvalidCreditorAccountNumber", "Creditor account number invalid or missing"), + AC04("ClosedAccountNumber", "Account number specified has been closed on the bank of account's books."), + AC05("ClosedDebtorAccountNumber", "Debtor account number closed"), + AC06("BlockedAccount", "Account specified is blocked, prohibiting posting of transactions against it."), + AC07("ClosedCreditorAccountNumber", "Creditor account number closed"), + AC08("InvalidBranchCode", "Branch code is invalid or missing"), + AC09("InvalidAccountCurrency", "Account currency is invalid or missing"), + AC10("InvalidDebtorAccountCurrency", "Debtor account currency is invalid or missing"), + AC11("InvalidCreditorAccountCurrency", "Creditor account currency is invalid or missing"), + AC12("InvalidAccountType", "Account type missing or invalid."), + AC13("InvalidDebtorAccountType", "Debtor account type missing or invalid"), + AC14("InvalidCreditorAccountType", "Creditor account type missing or invalid"), + AC15("AccountDetailsChanged", "The account details for the counterparty have changed."), + AC16("CardNumberInvalid", "Credit or debit card number is invalid."), + AEXR("AlreadyExpiredRTP", "Request-to-pay Expiry Date and Time has already passed."), + AG01("TransactionForbidden", "Transaction forbidden on this type of account (formerly NoAgreement)"), + AG02("InvalidBankOperationCode", "Bank Operation code specified in the message is not valid for receiver"), + AG03("TransactionNotSupported", "Transaction type not supported/authorized on this account"), + AG04("InvalidAgentCountry", "Agent country code is missing or invalid."), + AG05("InvalidDebtorAgentCountry", "Debtor agent country code is missing or invalid"), + AG06("InvalidCreditorAgentCountry", "Creditor agent country code is missing or invalid"), + AG07("UnsuccesfulDirectDebit", "Debtor account cannot be debited for a generic reason."), + AG08("InvalidAccessRights", "Transaction failed due to invalid or missing user or access right"), + AG09("PaymentNotReceived", "Original payment never received."), + AG10("AgentSuspended", "Agent of message is suspended from the Real Time Payment system."), + AG11("CreditorAgentSuspended", "Creditor Agent of message is suspended from the Real Time Payment system."), + AG12("NotAllowedBookTransfer", "Payment orders made by transferring funds from one account to another at the same financial institution (bank or payment institution) are not allowed."), + AG13("ForbiddenReturnPayment", "Returned payments derived from previously returned transactions are not allowed."), + AGNT("IncorrectAgent", "Agent in the payment workflow is incorrect"), + ALAC("AlreadyAcceptedRTP", "Request-to-pay has already been accepted by the Debtor."), + AM01("ZeroAmount", "Specified message amount is equal to zero"), + AM02("NotAllowedAmount", "Specific transaction/message amount is greater than allowed maximum"), + AM03("NotAllowedCurrency", "Specified message amount is an non processable currency outside of existing agreement"), + AM04("InsufficientFunds", "Amount of funds available to cover specified message amount is insufficient."), + AM05("Duplication", "Duplication"), + AM06("TooLowAmount", "Specified transaction amount is less than agreed minimum."), + AM07("BlockedAmount", "Amount specified in message has been blocked by regulatory authorities."), + AM09("WrongAmount", "Amount received is not the amount agreed or expected"), + AM10("InvalidControlSum", "Sum of instructed amounts does not equal the control sum."), + AM11("InvalidTransactionCurrency", "Transaction currency is invalid or missing"), + AM12("InvalidAmount", "Amount is invalid or missing"), + AM13("AmountExceedsClearingSystemLimit", "Transaction amount exceeds limits set by clearing system"), + AM14("AmountExceedsAgreedLimit", "Transaction amount exceeds limits agreed between bank and client"), + AM15("AmountBelowClearingSystemMinimum", "Transaction amount below minimum set by clearing system"), + AM16("InvalidGroupControlSum", "Control Sum at the Group level is invalid"), + AM17("InvalidPaymentInfoControlSum", "Control Sum at the Payment Information level is invalid"), + AM18("InvalidNumberOfTransactions", "Number of transactions is invalid or missing."), + AM19("InvalidGroupNumberOfTransactions", "Number of transactions at the Group level is invalid or missing"), + AM20("InvalidPaymentInfoNumberOfTransactions", "Number of transactions at the Payment Information level is invalid"), + AM21("LimitExceeded", "Transaction amount exceeds limits agreed between bank and client."), + AM22("ZeroAmountNotApplied", "Unable to apply zero amount to designated account. For example, where the rules of a service allow the use of zero amount payments, however the back-office system is unable to apply the funds to the account. If the rules of a service prohibit the use of zero amount payments, then code AM01 is used to report the error condition."), + AM23("AmountExceedsSettlementLimit", "Transaction amount exceeds settlement limit."), + APAR("AlreadyPaidRTP", "Request To Pay has already been paid by the Debtor."), + ARFR("AlreadyRefusedRTP", "Request-to-pay has already been refused by the Debtor."), + ARJR("AlreadyRejectedRTP", "Request-to-pay has already been rejected."), + ATNS("AttachementsNotSupported", "Attachments to the request-to-pay are not supported."), + BE01("InconsistenWithEndCustomer", "Identification of end customer is not consistent with associated account number. (formerly CreditorConsistency)."), + BE04("MissingCreditorAddress", "Specification of creditor's address, which is required for payment, is missing/not correct (formerly IncorrectCreditorAddress)."), + BE05("UnrecognisedInitiatingParty", "Party who initiated the message is not recognised by the end customer"), + BE06("UnknownEndCustomer", "End customer specified is not known at associated Sort/National Bank Code or does no longer exist in the books"), + BE07("MissingDebtorAddress", "Specification of debtor's address, which is required for payment, is missing/not correct."), + BE08("MissingDebtorName", "Debtor name is missing"), + BE09("InvalidCountry", "Country code is missing or Invalid."), + BE10("InvalidDebtorCountry", "Debtor country code is missing or invalid"), + BE11("InvalidCreditorCountry", "Creditor country code is missing or invalid"), + BE12("InvalidCountryOfResidence", "Country code of residence is missing or Invalid."), + BE13("InvalidDebtorCountryOfResidence", "Country code of debtor's residence is missing or Invalid"), + BE14("InvalidCreditorCountryOfResidence", "Country code of creditor's residence is missing or Invalid"), + BE15("InvalidIdentificationCode", "Identification code missing or invalid."), + BE16("InvalidDebtorIdentificationCode", "Debtor or Ultimate Debtor identification code missing or invalid"), + BE17("InvalidCreditorIdentificationCode", "Creditor or Ultimate Creditor identification code missing or invalid"), + BE18("InvalidContactDetails", "Contact details missing or invalid"), + BE19("InvalidChargeBearerCode", "Charge bearer code for transaction type is invalid"), + BE20("InvalidNameLength", "Name length exceeds local rules for payment type."), + BE21("MissingName", "Name missing or invalid. Generic usage if cannot specifically identify debtor or creditor."), + BE22("MissingCreditorName", "Creditor name is missing"), + BE23("AccountProxyInvalid", "Phone number or email address, or any other proxy, used as the account proxy is unknown or invalid."), + CERI("CheckERI", "Credit transfer is not tagged as an Extended Remittance Information (ERI) transaction but contains ERI."), + CH03("RequestedExecutionDateOrRequestedCollectionDateTooFarInFuture", "Value in Requested Execution Date or Requested Collection Date is too far in the future"), + CH04("RequestedExecutionDateOrRequestedCollectionDateTooFarInPast", "Value in Requested Execution Date or Requested Collection Date is too far in the past"), + CH07("ElementIsNotToBeUsedAtB-andC-Level", "Element is not to be used at B- and C-Level"), + CH09("MandateChangesNotAllowed", "Mandate changes are not allowed"), + CH10("InformationOnMandateChangesMissing", "Information on mandate changes are missing"), + CH11("CreditorIdentifierIncorrect", "Value in Creditor Identifier is incorrect"), + CH12("CreditorIdentifierNotUnambiguouslyAtTransaction-Level", "Creditor Identifier is ambiguous at Transaction Level"), + CH13("OriginalDebtorAccountIsNotToBeUsed", "Original Debtor Account is not to be used"), + CH14("OriginalDebtorAgentIsNotToBeUsed", "Original Debtor Agent is not to be used"), + CH15("ElementContentIncludesMoreThan140Characters", "Content Remittance Information/Structured includes more than 140 characters"), + CH16("ElementContentFormallyIncorrect", "Content is incorrect"), + CH17("ElementNotAdmitted", "Element is not allowed"), + CH19("ValuesWillBeSetToNextTARGETday", "Values in Interbank Settlement Date or Requested Collection Date will be set to the next TARGET day"), + CH20("DecimalPointsNotCompatibleWithCurrency", "Number of decimal points not compatible with the currency"), + CH21("RequiredCompulsoryElementMissing", "Mandatory element is missing"), + CH22("COREandB2BwithinOnemessage", "SDD CORE and B2B not permitted within one message"), + CHQC("ChequeSettledOnCreditorAccount", "Cheque has been presented in cheque clearing and settled on the creditor’s account."), + CN01("AuthorisationCancelled", "Authorisation is cancelled."), + CNOR("CreditorBankIsNotRegistered", "Creditor bank is not registered under this BIC in the CSM"), + CURR("IncorrectCurrency", "Currency of the payment is incorrect"), + CUST("RequestedByCustomer", "Cancellation requested by the Debtor"), + DC02("SettlementNotReceived", "Rejection of a payment due to covering FI settlement not being received."), + DNOR("DebtorBankIsNotRegistered", "Debtor bank is not registered under this BIC in the CSM"), + DS01("ElectronicSignaturesCorrect", "The electronic signature(s) is/are correct"), + DS02("OrderCancelled", "An authorized user has cancelled the order"), + DS03("OrderNotCancelled", "The user’s attempt to cancel the order was not successful"), + DS04("OrderRejected", "The order was rejected by the bank side (for reasons concerning content)"), + DS05("OrderForwardedForPostprocessing", "The order was correct and could be forwarded for postprocessing"), + DS06("TransferOrder", "The order was transferred to VEU"), + DS07("ProcessingOK", "All actions concerning the order could be done by the EBICS bank server"), + DS08("DecompressionError", "The decompression of the file was not successful"), + DS09("DecryptionError", "The decryption of the file was not successful"), + DS0A("DataSignRequested", "Data signature is required."), + DS0B("UnknownDataSignFormat", "Data signature for the format is not available or invalid."), + DS0C("SignerCertificateRevoked", "The signer certificate is revoked."), + DS0D("SignerCertificateNotValid", "The signer certificate is not valid (revoked or not active)."), + DS0E("IncorrectSignerCertificate", "The signer certificate is not present."), + DS0F("SignerCertificationAuthoritySignerNotValid", "The authority of the signer certification sending the certificate is unknown."), + DS0G("NotAllowedPayment", "Signer is not allowed to sign this operation type."), + DS0H("NotAllowedAccount", "Signer is not allowed to sign for this account."), + DS0K("NotAllowedNumberOfTransaction", "The number of transaction is over the number allowed for this signer."), + DS10("Signer1CertificateRevoked", "The certificate is revoked for the first signer."), + DS11("Signer1CertificateNotValid", "The certificate is not valid (revoked or not active) for the first signer."), + DS12("IncorrectSigner1Certificate", "The certificate is not present for the first signer."), + DS13("SignerCertificationAuthoritySigner1NotValid", "The authority of signer certification sending the certificate is unknown for the first signer."), + DS14("UserDoesNotExist", "The user is unknown on the server"), + DS15("IdenticalSignatureFound", "The same signature has already been sent to the bank"), + DS16("PublicKeyVersionIncorrect", "The public key version is not correct. This code is returned when a customer sends signature files to the financial institution after conversion from an older program version (old ES format) to a new program version (new ES format) without having carried out re-initialisation with regard to a public key change."), + DS17("DifferentOrderDataInSignatures", "Order data and signatures don’t match"), + DS18("RepeatOrder", "File cannot be tested, the complete order has to be repeated. This code is returned in the event of a malfunction during the signature check, e.g. not enough storage space."), + DS19("ElectronicSignatureRightsInsufficient", "The user’s rights (concerning his signature) are insufficient to execute the order"), + DS20("Signer2CertificateRevoked", "The certificate is revoked for the second signer."), + DS21("Signer2CertificateNotValid", "The certificate is not valid (revoked or not active) for the second signer."), + DS22("IncorrectSigner2Certificate", "The certificate is not present for the second signer."), + DS23("SignerCertificationAuthoritySigner2NotValid", "The authority of signer certification sending the certificate is unknown for the second signer."), + DS24("WaitingTimeExpired", "Waiting time expired due to incomplete order"), + DS25("OrderFileDeleted", "The order file was deleted by the bank server"), + DS26("UserSignedMultipleTimes", "The same user has signed multiple times"), + DS27("UserNotYetActivated", "The user is not yet activated (technically)"), + DT01("InvalidDate", "Invalid date (eg, wrong or missing settlement date)"), + DT02("InvalidCreationDate", "Invalid creation date and time in Group Header (eg, historic date)"), + DT03("InvalidNonProcessingDate", "Invalid non bank processing date (eg, weekend or local public holiday)"), + DT04("FutureDateNotSupported", "Future date not supported"), + DT05("InvalidCutOffDate", "Associated message, payment information block or transaction was received after agreed processing cut-off date, i.e., date in the past."), + DT06("ExecutionDateChanged", "Execution Date has been modified in order for transaction to be processed"), + DU01("DuplicateMessageID", "Message Identification is not unique."), + DU02("DuplicatePaymentInformationID", "Payment Information Block is not unique."), + DU03("DuplicateTransaction", "Transaction is not unique."), + DU04("DuplicateEndToEndID", "End To End ID is not unique."), + DU05("DuplicateInstructionID", "Instruction ID is not unique."), + DUPL("DuplicatePayment", "Payment is a duplicate of another payment"), + ED01("CorrespondentBankNotPossible", "Correspondent bank not possible."), + ED03("BalanceInfoRequest", "Balance of payments complementary info is requested"), + ED05("SettlementFailed", "Settlement of the transaction has failed."), + ED06("SettlementSystemNotAvailable", "Interbank settlement system not available."), + EDTL("ExpiryDateTooLong", "Expiry date time of the request-to-pay is too far in the future."), + EDTR("ExpiryDateTimeReached", "Expiry date time of the request-to-pay is already reached."), + ERIN("ERIOptionNotSupported", "Extended Remittance Information (ERI) option is not supported."), + FF01("InvalidFileFormat", "File Format incomplete or invalid"), + FF02("SyntaxError", "Syntax error reason is provided as narrative information in the additional reason information."), + FF03("InvalidPaymentTypeInformation", "Payment Type Information is missing or invalid."), + FF04("InvalidServiceLevelCode", "Service Level code is missing or invalid"), + FF05("InvalidLocalInstrumentCode", "Local Instrument code is missing or invalid"), + FF06("InvalidCategoryPurposeCode", "Category Purpose code is missing or invalid"), + FF07("InvalidPurpose", "Purpose is missing or invalid"), + FF08("InvalidEndToEndId", "End to End Id missing or invalid"), + FF09("InvalidChequeNumber", "Cheque number missing or invalid"), + FF10("BankSystemProcessingError", "File or transaction cannot be processed due to technical issues at the bank side"), + FF11("ClearingRequestAborted", "Clearing request rejected due it being subject to an abort operation."), + FF12("OriginalTransactionNotEligibleForRequestedReturn", "Original payment is not eligible to be returned given its current status."), + FF13("RequestForCancellationNotFound", "No record of request for cancellation found."), + FOCR("FollowingCancellationRequest", "Return following a cancellation request."), + FR01("Fraud", "Returned as a result of fraud."), + FRAD("FraudulentOrigin", "Cancellation requested following a transaction that was originated fraudulently. The use of the FraudulentOrigin code should be governed by jurisdictions."), + G000("PaymentTransferredAndTracked", "In an FI To FI Customer Credit Transfer: The Status Originator transferred the payment to the next Agent or to a Market Infrastructure. The payment transfer is tracked. No further updates will follow from the Status Originator."), + G001("PaymentTransferredAndNotTracked", "In an FI To FI Customer Credit Transfer: The Status Originator transferred the payment to the next Agent or to a Market Infrastructure. The payment transfer is not tracked. No further updates will follow from the Status Originator."), + G002("CreditDebitNotConfirmed", "In a FIToFI Customer Credit Transfer: Credit to the creditor’s account may not be confirmed same day. Update will follow from the Status Originator."), + G003("CreditPendingDocuments", "In a FIToFI Customer Credit Transfer: Credit to creditor’s account is pending receipt of required documents. The Status Originator has requested creditor to provide additional documentation. Update will follow from the Status Originator."), + G004("CreditPendingFunds", "In a FIToFI Customer Credit Transfer: Credit to the creditor’s account is pending, status Originator is waiting for funds provided via a cover. Update will follow from the Status Originator."), + G005("DeliveredWithServiceLevel", "Payment has been delivered to creditor agent with service level."), + G006("DeliveredWIthoutServiceLevel", "Payment has been delivered to creditor agent without service level."), + ID01("CorrespondingOriginalFileStillNotSent", "Signature file was sent to the bank but the corresponding original file has not been sent yet."), + IEDT("IncorrectExpiryDateTime", "Expiry date time of the request-to-pay is incorrect."), + IRNR("InitialRTPNeverReceived", "No initial request-to-pay has been received."), + MD01("NoMandate", "No Mandate"), + MD02("MissingMandatoryInformationInMandate", "Mandate related information data required by the scheme is missing."), + MD05("CollectionNotDue", "Creditor or creditor's agent should not have collected the direct debit"), + MD06("RefundRequestByEndCustomer", "Return of funds requested by end customer"), + MD07("EndCustomerDeceased", "End customer is deceased."), + MS02("NotSpecifiedReasonCustomerGenerated", "Reason has not been specified by end customer"), + MS03("NotSpecifiedReasonAgentGenerated", "Reason has not been specified by agent."), + NARR("Narrative", "Reason is provided as narrative information in the additional reason information."), + NERI("NoERI", "Credit transfer is tagged as an Extended Remittance Information (ERI) transaction but does not contain ERI."), + NOAR("NonAgreedRTP", "No existing agreement for receiving request-to-pay messages."), + NOAS("NoAnswerFromCustomer", "No response from Beneficiary."), + NOCM("NotCompliantGeneric", "Customer account is not compliant with regulatory requirements, for example FICA (in South Africa) or any other regulatory requirements which render an account inactive for certain processing."), + NOPG("NoPaymentGuarantee", "Requested payment guarantee (by Creditor) related to a request-to-pay cannot be provided."), + NRCH("PayerOrPayerRTPSPNotReachable", "Recipient side of the request-to-pay (payer or its request-to-pay service provider) is not reachable."), + PINS("TypeOfPaymentInstrumentNotSupported", "Type of payment requested in the request-to-pay is not supported by the payer."), + RC01("BankIdentifierIncorrect", "Bank identifier code specified in the message has an incorrect format (formerly IncorrectFormatForRoutingCode)."), + RC02("InvalidBankIdentifier", "Bank identifier is invalid or missing."), + RC03("InvalidDebtorBankIdentifier", "Debtor bank identifier is invalid or missing"), + RC04("InvalidCreditorBankIdentifier", "Creditor bank identifier is invalid or missing"), + RC05("InvalidBICIdentifier", "BIC identifier is invalid or missing."), + RC06("InvalidDebtorBICIdentifier", "Debtor BIC identifier is invalid or missing"), + RC07("InvalidCreditorBICIdentifier", "Creditor BIC identifier is invalid or missing"), + RC08("InvalidClearingSystemMemberIdentifier", "ClearingSystemMemberidentifier is invalid or missing."), + RC09("InvalidDebtorClearingSystemMemberIdentifier", "Debtor ClearingSystemMember identifier is invalid or missing"), + RC10("InvalidCreditorClearingSystemMemberIdentifier", "Creditor ClearingSystemMember identifier is invalid or missing"), + RC11("InvalidIntermediaryAgent", "Intermediary Agent is invalid or missing"), + RC12("MissingCreditorSchemeId", "Creditor Scheme Id is invalid or missing"), + RCON("RMessageConflict", "Conflict with R-Message"), + RECI("ReceiverCustomerInformation", "Further information regarding the intended recipient."), + REPR("RTPReceivedCanBeProcessed", "Request-to-pay has been received and can be processed further."), + RF01("NotUniqueTransactionReference", "Transaction reference is not unique within the message."), + RR01("MissingDebtorAccountOrIdentification", "Specification of the debtor’s account or unique identification needed for reasons of regulatory requirements is insufficient or missing"), + RR02("MissingDebtorNameOrAddress", "Specification of the debtor’s name and/or address needed for regulatory requirements is insufficient or missing."), + RR03("MissingCreditorNameOrAddress", "Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing."), + RR04("RegulatoryReason", "Regulatory Reason"), + RR05("RegulatoryInformationInvalid", "Regulatory or Central Bank Reporting information missing, incomplete or invalid."), + RR06("TaxInformationInvalid", "Tax information missing, incomplete or invalid."), + RR07("RemittanceInformationInvalid", "Remittance information structure does not comply with rules for payment type."), + RR08("RemittanceInformationTruncated", "Remittance information truncated to comply with rules for payment type."), + RR09("InvalidStructuredCreditorReference", "Structured creditor reference invalid or missing."), + RR10("InvalidCharacterSet", "Character set supplied not valid for the country and payment type."), + RR11("InvalidDebtorAgentServiceID", "Invalid or missing identification of a bank proprietary service."), + RR12("InvalidPartyID", "Invalid or missing identification required within a particular country or payment type."), + RTNS("RTPNotSupportedForDebtor", "Debtor does not support request-to-pay transactions."), + RUTA("ReturnUponUnableToApply", "Return following investigation request and no remediation possible."), + S000("ValidRequestForCancellationAcknowledged", "Request for Cancellation is acknowledged following validation."), + S001("UETRFlaggedForCancellation", "Unique End-to-end Transaction Reference (UETR) relating to a payment has been identified as being associated with a Request for Cancellation."), + S002("NetworkStopOfUETR", "Unique End-to-end Transaction Reference (UETR) relating to a payment has been prevent from traveling across a messaging network."), + S003("RequestForCancellationForwarded", "Request for Cancellation has been forwarded to the payment processing/last payment processing agent."), + S004("RequestForCancellationDeliveryAcknowledgement", "Request for Cancellation has been acknowledged as delivered to payment processing/last payment processing agent."), + SL01("SpecificServiceOfferedByDebtorAgent", "Due to specific service offered by the Debtor Agent."), + SL02("SpecificServiceOfferedByCreditorAgent", "Due to specific service offered by the Creditor Agent."), + SL03("ServiceofClearingSystem", "Due to a specific service offered by the clearing system."), + SL11("CreditorNotOnWhitelistOfDebtor", "Whitelisting service offered by the Debtor Agent; Debtor has not included the Creditor on its “Whitelist” (yet). In the Whitelist the Debtor may list all allowed Creditors to debit Debtor bank account."), + SL12("CreditorOnBlacklistOfDebtor", "Blacklisting service offered by the Debtor Agent; Debtor included the Creditor on his “Blacklist”. In the Blacklist the Debtor may list all Creditors not allowed to debit Debtor bank account."), + SL13("MaximumNumberOfDirectDebitTransactionsExceeded", "Due to Maximum allowed Direct Debit Transactions per period service offered by the Debtor Agent."), + SL14("MaximumDirectDebitTransactionAmountExceeded", "Due to Maximum allowed Direct Debit Transaction amount service offered by the Debtor Agent."), + SPII("RTPServiceProviderIdentifierIncorrect", "Identifier of the request-to-pay service provider is incorrect."), + TA01("TransmissonAborted", "The transmission of the file was not successful – it had to be aborted (for technical reasons)"), + TD01("NoDataAvailable", "There is no data available (for download)"), + TD02("FileNonReadable", "The file cannot be read (e.g. unknown format)"), + TD03("IncorrectFileStructure", "The file format is incomplete or invalid"), + TK01("TokenInvalid", "Token is invalid."), + TK02("SenderTokenNotFound", "Token used for the sender does not exist."), + TK03("ReceiverTokenNotFound", "Token used for the receiver does not exist."), + TK09("TokenMissing", "Token required for request is missing."), + TKCM("TokenCounterpartyMismatch", "Token found with counterparty mismatch."), + TKSG("TokenSingleUse", "Single Use Token already used."), + TKSP("TokenSuspended", "Token found with suspended status."), + TKVE("TokenValueLimitExceeded", "Token found with value limit rule violation."), + TKXP("TokenExpired", "Token expired."), + TM01("InvalidCutOffTime", "Associated message, payment information block, or transaction was received after agreed processing cut-off time."), + TS01("TransmissionSuccessful", "The (technical) transmission of the file was successful."), + TS04("TransferToSignByHand", "The order was transferred to pass by accompanying note signed by hand"), + UCRD("UnknownCreditor", "Unknown Creditor."), + UPAY("UnduePayment", "Payment is not justified."), +} + +enum class ExternalPaymentGroupStatusCode(val isoCode: String, val description: String) { + ACCC("AcceptedSettlementCompletedCreditorAccount", "Settlement on the creditor's account has been completed."), + ACCP("AcceptedCustomerProfile", "Preceding check of technical validation was successful. Customer profile check was also successful."), + ACSC("AcceptedSettlementCompletedDebitorAccount", "Settlement on the debtor's account has been completed."), + ACSP("AcceptedSettlementInProcess", "All preceding checks such as technical validation and customer profile were successful and therefore the payment initiation has been accepted for execution."), + ACTC("AcceptedTechnicalValidation", "Authentication and syntactical and semantical validation are successful"), + ACWC("AcceptedWithChange", "Instruction is accepted but a change will be made, such as date or remittance not sent."), + PART("PartiallyAccepted", "A number of transactions have been accepted, whereas another number of transactions have not yet achieved"), + PDNG("Pending", "Payment initiation or individual transaction included in the payment initiation is pending. Further checks and status update will be performed."), + RCVD("Received", "Payment initiation has been received by the receiving agent"), + RJCT("Rejected", "Payment initiation or individual transaction included in the payment initiation has been rejected."), +} + +enum class ExternalPaymentTransactionStatusCode(val isoCode: String, val description: String) { + ACCC("AcceptedSettlementCompletedCreditorAccount", "Settlement on the creditor's account has been completed."), + ACCP("AcceptedCustomerProfile", "Preceding check of technical validation was successful. Customer profile check was also successful."), + ACFC("AcceptedFundsChecked", "Preceding check of technical validation and customer profile was successful and an automatic funds check was positive."), + ACIS("AcceptedandChequeIssued", "Payment instruction to issue a cheque has been accepted, and the cheque has been issued but not yet been deposited or cleared."), + ACPD("AcceptedClearingProcessed", "Status of transaction released from the Debtor Agent and accepted by the clearing."), + ACSC("AcceptedSettlementCompletedDebitorAccount", "Settlement completed."), + ACSP("AcceptedSettlementInProcess", "All preceding checks such as technical validation and customer profile were successful and therefore the payment instruction has been accepted for execution."), + ACTC("AcceptedTechnicalValidation", "Authentication and syntactical and semantical validation are successful"), + ACWC("AcceptedWithChange", "Instruction is accepted but a change will be made, such as date or remittance not sent."), + ACWP("AcceptedWithoutPosting", "Payment instruction included in the credit transfer is accepted without being posted to the creditor customer’s account."), + BLCK("Blocked", "Payment transaction previously reported with status 'ACWP' is blocked, for example, funds will neither be posted to the Creditor's account, nor be returned to the Debtor."), + CANC("Cancelled", "Payment initiation has been successfully cancelled after having received a request for cancellation."), + CPUC("CashPickedUpByCreditor", "Cash has been picked up by the Creditor."), + PATC("PartiallyAcceptedTechnicalCorrect", "Payment initiation needs multiple authentications, where some but not yet all have been performed. Syntactical and semantical validations are successful."), + PDNG("Pending", "Payment instruction is pending. Further checks and status update will be performed."), + PRES("Presented", "Request for Payment has been presented to the Debtor."), + RCVD("Received", "Payment instruction has been received."), + RJCT("Rejected", "Payment instruction has been rejected."), +} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022Constants.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022Constants.kt new file mode 100644 index 00000000..abc7bc80 --- /dev/null +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022Constants.kt @@ -0,0 +1,35 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.nexus + +enum class HacAction(val description: String) { + FILE_UPLOAD("File submitted to the bank"), + FILE_DOWNLOAD("File downloaded from the bank"), + ES_UPLOAD("Electronic signature submitted to the bank"), + ES_DOWNLOAD("Electronic signature downloaded from the bank"), + ES_VERIFICATION("Signature verification"), + VEU_FORWARDING("Forwarding to EDS"), + VEU_VERIFICATION("EDS signature verification"), + VEU_VERIFICATION_END("VEU_VERIFICATION_END"), + VEU_CANCEL_ORDER("Cancellation of EDS order"), + ADDITIONAL("Additional information"), + ORDER_HAC_FINAL_POS("HAC end of order (positive)"), + ORDER_HAC_FINAL_NEG("ORDER_HAC_FINAL_NEG") +}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Log.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Log.kt index 9b4867d1..1367c97c 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Log.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Log.kt @@ -80,7 +80,7 @@ class FileLogger(path: String?) { * * @param content EBICS submit content */ - fun logSubmit(content: String) { + fun logSubmit(content: ByteArray) { if (dir == null) return // Subdir based on current day. @@ -90,6 +90,6 @@ class FileLogger(path: String?) { // Creating the combined dir. val subDir = dir.resolve("${asUtcDate.year}-${asUtcDate.monthValue}-${asUtcDate.dayOfMonth}").resolve("submit") subDir.createDirectories() - subDir.resolve("${nowMs}_pain.001.xml").writeText(content) + subDir.resolve("${nowMs}_pain.001.xml").writeBytes(content) } }
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/PDF.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/PDF.kt new file mode 100644 index 00000000..a485477c --- /dev/null +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/PDF.kt @@ -0,0 +1,115 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.nexus + +import com.itextpdf.kernel.pdf.PdfDocument +import com.itextpdf.kernel.pdf.PdfWriter +import com.itextpdf.layout.Document +import com.itextpdf.layout.element.AreaBreak +import com.itextpdf.layout.element.Paragraph +import java.security.interfaces.RSAPrivateCrtKey +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.* +import java.io.ByteArrayOutputStream +import tech.libeufin.common.crypto.* + +/** + * 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() +}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/XMLUtil.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/XMLUtil.kt new file mode 100644 index 00000000..2893f367 --- /dev/null +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/XMLUtil.kt @@ -0,0 +1,175 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.nexus + +import tech.libeufin.nexus.ebics.* +import io.ktor.http.* +import org.w3c.dom.Document +import org.w3c.dom.Node +import org.w3c.dom.NodeList +import org.w3c.dom.ls.LSInput +import org.w3c.dom.ls.LSResourceResolver +import org.xml.sax.ErrorHandler +import org.xml.sax.InputSource +import org.xml.sax.SAXException +import org.xml.sax.SAXParseException +import java.io.* +import java.security.PrivateKey +import java.security.PublicKey +import java.security.interfaces.RSAPrivateCrtKey +import javax.xml.XMLConstants +import javax.xml.crypto.* +import javax.xml.crypto.dom.DOMURIReference +import javax.xml.crypto.dsig.* +import javax.xml.crypto.dsig.dom.DOMSignContext +import javax.xml.crypto.dsig.dom.DOMValidateContext +import javax.xml.crypto.dsig.spec.C14NMethodParameterSpec +import javax.xml.crypto.dsig.spec.TransformParameterSpec +import javax.xml.namespace.NamespaceContext +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.OutputKeys +import javax.xml.transform.Source +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult +import javax.xml.transform.stream.StreamSource +import javax.xml.validation.SchemaFactory +import javax.xml.validation.Validator +import javax.xml.xpath.XPath +import javax.xml.xpath.XPathConstants +import javax.xml.xpath.XPathFactory + +/** + * This URI dereferencer allows handling the resource reference used for + * XML signatures in EBICS. + */ +private class EbicsSigUriDereferencer : URIDereferencer { + override fun dereference(myRef: URIReference?, myCtx: XMLCryptoContext?): Data { + if (myRef !is DOMURIReference) + throw Exception("invalid type") + if (myRef.uri != "#xpointer(//*[@authenticate='true'])") + throw Exception("invalid EBICS XML signature URI: '${myRef.uri}'") + val xp: XPath = XPathFactory.newInstance().newXPath() + val nodeSet = xp.compile("//*[@authenticate='true']/descendant-or-self::node()").evaluate( + myRef.here.ownerDocument, XPathConstants.NODESET + ) + if (nodeSet !is NodeList) + throw Exception("invalid type") + if (nodeSet.length <= 0) { + throw Exception("no nodes to sign") + } + val nodeList = ArrayList<Node>() + for (i in 0 until nodeSet.length) { + val node = nodeSet.item(i) + nodeList.add(node) + } + return NodeSetData { nodeList.iterator() } + } +} + +/** + * Helpers for dealing with XML in EBICS. + */ +object XMLUtil { + fun convertDomToBytes(document: Document): ByteArray { + val w = ByteArrayOutputStream() + val transformer = TransformerFactory.newInstance().newTransformer() + transformer.setOutputProperty(OutputKeys.STANDALONE, "yes") + transformer.transform(DOMSource(document), StreamResult(w)) + return w.toByteArray() + } + + /** Parse [xml] into a XML DOM */ + fun parseIntoDom(xml: InputStream): Document { + val factory = DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = true + } + val builder = factory.newDocumentBuilder() + return xml.use { + builder.parse(InputSource(it)) + } + } + + /** + * Sign an EBICS document with the authentication and identity signature. + */ + fun signEbicsDocument( + doc: Document, + signingPriv: PrivateKey, + schema: String + ) { + val authSigNode = XPathFactory.newInstance().newXPath() + .evaluate("/*[1]/urn:org:ebics:$schema:AuthSignature", doc, XPathConstants.NODE) + if (authSigNode !is Node) + throw java.lang.Exception("no AuthSignature") + val fac = XMLSignatureFactory.getInstance("DOM") + val c14n = fac.newTransform(CanonicalizationMethod.INCLUSIVE, null as TransformParameterSpec?) + val ref: Reference = + fac.newReference( + "#xpointer(//*[@authenticate='true'])", + fac.newDigestMethod(DigestMethod.SHA256, null), + listOf(c14n), + null, + null + ) + val canon: CanonicalizationMethod = + fac.newCanonicalizationMethod(CanonicalizationMethod.INCLUSIVE, null as C14NMethodParameterSpec?) + val signatureMethod = fac.newSignatureMethod("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", null) + val si: SignedInfo = fac.newSignedInfo(canon, signatureMethod, listOf(ref)) + val sig: XMLSignature = fac.newXMLSignature(si, null) + val dsc = DOMSignContext(signingPriv, authSigNode) + dsc.defaultNamespacePrefix = "ds" + dsc.uriDereferencer = EbicsSigUriDereferencer() + dsc.setProperty("javax.xml.crypto.dsig.cacheReference", true) + sig.sign(dsc) + val innerSig = authSigNode.firstChild + while (innerSig.hasChildNodes()) { + authSigNode.appendChild(innerSig.firstChild) + } + authSigNode.removeChild(innerSig) + } + + fun verifyEbicsDocument( + doc: Document, + signingPub: PublicKey, + schema: String + ): Boolean { + val doc2: Document = doc.cloneNode(true) as Document + val authSigNode = XPathFactory.newInstance().newXPath() + .evaluate("/*[1]/urn:org:ebics:$schema:AuthSignature", doc2, XPathConstants.NODE) + if (authSigNode !is Node) + throw java.lang.Exception("no AuthSignature") + val sigEl = doc2.createElementNS("http://www.w3.org/2000/09/xmldsig#", "ds:Signature") + authSigNode.parentNode.insertBefore(sigEl, authSigNode) + while (authSigNode.hasChildNodes()) { + sigEl.appendChild(authSigNode.firstChild) + } + authSigNode.parentNode.removeChild(authSigNode) + val fac = XMLSignatureFactory.getInstance("DOM") + val dvc = DOMValidateContext(signingPub, sigEl) + dvc.setProperty("javax.xml.crypto.dsig.cacheReference", true) + dvc.uriDereferencer = EbicsSigUriDereferencer() + val sig = fac.unmarshalXMLSignature(dvc) + // FIXME: check that parameters are okay! + val valResult = sig.validate(dvc) + sig.signedInfo.references[0].validate(dvc) + return valResult + } +}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt new file mode 100644 index 00000000..cfb93782 --- /dev/null +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt @@ -0,0 +1,204 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2020, 2024 Taler Systems S.A. + * + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + * + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.nexus + +import org.w3c.dom.* +import java.io.InputStream +import java.io.StringWriter +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import javax.xml.parsers.* +import javax.xml.stream.XMLOutputFactory +import javax.xml.stream.XMLStreamWriter + +interface XmlBuilder { + fun el(path: String, lambda: XmlBuilder.() -> Unit = {}) + fun el(path: String, content: String) { + el(path) { + text(content) + } + } + fun attr(namespace: String, name: String, value: String) + fun attr(name: String, value: String) + fun text(content: String) + + companion object { + fun toBytes(root: String, f: XmlBuilder.() -> Unit): ByteArray { + val factory = XMLOutputFactory.newFactory() + val stream = StringWriter() + var writer = factory.createXMLStreamWriter(stream) + /** + * NOTE: commenting out because it wasn't obvious how to output the + * "standalone = 'yes' directive". Manual forge was therefore preferred. + */ + stream.write("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>") + XmlStreamBuilder(writer).el(root) { + this.f() + } + writer.writeEndDocument() + return stream.buffer.toString().toByteArray() + } + + fun toDom(root: String, schema: String?, f: XmlBuilder.() -> Unit): Document { + val factory = DocumentBuilderFactory.newInstance(); + factory.isNamespaceAware = true + val builder = factory.newDocumentBuilder(); + val doc = builder.newDocument(); + doc.setXmlVersion("1.0") + doc.setXmlStandalone(true) + val root = doc.createElementNS(schema, root) + doc.appendChild(root); + XmlDOMBuilder(doc, schema, root).f() + doc.normalize() + return doc + } + } +} + +private class XmlStreamBuilder(private val w: XMLStreamWriter): XmlBuilder { + override fun el(path: String, lambda: XmlBuilder.() -> Unit) { + path.splitToSequence('/').forEach { + w.writeStartElement(it) + } + lambda() + path.splitToSequence('/').forEach { + w.writeEndElement() + } + } + + override fun attr(namespace: String, name: String, value: String) { + w.writeAttribute(namespace, name, value) + } + + override fun attr(name: String, value: String) { + w.writeAttribute(name, value) + } + + override fun text(content: String) { + w.writeCharacters(content) + } +} + +private class XmlDOMBuilder(private val doc: Document, private val schema: String?, private var node: Element): XmlBuilder { + override fun el(path: String, lambda: XmlBuilder.() -> Unit) { + val current = node + path.splitToSequence('/').forEach { + val new = doc.createElementNS(schema, it) + node.appendChild(new) + node = new + } + lambda() + node = current + } + + override fun attr(namespace: String, name: String, value: String) { + node.setAttributeNS(namespace, name, value) + } + + override fun attr(name: String, value: String) { + node.setAttribute(name, value) + } + + override fun text(content: String) { + node.appendChild(doc.createTextNode(content)); + } +} + +class DestructionError(m: String) : Exception(m) + +private fun Element.childrenByTag(tag: String): Sequence<Element> = sequence { + for (i in 0..childNodes.length) { + val el = childNodes.item(i) + if (el !is Element) { + continue + } + if (el.localName != tag) { + continue + } + yield(el) + } +} + +class XmlDestructor internal constructor(private val el: Element) { + fun each(path: String, f: XmlDestructor.() -> Unit) { + el.childrenByTag(path).forEach { + f(XmlDestructor(it)) + } + } + + fun <T> map(path: String, f: XmlDestructor.() -> T): List<T> { + return el.childrenByTag(path).map { + f(XmlDestructor(it)) + }.toList() + } + + fun one(path: String): XmlDestructor { + val children = el.childrenByTag(path).iterator() + if (!children.hasNext()) { + throw DestructionError("expected a single $path child, got none instead at $el") + } + val el = children.next() + if (children.hasNext()) { + throw DestructionError("expected a single $path child, got ${children.asSequence() + 1} instead at $el") + } + return XmlDestructor(el) + } + fun opt(path: String): XmlDestructor? { + val children = el.childrenByTag(path).iterator() + if (!children.hasNext()) { + return null + } + val el = children.next() + if (children.hasNext()) { + throw DestructionError("expected an optional $path child, got ${children.asSequence().count() + 1} instead at $el") + } + return XmlDestructor(el) + } + + fun <T> one(path: String, f: XmlDestructor.() -> T): T = f(one(path)) + fun <T> opt(path: String, f: XmlDestructor.() -> T): T? = opt(path)?.run(f) + + fun text(): String = el.textContent + fun bool(): Boolean = el.textContent.toBoolean() + fun date(): LocalDate = LocalDate.parse(text(), DateTimeFormatter.ISO_DATE) + fun dateTime(): LocalDateTime = LocalDateTime.parse(text(), DateTimeFormatter.ISO_DATE_TIME) + inline fun <reified T : Enum<T>> enum(): T = java.lang.Enum.valueOf(T::class.java, text()) + + fun attr(index: String): String = el.getAttribute(index) + + companion object { + fun <T> fromStream(xml: InputStream, root: String, f: XmlDestructor.() -> T): T { + val doc = XMLUtil.parseIntoDom(xml) + return fromDoc(doc, root, f) + } + + fun <T> fromDoc(doc: Document, root: String, f: XmlDestructor.() -> T): T { + if (doc.documentElement.tagName != root) { + throw DestructionError("expected root '$root' got '${doc.documentElement.tagName}'") + } + val destr = XmlDestructor(doc.documentElement) + return f(destr) + } + } +} + +fun <T> destructXml(xml: InputStream, root: String, f: XmlDestructor.() -> T): T + = XmlDestructor.fromStream(xml, root, f) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt deleted file mode 100644 index dd2cd67b..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt +++ /dev/null @@ -1,379 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. - - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - -/** - * This file contains helpers to construct EBICS 2.x requests. - */ - -package tech.libeufin.nexus.ebics - -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import tech.libeufin.ebics.* -import tech.libeufin.ebics.ebics_h004.EbicsKeyManagementResponse -import tech.libeufin.ebics.ebics_h004.EbicsNpkdRequest -import tech.libeufin.ebics.ebics_h004.EbicsRequest -import tech.libeufin.ebics.ebics_h004.EbicsUnsecuredRequest -import tech.libeufin.nexus.BankPublicKeysFile -import tech.libeufin.nexus.ClientPrivateKeysFile -import tech.libeufin.nexus.EbicsSetupConfig -import java.io.InputStream -import java.security.interfaces.RSAPrivateCrtKey -import java.time.Instant -import java.time.ZoneId -import java.util.* -import javax.xml.datatype.DatatypeFactory - -private val logger: Logger = LoggerFactory.getLogger("libeufin-nexus-ebics2") - -/** - * 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() -): ByteArray { - 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.convertDomToBytes(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. - * @param success was the download successfully processed - * @return receipt request in XML. - */ -fun createEbics25DownloadReceiptPhase( - cfg: EbicsSetupConfig, - clientKeys: ClientPrivateKeysFile, - transactionId: String, - success: Boolean -): ByteArray { - val req = EbicsRequest.createForDownloadReceiptPhase( - transactionId, - cfg.ebicsHostId, - success - ) - val doc = XMLUtil.convertJaxbToDocument(req) - XMLUtil.signEbicsDocument( - doc, - clientKeys.authentication_private_key, - withEbics3 = false - ) - return XMLUtil.convertDomToBytes(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 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 createEbics25DownloadTransferPhase( - cfg: EbicsSetupConfig, - clientKeys: ClientPrivateKeysFile, - segNumber: Int, - totalSegments: Int, - transactionId: String -): ByteArray { - 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.convertDomToBytes(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: InputStream -): EbicsKeyManagementResponseContent? { - // TODO throw instead of null - val jaxb = try { - XMLUtil.convertToJaxb<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) - ).readBytes() - } - } - 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): ByteArray { - val iniRequest = EbicsUnsecuredRequest.createIni( - cfg.ebicsHostId, - cfg.ebicsUserId, - cfg.ebicsPartnerId, - clientKeys.signature_private_key - ) - val doc = XMLUtil.convertJaxbToDocument(iniRequest) - return XMLUtil.convertDomToBytes(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): ByteArray { - 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.convertDomToBytes(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): ByteArray { - 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.convertDomToBytes(doc) -} - -/** - * Collects message type and date range of an EBICS 2 request. - */ -data class Ebics2Request( - val messageType: String, - val orderParams: EbicsOrderParams -) - -/** - * Prepares an EBICS 2 request to get pain.002 acknowledgements - * about submitted pain.001 documents. - * - * @param startDate earliest timestamp of the returned document(s). If - * null, it defaults to download the unseen documents. - * @param endDate latest timestamp of the returned document(s). If - * null, it defaults to the current time. - * @return [Ebics2Request] object to be first converted in XML and - * then be passed to the EBICS downloader. - */ -private fun prepAckRequest2( - startDate: Instant? = null, - endDate: Instant? = null -): Ebics2Request { - val maybeDateRange = if (startDate != null) EbicsDateRange(startDate, endDate ?: Instant.now()) else null - return Ebics2Request( - messageType = "Z01", - orderParams = EbicsStandardOrderParams(dateRange = maybeDateRange) - ) -} - -/** - * Prepares an EBICS 2 request to get intraday camt.052 reports. - * - * @param startDate earliest timestamp of the returned document(s). If - * null, it defaults to download the unseen documents. - * @param endDate latest timestamp of the returned document(s). If - * null, it defaults to the current time. - * @return [Ebics2Request] object to be first converted in XML and - * then be passed to the EBICS downloader. - */ -private fun prepReportRequest2( - startDate: Instant? = null, - endDate: Instant? = null -): Ebics2Request { - val maybeDateRange = if (startDate != null) EbicsDateRange(startDate, endDate ?: Instant.now()) else null - return Ebics2Request( - messageType = "Z52", - orderParams = EbicsStandardOrderParams(dateRange = maybeDateRange) - ) -} - -/** - * Prepares an EBICS 2 request to get daily camt.053 statements. - * - * @param startDate earliest timestamp of the returned document(s). If - * null, it defaults to download the unseen documents. - * @param endDate latest timestamp of the returned document(s). If - * null, it defaults to the current time. - * @return [Ebics2Request] object to be first converted in XML and - * then be passed to the EBICS downloader. - */ -private fun prepStatementRequest2( - startDate: Instant? = null, - endDate: Instant? = null -): Ebics2Request { - val maybeDateRange = if (startDate != null) EbicsDateRange(startDate, endDate ?: Instant.now()) else null - return Ebics2Request( - messageType = "Z53", - orderParams = EbicsStandardOrderParams(dateRange = maybeDateRange) - ) -} - -/** - * Prepares an EBICS 2 request to get camt.054 notifications. - * - * @param startDate earliest timestamp of the returned document(s). If - * null, it defaults to download the unseen documents. - * @param endDate latest timestamp of the returned document(s). If - * null, it defaults to the current time. - * @return [Ebics2Request] object to be first converted in XML and - * then be passed to the EBICS downloader. - */ -private fun prepNotificationRequest2( - startDate: Instant? = null, - endDate: Instant? = null -): Ebics2Request { - val maybeDateRange = if (startDate != null) EbicsDateRange(startDate, endDate ?: Instant.now()) else null - return Ebics2Request( - messageType = "Z54", // ZS2 is the non-appendix type - orderParams = EbicsStandardOrderParams(dateRange = maybeDateRange) - ) -} - -/** - * Prepares an EBICS 2 request to get logs from the bank about any - * uploaded or downloaded document. - * - * @param startDate earliest timestamp of the returned document(s). If - * null, it defaults to download the unseen documents. - * @param endDate latest timestamp of the returned document(s). If - * null, it defaults to the current time. - * @return [Ebics2Request] object to be first converted in XML and - * then be passed to the EBICS downloader. - */ -private fun prepLogsRequest2( - startDate: Instant? = null, - endDate: Instant? = null -): Ebics2Request { - val maybeDateRange = if (startDate != null) EbicsDateRange(startDate, endDate ?: Instant.now()) else null - return Ebics2Request( - messageType = "HAC", - orderParams = EbicsStandardOrderParams(dateRange = maybeDateRange) - ) -} - -/** - * Abstracts EBICS 2 request creation of a download init phase. - * - * @param whichDoc type of wanted document. - * @param startDate earliest timestamp of the document(s) to download. - * If null, it gets the unseen documents. If defined, - * the latest timestamp defaults to the current time. - * @return [Ebics2Request] to be converted to XML string and passed to - * the EBICS downloader. - */ -fun prepEbics2Document( - whichDoc: SupportedDocument, - startDate: Instant? = null -): Ebics2Request = - when(whichDoc) { - SupportedDocument.PAIN_002 -> prepAckRequest2(startDate) - SupportedDocument.CAMT_052 -> prepReportRequest2(startDate) - SupportedDocument.CAMT_053 -> prepStatementRequest2(startDate) - SupportedDocument.CAMT_054 -> prepNotificationRequest2(startDate) - SupportedDocument.PAIN_002_LOGS -> prepLogsRequest2(startDate) - }
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt deleted file mode 100644 index 09aac3df..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt +++ /dev/null @@ -1,443 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. - - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ -package tech.libeufin.nexus.ebics - -import io.ktor.client.* -import tech.libeufin.ebics.PreparedUploadData -import tech.libeufin.ebics.XMLUtil -import tech.libeufin.ebics.ebics_h005.Ebics3Request -import tech.libeufin.ebics.getNonce -import tech.libeufin.ebics.getXmlDate -import tech.libeufin.nexus.BankPublicKeysFile -import tech.libeufin.nexus.ClientPrivateKeysFile -import tech.libeufin.nexus.EbicsSetupConfig -import tech.libeufin.nexus.logger -import java.math.BigInteger -import java.time.Instant -import java.util.* -import javax.xml.datatype.DatatypeFactory - -/** - * Crafts an EBICS request for the receipt phase of a download - * transaction. - * - * @param cfg config handle - * @param clientKeys subscriber private keys. - * @param transactionId EBICS transaction ID as assigned by the - * bank to any successful transaction. - * @param success was the download successfully processed - * @return the raw XML of the EBICS request. - */ -fun createEbics3DownloadReceiptPhase( - cfg: EbicsSetupConfig, - clientKeys: ClientPrivateKeysFile, - transactionId: String, - success: Boolean -): ByteArray { - val req = Ebics3Request.createForDownloadReceiptPhase( - transactionId, - cfg.ebicsHostId, - success - ) - val doc = XMLUtil.convertJaxbToDocument(req) - XMLUtil.signEbicsDocument( - doc, - clientKeys.authentication_private_key, - withEbics3 = true - ) - return XMLUtil.convertDomToBytes(doc) -} - -/** - * Crafts an EBICS download request for the transfer phase. - * - * @param cfg config handle - * @param clientKeys subscriber private keys - * @param transactionId EBICS transaction ID. That came from the - * bank after the initialization phase ended successfully. - * @param segmentNumber which (payload's) segment number this requests wants. - * @param howManySegments total number of segments that the payload is split to. - * @return the raw XML EBICS request. - */ -fun createEbics3DownloadTransferPhase( - cfg: EbicsSetupConfig, - clientKeys: ClientPrivateKeysFile, - howManySegments: Int, - segmentNumber: Int, - transactionId: String -): ByteArray { - val req = Ebics3Request.createForDownloadTransferPhase( - cfg.ebicsHostId, - transactionId, - segmentNumber, - howManySegments - ) - val doc = XMLUtil.convertJaxbToDocument(req) - XMLUtil.signEbicsDocument( - doc, - clientKeys.authentication_private_key, - withEbics3 = true - ) - return XMLUtil.convertDomToBytes(doc) -} - -/** - * Creates the EBICS 3 document for the init phase of a download - * transaction. - * - * @param cfg configuration handle. - * @param bankkeys bank public keys. - * @param clientKeys client private keys. - * @param orderService EBICS 3 document defining the request type - */ -fun createEbics3DownloadInitialization( - cfg: EbicsSetupConfig, - bankkeys: BankPublicKeysFile, - clientKeys: ClientPrivateKeysFile, - orderParams: Ebics3Request.OrderDetails.BTDOrderParams -): ByteArray { - val nonce = getNonce(128) - val req = Ebics3Request.createForDownloadInitializationPhase( - cfg.ebicsUserId, - cfg.ebicsPartnerId, - cfg.ebicsHostId, - nonce, - DatatypeFactory.newInstance().newXMLGregorianCalendar(GregorianCalendar()), - bankAuthPub = bankkeys.bank_authentication_public_key, - bankEncPub = bankkeys.bank_encryption_public_key, - myOrderParams = orderParams - ) - val doc = XMLUtil.convertJaxbToDocument( - req, - withSchemaLocation = "urn:org:ebics:H005 ebics_request_H005.xsd" - ) - XMLUtil.signEbicsDocument( - doc, - clientKeys.authentication_private_key, - withEbics3 = true - ) - return XMLUtil.convertDomToBytes(doc) -} - -/** - * Creates the EBICS 3 document for the init phase of an upload - * transaction. - * - * @param cfg configuration handle. - * @param preparedUploadData business payload to send. - * @param bankkeys bank public keys. - * @param clientKeys client private keys. - * @param orderService EBICS 3 document defining the request type - * @return raw XML of the EBICS 3 init phase. - */ -fun createEbics3RequestForUploadInitialization( - cfg: EbicsSetupConfig, - preparedUploadData: PreparedUploadData, - bankkeys: BankPublicKeysFile, - clientKeys: ClientPrivateKeysFile, - orderService: Ebics3Request.OrderDetails.Service -): ByteArray { - val nonce = getNonce(128) - val req = Ebics3Request.createForUploadInitializationPhase( - preparedUploadData.transactionKey, - preparedUploadData.userSignatureDataEncrypted, - preparedUploadData.dataDigest, - cfg.ebicsHostId, - nonce, - cfg.ebicsPartnerId, - cfg.ebicsUserId, - DatatypeFactory.newInstance().newXMLGregorianCalendar(GregorianCalendar()), - bankkeys.bank_authentication_public_key, - bankkeys.bank_encryption_public_key, - BigInteger.ONE, - orderService - ) - val doc = XMLUtil.convertJaxbToDocument( - req, - withSchemaLocation = "urn:org:ebics:H005 ebics_request_H005.xsd" - ) - XMLUtil.signEbicsDocument( - doc, - clientKeys.authentication_private_key, - withEbics3 = true - ) - return XMLUtil.convertDomToBytes(doc) -} - -/** - * Crafts one EBICS 3 request for the upload transfer phase. Currently - * only 1-chunk payloads are supported. - * - * @param cfg configuration handle. - * @param clientKeys client private keys. - * @param transactionId EBICS transaction ID obtained from an init phase. - * @param uploadData business content to upload. - * - * @return raw XML document. - */ -fun createEbics3RequestForUploadTransferPhase( - cfg: EbicsSetupConfig, - clientKeys: ClientPrivateKeysFile, - transactionId: String, - uploadData: PreparedUploadData -): ByteArray { - val chunkIndex = 1 // only 1-chunk communication currently supported. - val req = Ebics3Request.createForUploadTransferPhase( - cfg.ebicsHostId, - transactionId, - BigInteger.valueOf(chunkIndex.toLong()), - uploadData.encryptedPayloadChunks[chunkIndex - 1] - ) - val doc = XMLUtil.convertJaxbToDocument(req) - XMLUtil.signEbicsDocument( - doc, - clientKeys.authentication_private_key, - withEbics3 = true - ) - return XMLUtil.convertDomToBytes(doc) -} - -/** - * Collects all the steps to prepare the submission of a pain.001 - * document to the bank, and finally send it. Indirectly throws - * [EbicsSideException] or [EbicsUploadException]. The first means - * that the bank sent an invalid response or signature, the second - * that a proper EBICS or business error took place. The caller must - * catch those exceptions and decide the retry policy. - * - * @param pain001xml pain.001 document in XML. The caller should - * ensure its validity. - * @param cfg configuration handle. - * @param clientKeys client private keys. - * @param bankkeys bank public keys. - * @param httpClient HTTP client to connect to the bank. - */ -suspend fun submitPain001( - pain001xml: String, - cfg: EbicsSetupConfig, - clientKeys: ClientPrivateKeysFile, - bankkeys: BankPublicKeysFile, - httpClient: HttpClient -): String { - val orderService: Ebics3Request.OrderDetails.Service = Ebics3Request.OrderDetails.Service().apply { - serviceName = "MCT" - scope = "CH" - messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { - value = "pain.001" - version = "09" - } - } - val maybeUploaded = doEbicsUpload( - httpClient, - cfg, - clientKeys, - bankkeys, - orderService, - pain001xml.toByteArray(Charsets.UTF_8), - ) - logger.debug("Payment submitted, report text is: ${maybeUploaded.reportText}," + - " EBICS technical code is: ${maybeUploaded.technicalReturnCode}," + - " bank technical return code is: ${maybeUploaded.bankReturnCode}" - ) - return maybeUploaded.orderID!! -} - -/** - * Crafts a date range object, when the caller needs a time range. - * - * @param startDate inclusive starting date for the returned banking events. - * @param endDate inclusive ending date for the returned banking events. - * @return [Ebics3Request.DateRange] - */ -private fun getEbics3DateRange( - startDate: Instant, - endDate: Instant -): Ebics3Request.DateRange { - return Ebics3Request.DateRange().apply { - start = getXmlDate(startDate) - end = getXmlDate(endDate) - } -} - -/** - * Prepares the request for a camt.054 notification from the bank, - * via EBICS 3. - * Notifications inform the subscriber that some new events occurred - * on their account. One main difference with reports/statements is - * that notifications - according to the ISO20022 documentation - do - * NOT contain any balance. - * - * @param startDate inclusive starting date for the returned notification(s). - * @param endDate inclusive ending date for the returned notification(s). NOTE: - * if startDate is NOT null and endDate IS null, endDate gets defaulted - * to the current UTC time. - * @param isAppendix if true, the responded camt.054 will be an appendix of - * another camt.053 document, not therefore strictly acting as a notification. - * For example, camt.053 may omit wire transfer subjects and its related - * camt.054 appendix would instead contain those. - * - * @return [Ebics3Request.OrderDetails.BTOrderParams] - */ -fun prepNotificationRequest3( - startDate: Instant? = null, - endDate: Instant? = null, - isAppendix: Boolean -): Ebics3Request.OrderDetails.BTDOrderParams { - val service = Ebics3Request.OrderDetails.Service().apply { - serviceName = "REP" - scope = "CH" - container = Ebics3Request.OrderDetails.Service.Container().apply { - containerType = "ZIP" - } - messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { - value = "camt.054" - version = "08" - } - if (!isAppendix) - serviceOption = "XDCI" - } - return Ebics3Request.OrderDetails.BTDOrderParams().apply { - this.service = service - this.dateRange = if (startDate != null) - getEbics3DateRange(startDate, endDate ?: Instant.now()) - else null - } -} - -/** - * Prepares the request for a pain.002 acknowledgement from the bank, via - * EBICS 3. - * - * @param startDate inclusive starting date for the returned acknowledgements. - * @param endDate inclusive ending date for the returned acknowledgements. NOTE: - * if startDate is NOT null and endDate IS null, endDate gets defaulted - * to the current UTC time. - * - * @return [Ebics3Request.OrderDetails.BTOrderParams] - */ -fun prepAckRequest3( - startDate: Instant? = null, - endDate: Instant? = null -): Ebics3Request.OrderDetails.BTDOrderParams { - val service = Ebics3Request.OrderDetails.Service().apply { - serviceName = "PSR" - scope = "CH" - container = Ebics3Request.OrderDetails.Service.Container().apply { - containerType = "ZIP" - } - messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { - value = "pain.002" - version = "10" - } - } - return Ebics3Request.OrderDetails.BTDOrderParams().apply { - this.service = service - this.dateRange = if (startDate != null) - getEbics3DateRange(startDate, endDate ?: Instant.now()) - else null - } -} - -/** - * Prepares the request for (a) camt.053/statement(s) via EBICS 3. - * - * @param startDate inclusive starting date for the returned banking events. - * @param endDate inclusive ending date for the returned banking events. NOTE: - * if startDate is NOT null and endDate IS null, endDate gets defaulted - * to the current UTC time. - * - * @return [Ebics3Request.OrderDetails.BTOrderParams] - */ -fun prepStatementRequest3( - startDate: Instant? = null, - endDate: Instant? = null -): Ebics3Request.OrderDetails.BTDOrderParams { - val service = Ebics3Request.OrderDetails.Service().apply { - serviceName = "EOP" - scope = "CH" - container = Ebics3Request.OrderDetails.Service.Container().apply { - containerType = "ZIP" - } - messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { - value = "camt.053" - version = "08" - } - } - return Ebics3Request.OrderDetails.BTDOrderParams().apply { - this.service = service - this.dateRange = if (startDate != null) - getEbics3DateRange(startDate, endDate ?: Instant.now()) - else null - } -} - -/** - * Prepares the request for camt.052/intraday records via EBICS 3. - * - * @param startDate inclusive starting date for the returned banking events. - * @param endDate inclusive ending date for the returned banking events. NOTE: - * if startDate is NOT null and endDate IS null, endDate gets defaulted - * to the current UTC time. - * - * @return [Ebics3Request.OrderDetails.BTOrderParams] - */ -fun prepReportRequest3( - startDate: Instant? = null, - endDate: Instant? = null -): Ebics3Request.OrderDetails.BTDOrderParams { - val service = Ebics3Request.OrderDetails.Service().apply { - serviceName = "STM" - scope = "CH" - container = Ebics3Request.OrderDetails.Service.Container().apply { - containerType = "ZIP" - } - messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { - value = "camt.052" - version = "08" - } - } - return Ebics3Request.OrderDetails.BTDOrderParams().apply { - this.service = service - this.dateRange = if (startDate != null) - getEbics3DateRange(startDate, endDate ?: Instant.now()) - else null - } -} - -/** - * Abstracts EBICS 3 request creation of a download init phase. - * - * @param whichDoc type of wanted document. - * @param startDate earliest timestamp of the document(s) to download. - * If null, it gets the unseen documents. If defined, - * the latest timestamp defaults to the current time. - * @return [Ebics2Request] to be converted to XML string and passed to - * the EBICS downloader. - */ -fun prepEbics3Document( - whichDoc: SupportedDocument, - startDate: Instant? = null -): Ebics3Request.OrderDetails.BTDOrderParams = - when(whichDoc) { - SupportedDocument.PAIN_002 -> prepAckRequest3(startDate) - SupportedDocument.CAMT_052 -> prepReportRequest3(startDate) - SupportedDocument.CAMT_053 -> prepStatementRequest3(startDate) - SupportedDocument.CAMT_054 -> prepNotificationRequest3(startDate, isAppendix = true) - SupportedDocument.PAIN_002_LOGS -> throw Exception("HAC (--only-logs) not available in EBICS 3") - }
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt new file mode 100644 index 00000000..356d4b96 --- /dev/null +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt @@ -0,0 +1,347 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ +package tech.libeufin.nexus.ebics + +import io.ktor.client.* +import tech.libeufin.nexus.* +import tech.libeufin.common.* +import tech.libeufin.common.crypto.* +import java.math.BigInteger +import java.time.* +import java.time.format.* +import java.util.* +import java.io.File +import org.w3c.dom.* +import javax.xml.datatype.XMLGregorianCalendar +import javax.xml.datatype.DatatypeFactory +import java.security.interfaces.* + + +fun Instant.xmlDate(): String = DateTimeFormatter.ISO_DATE.withZone(ZoneId.of("UTC")).format(this) +fun Instant.xmlDateTime(): String = DateTimeFormatter.ISO_OFFSET_DATE_TIME.withZone(ZoneId.of("UTC")).format(this) + +/** EBICS protocol for business transactions */ +class EbicsBTS( + val cfg: EbicsSetupConfig, + val bankKeys: BankPublicKeysFile, + val clientKeys: ClientPrivateKeysFile, + val order: EbicsOrder +) { + /* ----- Download ----- */ + + fun downloadInitialization(startDate: Instant?, endDate: Instant?): ByteArray { + val nonce = getNonce(128) + return signedRequest { + el("header") { + attr("authenticate", "true") + el("static") { + el("HostID", cfg.ebicsHostId) + el("Nonce", nonce.encodeHex()) + el("Timestamp", Instant.now().xmlDateTime()) + el("PartnerID", cfg.ebicsPartnerId) + el("UserID", cfg.ebicsUserId) + // SystemID + // Product + el("OrderDetails") { + when (order) { + is EbicsOrder.V2_5 -> { + el("OrderType", order.type) + el("OrderAttribute", order.attribute) + el("StandardOrderParams") { + if (startDate != null) { + el("DateRange") { + el("Start", startDate.xmlDate()) + el("End", (endDate ?: Instant.now()).xmlDate()) + } + } + } + } + is EbicsOrder.V3 -> { + el("AdminOrderType", order.type) + if (order.type == "BTD") { + el("BTDOrderParams") { + el("Service") { + el("ServiceName", order.name!!) + el("Scope", order.scope!!) + if (order.container != null) { + el("Container") { + attr("containerType", order.container) + } + } + el("MsgName") { + attr("version", order.messageVersion!!) + text(order.messageName!!) + } + } + if (startDate != null) { + el("DateRange") { + el("Start", startDate.xmlDate()) + el("End", (endDate ?: Instant.now()).xmlDate()) + } + } + } + } + } + } + } + bankDigest() + } + el("mutable/TransactionPhase", "Initialisation") + } + el("AuthSignature") + el("body") + } + } + + fun downloadTransfer( + howManySegments: Int, + segmentNumber: Int, + transactionId: String + ): ByteArray { + return signedRequest { + el("header") { + attr("authenticate", "true") + el("static") { + el("HostID", cfg.ebicsHostId) + el("TransactionID", transactionId) + } + el("mutable") { + el("TransactionPhase", "Transfer") + el("SegmentNumber") { + attr("lastSegment", if (howManySegments == segmentNumber) "true" else "false") + } + } + } + el("AuthSignature") + el("body") + } + } + + fun downloadReceipt( + transactionId: String, + success: Boolean + ): ByteArray { + return signedRequest { + el("header") { + attr("authenticate", "true") + el("static") { + el("HostID", cfg.ebicsHostId) + el("TransactionID", transactionId) + } + el("mutable") { + el("TransactionPhase", "Receipt") + } + } + el("AuthSignature") + el("body/TransferReceipt") { + attr("authenticate", "true") + el("ReceiptCode", if (success) "0" else "1") + } + } + } + + /* ----- Upload ----- */ + + fun uploadInitialization(preparedUploadData: PreparedUploadData): ByteArray { + val nonce = getNonce(128) + return signedRequest { + el("header") { + attr("authenticate", "true") + el("static") { + el("HostID", cfg.ebicsHostId) + el("Nonce", nonce.encodeUpHex()) + el("Timestamp", Instant.now().xmlDateTime()) + el("PartnerID", cfg.ebicsPartnerId) + el("UserID", cfg.ebicsUserId) + // SystemID + // Product + el("OrderDetails") { + when (order) { + is EbicsOrder.V2_5 -> { + // TODO + } + is EbicsOrder.V3 -> { + el("AdminOrderType", order.type) + el("BTUOrderParams") { + el("Service") { + el("ServiceName", order.name!!) + el("Scope", order.scope!!) + el("MsgName") { + attr("version", order.messageVersion!!) + text(order.messageName!!) + } + } + el("SignatureFlag", "true") + } + } + } + } + bankDigest() + el("NumSegments", "1") // TODO test upload of many segment + + } + el("mutable") { + el("TransactionPhase", "Initialisation") + } + } + el("AuthSignature") + el("body") { + el("DataTransfer") { + el("DataEncryptionInfo") { + attr("authenticate", "true") + el("EncryptionPubKeyDigest") { + attr("Version", "E002") + attr("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256") + text(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).encodeBase64()) + } + el("TransactionKey", preparedUploadData.transactionKey.encodeBase64()) + } + el("SignatureData") { + attr("authenticate", "true") + text(preparedUploadData.userSignatureDataEncrypted.encodeBase64()) + } + el("DataDigest") { + attr("SignatureVersion", "A006") + text(preparedUploadData.dataDigest.encodeBase64()) + } + } + } + } + } + + fun uploadTransfer( + transactionId: String, + uploadData: PreparedUploadData + ): ByteArray { + val chunkIndex = 1 // TODO test upload of many segment + return signedRequest { + el("header") { + attr("authenticate", "true") + el("static") { + el("HostID", cfg.ebicsHostId) + el("TransactionID", transactionId) + } + el("mutable") { + el("TransactionPhase", "Transfer") + el("SegmentNumber") { + attr("lastSegment", "true") + text(chunkIndex.toString()) + } + } + } + el("AuthSignature") + el("body/DataTransfer/OrderData", uploadData.encryptedPayloadChunks[chunkIndex - 1]) + } + } + + /* ----- Helpers ----- */ + + /** Generate a signed ebicsRequest */ + private fun signedRequest(lambda: XmlBuilder.() -> Unit): ByteArray { + val doc = XmlBuilder.toDom("ebicsRequest", "urn:org:ebics:${order.schema}") { + attr("http://www.w3.org/2000/xmlns/", "xmlns", "urn:org:ebics:${order.schema}") + attr("http://www.w3.org/2000/xmlns/", "xmlns:ds", "http://www.w3.org/2000/09/xmldsig#") + attr("Version", order.schema) + attr("Revision", "1") + lambda() + } + XMLUtil.signEbicsDocument( + doc, + clientKeys.authentication_private_key, + order.schema + ) + return XMLUtil.convertDomToBytes(doc) + } + + private fun XmlBuilder.bankDigest() { + el("BankPubKeyDigests") { + el("Authentication") { + attr("Version", "X002") + attr("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256") + text(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key).encodeBase64()) + } + el("Encryption") { + attr("Version", "E002") + attr("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256") + text(CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).encodeBase64()) + } + // Signature + } + el("SecurityMedium", "0000") + } + + companion object { + fun parseResponse(doc: Document): EbicsResponse<BTSResponse> { + return XmlDestructor.fromDoc(doc, "ebicsResponse") { + var transactionID: String? = null + var numSegments: Int? = null + lateinit var technicalCode: EbicsReturnCode + lateinit var bankCode: EbicsReturnCode + var orderID: String? = null + var segmentNumber: Int? = null + var payloadChunk: ByteArray? = null + var dataEncryptionInfo: DataEncryptionInfo? = null + one("header") { + one("static") { + transactionID = opt("TransactionID")?.text() + numSegments = opt("NumSegments")?.text()?.toInt() + } + one("mutable") { + segmentNumber = opt("SegmentNumber")?.text()?.toInt() + orderID = opt("OrderID")?.text() + technicalCode = EbicsReturnCode.lookup(one("ReturnCode").text()) + } + } + one("body") { + opt("DataTransfer") { + payloadChunk = one("OrderData").text().decodeBase64() + dataEncryptionInfo = opt("DataEncryptionInfo") { + DataEncryptionInfo( + one("TransactionKey").text().decodeBase64(), + one("EncryptionPubKeyDigest").text().decodeBase64() + ) + } + } + bankCode = EbicsReturnCode.lookup(one("ReturnCode").text()) + } + EbicsResponse( + bankCode = bankCode, + technicalCode = technicalCode, + content = BTSResponse( + transactionID = transactionID, + orderID = orderID, + payloadChunk = payloadChunk, + dataEncryptionInfo = dataEncryptionInfo, + numSegments = numSegments, + segmentNumber = segmentNumber + ) + ) + } + } + } +} + +data class BTSResponse( + val transactionID: String?, + val orderID: String?, + val dataEncryptionInfo: DataEncryptionInfo?, + val payloadChunk: ByteArray?, + val segmentNumber: Int?, + val numSegments: Int? +)
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt index c4231455..74c1dd32 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt @@ -32,11 +32,6 @@ 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.* @@ -45,8 +40,6 @@ import io.ktor.http.* import io.ktor.utils.io.jvm.javaio.* import tech.libeufin.common.* import tech.libeufin.common.crypto.* -import tech.libeufin.ebics.* -import tech.libeufin.ebics.ebics_h005.Ebics3Request import tech.libeufin.nexus.* import java.io.ByteArrayOutputStream import java.io.InputStream @@ -55,7 +48,11 @@ import java.security.interfaces.RSAPrivateCrtKey import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.* +import java.time.Instant import kotlinx.coroutines.* +import java.security.SecureRandom +import org.w3c.dom.Document +import org.xml.sax.SAXException /** * Available EBICS versions. @@ -82,15 +79,14 @@ enum class SupportedDocument { * 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. + * @return the plain payload. */ fun decryptAndDecompressPayload( clientEncryptionKey: RSAPrivateCrtKey, encryptionInfo: DataEncryptionInfo, - chunks: List<String> + chunks: List<ByteArray> ): InputStream = - SequenceInputStream(Collections.enumeration(chunks.map { it.toByteArray().inputStream() })) // Aggregate - .decodeBase64() + SequenceInputStream(Collections.enumeration(chunks.map { it.inputStream() })) // Aggregate .run { CryptoUtil.decryptEbicsE002( encryptionInfo.transactionKey, @@ -99,6 +95,13 @@ fun decryptAndDecompressPayload( ) }.inflate() +sealed class EbicsError(msg: String, cause: Throwable? = null): Exception(msg, cause) { + /** Http and network errors */ + class Transport(msg: String, cause: Throwable? = null): EbicsError(msg, cause) + /** EBICS protocol & XML format error */ + class Protocol(msg: String, cause: Throwable? = null): EbicsError(msg, cause) +} + /** * POSTs the EBICS message to the bank. * @@ -106,164 +109,49 @@ fun decryptAndDecompressPayload( * @param msg EBICS message as raw bytes. * @return the raw bank response. */ -suspend fun HttpClient.postToBank(bankUrl: String, msg: ByteArray): InputStream { - logger.debug("POSTing EBICS to '$bankUrl'") - val res = post(urlString = bankUrl) { - contentType(ContentType.Text.Xml) - setBody(msg) - } - if (res.status != HttpStatusCode.OK) { - throw Exception("Invalid response status: ${res.status}") - } - return res.bodyAsChannel().toInputStream() -} - -/** - * 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 += " " +suspend fun HttpClient.postToBank(bankUrl: String, msg: ByteArray, phase: String): Document { + val res = try { + post(urlString = bankUrl) { + contentType(ContentType.Text.Xml) + setBody(msg) } - 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)}")) + } catch (e: Exception) { + throw EbicsError.Transport("$phase: failed to contact bank", e) } - - fun writeSigLine(doc: Document) { - doc.add(Paragraph("Ort / Datum: ________________")) - doc.add(Paragraph("Firma / Name: ________________")) - doc.add(Paragraph("Unterschrift: ________________")) + + if (res.status != HttpStatusCode.OK) { + throw EbicsError.Transport("$phase: bank HTTP error: ${res.status}") } - - 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) + try { + return XMLUtil.parseIntoDom(res.bodyAsChannel().toInputStream()) + } catch (e: SAXException) { + throw EbicsError.Protocol("$phase: invalid XML bank reponse", e) + } catch (e: Exception) { + throw EbicsError.Transport("$phase: failed read bank response", e) } - 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 xmlReq raw EBICS request in XML. - * @param isEbics3 true in case the communication is EBICS 3, false - * @return [EbicsResponseContent] or throws [EbicsSideException] - */ -suspend fun postEbics( +suspend fun EbicsBTS.postBTS( client: HttpClient, - cfg: EbicsSetupConfig, - bankKeys: BankPublicKeysFile, xmlReq: ByteArray, - isEbics3: Boolean -): EbicsResponseContent { - val respXml = try { - client.postToBank(cfg.hostBaseUrl, xmlReq) - } catch (e: Exception) { - throw EbicsSideException( - "POSTing to ${cfg.hostBaseUrl} failed", - sideEc = EbicsSideError.HTTP_POST_FAILED, - e - ) - } - - // Parses the bank response from the raw XML and verifies - // the bank signature. - val doc = try { - XMLUtil.parseIntoDom(respXml) - } catch (e: Exception) { - throw EbicsSideException( - "Bank response apparently invalid", - sideEc = EbicsSideError.BANK_RESPONSE_IS_INVALID - ) - } + phase: String, +): EbicsResponse<BTSResponse> { + val doc = client.postToBank(cfg.hostBaseUrl, xmlReq, phase) if (!XMLUtil.verifyEbicsDocument( doc, bankKeys.bank_authentication_public_key, - isEbics3 + order.schema )) { - throw EbicsSideException( - "Bank signature did not verify", - sideEc = EbicsSideError.BANK_SIGNATURE_DIDNT_VERIFY - ) + throw EbicsError.Protocol("$phase: bank signature did not verify") + } + try { + return EbicsBTS.parseResponse(doc) + } catch (e: Exception) { + throw EbicsError.Protocol("$phase: invalid ebics response", e) } - if (isEbics3) - return ebics3toInternalRepr(doc) - return ebics25toInternalRepr(doc) } /** - * Checks that EBICS- and bank-technical return codes are both EBICS_OK. - * - * @param ebicsResponseContent valid response gotten from the bank. - * @return true only if both codes are EBICS_OK. - */ -private fun areCodesOk(ebicsResponseContent: EbicsResponseContent) = - ebicsResponseContent.technicalReturnCode == EbicsReturnCode.EBICS_OK && - ebicsResponseContent.bankReturnCode == EbicsReturnCode.EBICS_OK - -/** * Perform an EBICS download transaction. * * It conducts init -> transfer -> processing -> receipt phases. @@ -273,7 +161,6 @@ private fun areCodesOk(ebicsResponseContent: EbicsResponseContent) = * @param clientKeys client EBICS private keys. * @param bankKeys bank EBICS public keys. * @param reqXml raw EBICS XML request of the init phase. - * @param isEbics3 true for EBICS 3, false otherwise. * @param processing processing lambda receiving EBICS files as a byte stream if the transaction was not empty. * @return T if the transaction was successful. If the failure is at the EBICS * level EbicsSideException is thrown else ités the exception of the processing lambda. @@ -283,142 +170,98 @@ suspend fun ebicsDownload( cfg: EbicsSetupConfig, clientKeys: ClientPrivateKeysFile, bankKeys: BankPublicKeysFile, - reqXml: ByteArray, - isEbics3: Boolean, - processing: (InputStream) -> Unit + order: EbicsOrder, + startDate: Instant?, + endDate: Instant?, + processing: (InputStream) -> Unit, ) = coroutineScope { - val scope = this + val impl = EbicsBTS(cfg, bankKeys, clientKeys, order) + val parentScope = this + // We need to run the logic in a non-cancelable context because we need to send // a receipt for each open download transaction, otherwise we'll be stuck in an // error loop until the pending transaction timeout. // TODO find a way to cancel the pending transaction ? - withContext(NonCancellable) { - val initResp = postEbics(client, cfg, bankKeys, reqXml, isEbics3) - logger.debug("Download init phase done. EBICS- and bank-technical codes are: ${initResp.technicalReturnCode}, ${initResp.bankReturnCode}") - if (initResp.technicalReturnCode != EbicsReturnCode.EBICS_OK) { - throw Exception("Download init phase has EBICS-technical error: ${initResp.technicalReturnCode}") + withContext(NonCancellable) { + // Init phase + val initReq = impl.downloadInitialization(startDate, endDate) + val initResp = impl.postBTS(client, initReq, "Download init phase") + if (initResp.bankCode == EbicsReturnCode.EBICS_NO_DOWNLOAD_DATA_AVAILABLE) { + logger.debug("Download content is empty") + return@withContext } - if (initResp.bankReturnCode == EbicsReturnCode.EBICS_NO_DOWNLOAD_DATA_AVAILABLE) { - logger.debug("Download content is empty") - return@withContext - } else if (initResp.bankReturnCode != EbicsReturnCode.EBICS_OK) { - throw Exception("Download init phase has bank-technical error: ${initResp.bankReturnCode}") + val initContent = initResp.okOrFail("Download init phase") + val tId = requireNotNull(initContent.transactionID) { + "Download init phase: missing transaction ID" } - val tId = initResp.transactionID - ?: throw EbicsSideException( - "EBICS download init phase did not return a transaction ID, cannot do the transfer phase.", - sideEc = EbicsSideError.EBICS_UPLOAD_TRANSACTION_ID_MISSING - ) - logger.debug("EBICS download transaction passed the init phase, got ID: $tId") - val howManySegments = initResp.numSegments - if (howManySegments == null) { - throw Exception("Init response lacks the quantity of segments, failing.") + val howManySegments = requireNotNull(initContent.numSegments) { + "Download init phase: missing num segments" } - val ebicsChunks = mutableListOf<String>() - // Getting the chunk(s) - val firstDataChunk = initResp.orderDataEncChunk - ?: throw EbicsSideException( - "OrderData element not found, despite non empty payload, failing.", - sideEc = EbicsSideError.ORDER_DATA_ELEMENT_NOT_FOUND - ) - val dataEncryptionInfo = initResp.dataEncryptionInfo ?: run { - throw EbicsSideException( - "EncryptionInfo element not found, despite non empty payload, failing.", - sideEc = EbicsSideError.ENCRYPTION_INFO_ELEMENT_NOT_FOUND - ) + val firstDataChunk = requireNotNull(initContent.payloadChunk) { + "Download init phase: missing OrderData" } - ebicsChunks.add(firstDataChunk) - // proceed with the transfer phase. - for (x in 2 .. howManySegments) { - if (!scope.isActive) break - // request segment number x. - val transReq = if (isEbics3) - createEbics3DownloadTransferPhase(cfg, clientKeys, x, howManySegments, tId) - else createEbics25DownloadTransferPhase(cfg, clientKeys, x, howManySegments, tId) - - val transResp = postEbics(client, cfg, bankKeys, transReq, isEbics3) - if (!areCodesOk(transResp)) { - throw EbicsSideException( - "EBICS transfer segment #$x failed.", - sideEc = EbicsSideError.TRANSFER_SEGMENT_FAILED - ) + val dataEncryptionInfo = requireNotNull(initContent.dataEncryptionInfo) { + "Download init phase: missing EncryptionInfo" + } + + logger.debug("Download init phase for transaction '$tId'") + + /** Send download receipt */ + suspend fun receipt(success: Boolean) { + val xml = impl.downloadReceipt(tId, success) + impl.postBTS(client, xml, "Download receipt phase").okOrFail("Download receipt phase") + } + /** Throw if parent scope have been canceled */ + suspend fun checkCancellation() { + if (!parentScope.isActive) { + // First send a proper EBICS transaction failure + receipt(false) + // Send throw cancelation exception + throw CancellationException() } - val chunk = transResp.orderDataEncChunk - if (chunk == null) { - throw Exception("EBICS transfer phase lacks chunk #$x, failing.") + } + + // Transfer phase + val ebicsChunks = mutableListOf(firstDataChunk) + for (x in 2 .. howManySegments) { + checkCancellation() + val transReq = impl.downloadTransfer(x, howManySegments, tId) + val transResp = impl.postBTS(client, transReq, "Download transfer phase").okOrFail("Download transfer phase") + val chunk = requireNotNull(transResp.payloadChunk) { + "Download transfer phase: missing encrypted chunk" } ebicsChunks.add(chunk) } - suspend fun receipt(success: Boolean) { - val receiptXml = if (isEbics3) - createEbics3DownloadReceiptPhase(cfg, clientKeys, tId, success) - else createEbics25DownloadReceiptPhase(cfg, clientKeys, tId, success) - - // Sending the receipt to the bank. - postEbics( - client, - cfg, - bankKeys, - receiptXml, - isEbics3 - ) - } - if (scope.isActive) { - // all chunks gotten, shaping a meaningful response now. - val payloadBytes = decryptAndDecompressPayload( + + checkCancellation() + + // Decompress encrypted chunks + val payloadStream = try { + decryptAndDecompressPayload( clientKeys.encryption_private_key, dataEncryptionInfo, ebicsChunks ) - // Process payload - val res = runCatching { - processing(payloadBytes) - } - receipt(res.isSuccess) + } catch (e: Exception) { + throw EbicsError.Protocol("invalid chunks", e) + } - res.getOrThrow() - } else { - receipt(false) - throw CancellationException() + checkCancellation() + + // Run business logic + val res = runCatching { + processing(payloadStream) } + + // First send a proper EBICS transaction receipt + receipt(res.isSuccess) + // Then throw business logic exception if any + res.getOrThrow() } Unit } /** - * These errors affect an EBICS transaction regardless - * of the standard error codes. - */ -enum class EbicsSideError { - BANK_SIGNATURE_DIDNT_VERIFY, - BANK_RESPONSE_IS_INVALID, - ENCRYPTION_INFO_ELEMENT_NOT_FOUND, - ORDER_DATA_ELEMENT_NOT_FOUND, - TRANSFER_SEGMENT_FAILED, - /** - * This might indicate that the EBICS transaction had errors. - */ - EBICS_UPLOAD_TRANSACTION_ID_MISSING, - /** - * May be caused by a connection issue OR the HTTP response - * code was not 200 OK. Both cases should lead to retry as - * they are fixable or transient. - */ - HTTP_POST_FAILED -} - -/** - * Those errors happen before getting to validate the bank response - * and successfully verify its signature. They bring therefore NO - * business meaning and may be retried. - */ -class EbicsSideException( - msg: String, - val sideEc: EbicsSideError, - cause: Exception? = null -) : Exception(msg, cause) - -/** * Signs and the encrypts the data to send via EBICS. * * @param cfg configuration handle. @@ -433,43 +276,32 @@ fun prepareUploadPayload( clientKeys: ClientPrivateKeysFile, bankKeys: BankPublicKeysFile, payload: ByteArray, - isEbics3: Boolean ): PreparedUploadData { - val encryptionResult: CryptoUtil.EncryptionResult = if (isEbics3) { - val innerSignedEbicsXml = signOrderEbics3( // A006 signature. - payload, - clientKeys.signature_private_key, - cfg.ebicsPartnerId, - cfg.ebicsUserId - ) - val userSignatureDataEncrypted = CryptoUtil.encryptEbicsE002( - EbicsOrderUtil.encodeOrderDataXml(innerSignedEbicsXml), - bankKeys.bank_encryption_public_key - ) - userSignatureDataEncrypted - } else { - val innerSignedEbicsXml = signOrder( // A006 signature. - payload, - clientKeys.signature_private_key, - cfg.ebicsPartnerId, - cfg.ebicsUserId - ) - val userSignatureDataEncrypted = CryptoUtil.encryptEbicsE002( - EbicsOrderUtil.encodeOrderDataXml(innerSignedEbicsXml), - bankKeys.bank_encryption_public_key - ) - userSignatureDataEncrypted + val innerSignedEbicsXml = XmlBuilder.toBytes("UserSignatureData") { + attr("xmlns", "http://www.ebics.org/S002") + el("OrderSignatureData") { + el("SignatureVersion", "A006") + el("SignatureValue", CryptoUtil.signEbicsA006( + CryptoUtil.digestEbicsOrderA006(payload), + clientKeys.signature_private_key, + ).encodeBase64()) + el("PartnerID", cfg.ebicsPartnerId) + el("UserID", cfg.ebicsUserId) + } } - val plainTransactionKey = encryptionResult.plainTransactionKey - ?: throw Exception("Could not generate the transaction key, cannot encrypt the payload!") + val encryptionResult = CryptoUtil.encryptEbicsE002( + innerSignedEbicsXml.inputStream().deflate(), + bankKeys.bank_encryption_public_key + ) // Then only E002 symmetric (with ephemeral key) encrypt. - val compressedInnerPayload = payload.inputStream().deflate().readAllBytes() + val compressedInnerPayload = payload.inputStream().deflate() + // TODO stream val encryptedPayload = CryptoUtil.encryptEbicsE002withTransactionKey( compressedInnerPayload, bankKeys.bank_encryption_public_key, - plainTransactionKey + encryptionResult.plainTransactionKey ) - val encodedEncryptedPayload = Base64.getEncoder().encodeToString(encryptedPayload.encryptedData) + val encodedEncryptedPayload = encryptedPayload.encryptedData.encodeBase64() return PreparedUploadData( encryptionResult.encryptedTransactionKey, // ephemeral key @@ -480,32 +312,6 @@ fun prepareUploadPayload( } /** - * Possible states of an EBICS transaction. - */ -enum class EbicsPhase { - initialization, - transmission, - receipt -} - -/** - * Witnesses a failure in an EBICS communication. That - * implies that the bank response and its signature were - * both valid. - */ -class EbicsUploadException( - msg: String, - val phase: EbicsPhase, - val ebicsErrorCode: EbicsReturnCode, - /** - * If the error was EBICS-technical, then we might not - * even have interest on the business error code, therefore - * the value below may be null. - */ - val bankErrorCode: EbicsReturnCode? = null -) : Exception(msg) - -/** * Collects all the steps of an EBICS 3 upload transaction. * NOTE: this function could conveniently be reused for an EBICS 2.x * transaction, hence this function stays in this file. @@ -522,56 +328,122 @@ suspend fun doEbicsUpload( cfg: EbicsSetupConfig, clientKeys: ClientPrivateKeysFile, bankKeys: BankPublicKeysFile, - orderService: Ebics3Request.OrderDetails.Service, + order: EbicsOrder, payload: ByteArray, -): EbicsResponseContent = withContext(NonCancellable) { +): String = withContext(NonCancellable) { + val impl = EbicsBTS(cfg, bankKeys, clientKeys, order) // TODO use a lambda and pass the order detail there for atomicity ? - val preparedPayload = prepareUploadPayload(cfg, clientKeys, bankKeys, payload, isEbics3 = true) - val initXml = createEbics3RequestForUploadInitialization( - cfg, - preparedPayload, - bankKeys, - clientKeys, - orderService - ) - val initResp = postEbics( // may throw EbicsEarlyException - client, - cfg, - bankKeys, - initXml, - isEbics3 = true - ) - if (!areCodesOk(initResp)) throw EbicsUploadException( - "EBICS upload init failed", - phase = EbicsPhase.initialization, - ebicsErrorCode = initResp.technicalReturnCode, - bankErrorCode = initResp.bankReturnCode - ) - // Init phase OK, proceeding with the transfer phase. - val tId = initResp.transactionID - ?: throw EbicsSideException( - "EBICS upload init phase did not return a transaction ID, cannot do the transfer phase.", - sideEc = EbicsSideError.EBICS_UPLOAD_TRANSACTION_ID_MISSING - ) - val transferXml = createEbics3RequestForUploadTransferPhase( - cfg, - clientKeys, - tId, - preparedPayload - ) - val transferResp = postEbics( - client, - cfg, - bankKeys, - transferXml, - isEbics3 = true - ) - if (!areCodesOk(transferResp)) throw EbicsUploadException( - "EBICS upload transfer failed", - phase = EbicsPhase.transmission, - ebicsErrorCode = initResp.technicalReturnCode, - bankErrorCode = initResp.bankReturnCode - ) - // EBICS- and bank-technical codes were both EBICS_OK, success! - transferResp + val preparedPayload = prepareUploadPayload(cfg, clientKeys, bankKeys, payload) + + // Init phase + val initXml = impl.uploadInitialization(preparedPayload) + val initResp = impl.postBTS(client, initXml, "Upload init phase").okOrFail("Upload init phase") + val tId = requireNotNull(initResp.transactionID) { + "Upload init phase: missing transaction ID" + } + + // Transfer phase + val transferXml = impl.uploadTransfer(tId, preparedPayload) + val transferResp = impl.postBTS(client, transferXml, "Upload transfer phase").okOrFail("Upload transfer phase") + val orderId = requireNotNull(transferResp.orderID) { + "Upload transfer phase: missing order ID" + } + orderId +} + +/** + * @param size in bits + */ +fun getNonce(size: Int): ByteArray { + val sr = SecureRandom() + val ret = ByteArray(size / 8) + sr.nextBytes(ret) + return ret +} + +class PreparedUploadData( + val transactionKey: ByteArray, + val userSignatureDataEncrypted: ByteArray, + val dataDigest: ByteArray, + val encryptedPayloadChunks: List<String> +) + +class DataEncryptionInfo( + val transactionKey: ByteArray, + val bankPubDigest: ByteArray +) + +class EbicsResponse<T>( + val technicalCode: EbicsReturnCode, + val bankCode: EbicsReturnCode, + private val content: T +) { + /** Checks that return codes are both EBICS_OK or throw an exception */ + fun okOrFail(phase: String): T { + logger.debug("$phase return codes: $technicalCode & $bankCode") + require(technicalCode.kind() != EbicsReturnCode.Kind.Error) { + "$phase has technical error: $technicalCode" + } + require(bankCode.kind() != EbicsReturnCode.Kind.Error) { + "$phase has bank error: $bankCode" + } + return content + } +} + +// TODO import missing using a script +@Suppress("SpellCheckingInspection") +enum class EbicsReturnCode(val code: String) { + EBICS_OK("000000"), + EBICS_DOWNLOAD_POSTPROCESS_DONE("011000"), + EBICS_DOWNLOAD_POSTPROCESS_SKIPPED("011001"), + EBICS_TX_SEGMENT_NUMBER_UNDERRUN("011101"), + EBICS_AUTHENTICATION_FAILED("061001"), + EBICS_INVALID_REQUEST("061002"), + EBICS_INTERNAL_ERROR("061099"), + EBICS_TX_RECOVERY_SYNC("061101"), + EBICS_AUTHORISATION_ORDER_IDENTIFIER_FAILED("090003"), + EBICS_INVALID_ORDER_DATA_FORMAT("090004"), + EBICS_NO_DOWNLOAD_DATA_AVAILABLE("090005"), + EBICS_INVALID_USER_OR_USER_STATE("091002"), + EBICS_USER_UNKNOWN("091003"), + EBICS_INVALID_USER_STATE("091004"), + EBICS_INVALID_ORDER_IDENTIFIER("091005"), + EBICS_UNSUPPORTED_ORDER_TYPE("091006"), + EBICS_INVALID_XML("091010"), + EBICS_TX_MESSAGE_REPLAY("091103"), + EBICS_INVALID_REQUEST_CONTENT("091113"), + EBICS_PROCESSING_ERROR("091116"), + EBICS_ACCOUNT_AUTHORISATION_FAILED("091302"), + EBICS_AMOUNT_CHECK_FAILED("091303"); + + enum class Kind { + Information, + Note, + Warning, + Error + } + + fun kind(): Kind { + return when (val errorClass = code.substring(0..1)) { + "00" -> Kind.Information + "01" -> Kind.Note + "03" -> Kind.Warning + "06", "09" -> Kind.Error + else -> throw Exception("Unknown EBICS status code error class: $errorClass") + } + } + + companion object { + fun lookup(code: String): EbicsReturnCode { + for (x in entries) { + if (x.code == code) { + return x + } + } + throw Exception( + "Unknown EBICS status code: $code" + ) + } + } }
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsKeyMng.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsKeyMng.kt new file mode 100644 index 00000000..a6d965e5 --- /dev/null +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsKeyMng.kt @@ -0,0 +1,210 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.nexus.ebics + +import org.w3c.dom.Document +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.common.* +import tech.libeufin.nexus.* +import tech.libeufin.nexus.BankPublicKeysFile +import tech.libeufin.nexus.ClientPrivateKeysFile +import tech.libeufin.nexus.EbicsSetupConfig +import java.io.InputStream +import java.time.Instant +import java.time.ZoneId +import java.util.* +import javax.xml.datatype.DatatypeFactory +import java.security.interfaces.* + +/** EBICS protocol for key management */ +class Ebics3KeyMng( + private val cfg: EbicsSetupConfig, + private val clientKeys: ClientPrivateKeysFile +) { + fun INI(): ByteArray { + val inner = XMLOrderData(cfg, "ns2:SignaturePubKeyOrderData", "http://www.ebics.org/S001") { + el("ns2:SignaturePubKeyInfo") { + RSAKeyXml(clientKeys.signature_private_key) + el("ns2:SignatureVersion", "A006") + } + } + val doc = request("ebicsUnsecuredRequest") { + el("header") { + attr("authenticate", "true") + el("static") { + el("HostID", cfg.ebicsHostId) + el("PartnerID", cfg.ebicsPartnerId) + el("UserID", cfg.ebicsUserId) + el("OrderDetails") { + el("OrderType", "INI") + el("OrderAttribute", "DZNNN") + } + el("SecurityMedium", "0200") + } + el("mutable") + } + el("body/DataTransfer/OrderData", inner) + } + return XMLUtil.convertDomToBytes(doc) + } + + fun HIA(): ByteArray { + val inner = XMLOrderData(cfg, "ns2:HIARequestOrderData", "urn:org:ebics:H004") { + el("ns2:AuthenticationPubKeyInfo") { + RSAKeyXml(clientKeys.authentication_private_key) + el("ns2:AuthenticationVersion", "X002") + } + el("ns2:EncryptionPubKeyInfo") { + RSAKeyXml(clientKeys.encryption_private_key) + el("ns2:EncryptionVersion", "E002") + } + } + val doc = request("ebicsUnsecuredRequest") { + el("header") { + attr("authenticate", "true") + el("static") { + el("HostID", cfg.ebicsHostId) + el("PartnerID", cfg.ebicsPartnerId) + el("UserID", cfg.ebicsUserId) + el("OrderDetails") { + el("OrderType", "HIA") + el("OrderAttribute", "DZNNN") + } + el("SecurityMedium", "0200") + } + el("mutable") + } + el("body/DataTransfer/OrderData", inner) + } + return XMLUtil.convertDomToBytes(doc) + } + + fun HPB(): ByteArray { + val nonce = getNonce(128) + val doc = request("ebicsNoPubKeyDigestsRequest") { + el("header") { + attr("authenticate", "true") + el("static") { + el("HostID", cfg.ebicsHostId) + el("Nonce", nonce.encodeUpHex()) + el("Timestamp", Instant.now().xmlDateTime()) + el("PartnerID", cfg.ebicsPartnerId) + el("UserID", cfg.ebicsUserId) + el("OrderDetails") { + el("OrderType", "HPB") + el("OrderAttribute", "DZHNN") + } + el("SecurityMedium", "0000") + } + el("mutable") + } + el("AuthSignature") + el("body") + } + XMLUtil.signEbicsDocument(doc, clientKeys.authentication_private_key, "H004") + return XMLUtil.convertDomToBytes(doc) + } + + /* ----- Helpers ----- */ + + private fun request(name: String, build: XmlBuilder.() -> Unit): Document { + return XmlBuilder.toDom(name, "urn:org:ebics:H004") { + attr("http://www.w3.org/2000/xmlns/", "xmlns", "urn:org:ebics:H004") + attr("http://www.w3.org/2000/xmlns/", "xmlns:ds", "http://www.w3.org/2000/09/xmldsig#") + attr("Version", "H004") + attr("Revision", "1") + build() + } + } + + private fun XmlBuilder.RSAKeyXml(key: RSAPrivateCrtKey) { + el("ns2:PubKeyValue") { + el("ds:RSAKeyValue") { + el("ds:Modulus", key.modulus.encodeBase64()) + el("ds:Exponent", key.publicExponent.encodeBase64()) + } + } + } + + private fun XMLOrderData(cfg: EbicsSetupConfig, name: String, schema: String, build: XmlBuilder.() -> Unit): String { + return XmlBuilder.toBytes(name) { + attr("xmlns:ds", "http://www.w3.org/2000/09/xmldsig#") + attr("xmlns:ns2", schema) + build() + el("ns2:PartnerID", cfg.ebicsPartnerId) + el("ns2:UserID", cfg.ebicsUserId) + }.inputStream().deflate().encodeBase64() + } + + companion object { + fun parseResponse(doc: Document, clientEncryptionKey: RSAPrivateCrtKey): EbicsResponse<InputStream?> { + return XmlDestructor.fromDoc(doc, "ebicsKeyManagementResponse") { + lateinit var technicalCode: EbicsReturnCode + lateinit var bankCode: EbicsReturnCode + var payload: InputStream? = null + one("header") { + one("mutable") { + technicalCode = EbicsReturnCode.lookup(one("ReturnCode").text()) + } + } + one("body") { + bankCode = EbicsReturnCode.lookup(one("ReturnCode").text()) + payload = opt("DataTransfer") { + val descriptionInfo = one("DataEncryptionInfo") { + DataEncryptionInfo( + one("TransactionKey").text().decodeBase64(), + one("EncryptionPubKeyDigest").text().decodeBase64() + ) + } + val chunk = one("OrderData").text().decodeBase64() + decryptAndDecompressPayload( + clientEncryptionKey, + descriptionInfo, + listOf(chunk) + ) + } + } + EbicsResponse( + technicalCode = technicalCode, + bankCode, + content = payload + ) + } + } + + fun parseHpbOrder(data: InputStream): Pair<RSAPublicKey, RSAPublicKey> { + return XmlDestructor.fromStream(data, "HPBResponseOrderData") { + val authPub = one("AuthenticationPubKeyInfo").one("PubKeyValue").one("RSAKeyValue") { + CryptoUtil.loadRsaPublicKeyFromComponents( + one("Modulus").text().decodeBase64(), + one("Exponent").text().decodeBase64(), + ) + } + val encPub = one("EncryptionPubKeyInfo").one("PubKeyValue").one("RSAKeyValue") { + CryptoUtil.loadRsaPublicKeyFromComponents( + one("Modulus").text().decodeBase64(), + one("Exponent").text().decodeBase64(), + ) + } + Pair(authPub, encPub) + } + } + } +}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt new file mode 100644 index 00000000..3c73fff0 --- /dev/null +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt @@ -0,0 +1,59 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ +package tech.libeufin.nexus.ebics + +// We will support more dialect in the future + +sealed class EbicsOrder(val schema: String) { + data class V2_5( + val type: String, + val attribute: String + ): EbicsOrder("H004") + data class V3( + val type: String, + val name: String? = null, + val scope: String? = null, + val messageName: String? = null, + val messageVersion: String? = null, + val container: String? = null, + ): EbicsOrder("H005") +} + +fun downloadDocService(doc: SupportedDocument, ebics2: Boolean): EbicsOrder { + return if (ebics2) { + when (doc) { + SupportedDocument.PAIN_002 -> EbicsOrder.V2_5("Z01", "DZHNN") + SupportedDocument.CAMT_052 -> EbicsOrder.V2_5("Z52", "DZHNN") + SupportedDocument.CAMT_053 -> EbicsOrder.V2_5("Z53", "DZHNN") + SupportedDocument.CAMT_054 -> EbicsOrder.V2_5("Z54", "DZHNN") + SupportedDocument.PAIN_002_LOGS -> EbicsOrder.V2_5("HAC", "DZHNN") + } + } else { + when (doc) { + SupportedDocument.PAIN_002 -> EbicsOrder.V3("BTD", "PSR", "CH", "pain.002", "10", "ZIP") + SupportedDocument.CAMT_052 -> EbicsOrder.V3("BTD", "STM", "CH", "camt.052", "08", "ZIP") + SupportedDocument.CAMT_053 -> EbicsOrder.V3("BTD", "EOP", "CH", "camt.053", "08", "ZIP") + SupportedDocument.CAMT_054 -> EbicsOrder.V3("BTD", "REP", "CH", "camt.054", "08", "ZIP") + SupportedDocument.PAIN_002_LOGS -> EbicsOrder.V3("HAC") + } + } +} + +fun uploadPaymentService(): EbicsOrder = + EbicsOrder.V3("BTU", "MCT", "CH", "pain.001", "09") |