summaryrefslogtreecommitdiff
path: root/nexus/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'nexus/src/main')
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt74
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt136
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt114
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt5
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022CodeSets.kt330
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022Constants.kt35
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Log.kt4
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/PDF.kt115
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/XMLUtil.kt175
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt204
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt379
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt443
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt347
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt616
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsKeyMng.kt210
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt59
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")