summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAntoine A <>2024-03-08 17:05:36 +0100
committerAntoine A <>2024-03-08 17:05:36 +0100
commit8b0ca3dbd7359a402b1671a7cb0fa37b92543d1e (patch)
tree8b18ca561bceb8e603d765cd56b1da0b169b65c8
parent854803e6510eed234844c1d58930ff74ee4f054e (diff)
downloadlibeufin-8b0ca3dbd7359a402b1671a7cb0fa37b92543d1e.tar.gz
libeufin-8b0ca3dbd7359a402b1671a7cb0fa37b92543d1e.tar.bz2
libeufin-8b0ca3dbd7359a402b1671a7cb0fa37b92543d1e.zip
Clean & reafactor & optimize
-rw-r--r--common/src/main/kotlin/crypto/utils.kt16
-rw-r--r--common/src/main/kotlin/helpers.kt (renamed from common/src/main/kotlin/Stream.kt)49
-rw-r--r--common/src/main/kotlin/strings.kt90
-rw-r--r--common/src/test/kotlin/CryptoUtilTest.kt18
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt2
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt7
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt111
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt4
-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.kt32
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt4
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt25
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt252
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt623
-rw-r--r--nexus/src/test/kotlin/EbicsTest.kt (renamed from nexus/src/test/kotlin/Ebics.kt)44
-rw-r--r--nexus/src/test/kotlin/XmlCombinatorsTest.kt2
17 files changed, 566 insertions, 832 deletions
diff --git a/common/src/main/kotlin/crypto/utils.kt b/common/src/main/kotlin/crypto/utils.kt
index 6bad9741..4e272b15 100644
--- a/common/src/main/kotlin/crypto/utils.kt
+++ b/common/src/main/kotlin/crypto/utils.kt
@@ -50,10 +50,7 @@ object CryptoUtil {
val encryptedTransactionKey: ByteArray,
val pubKeyDigest: ByteArray,
val encryptedData: ByteArray,
- /**
- * This key needs to be reused between different upload phases.
- */
- val plainTransactionKey: SecretKey? = null
+ val plainTransactionKey: SecretKey
)
private val bouncyCastleProvider = BouncyCastleProvider()
@@ -130,15 +127,14 @@ object CryptoUtil {
*/
fun getEbicsPublicKeyHash(publicKey: RSAPublicKey): ByteArray {
val keyBytes = ByteArrayOutputStream()
- keyBytes.writeBytes(publicKey.publicExponent.toUnsignedHexString().lowercase().trimStart('0').toByteArray())
+ keyBytes.writeBytes(publicKey.publicExponent.encodeHex().trimStart('0').toByteArray())
keyBytes.write(' '.code)
- keyBytes.writeBytes(publicKey.modulus.toUnsignedHexString().lowercase().trimStart('0').toByteArray())
- // println("buffer before hashing: '${keyBytes.toString(Charsets.UTF_8)}'")
+ keyBytes.writeBytes(publicKey.modulus.encodeHex().trimStart('0').toByteArray())
val digest = MessageDigest.getInstance("SHA-256")
return digest.digest(keyBytes.toByteArray())
}
- fun encryptEbicsE002(data: ByteArray, encryptionPublicKey: RSAPublicKey): EncryptionResult {
+ fun encryptEbicsE002(data: InputStream, encryptionPublicKey: RSAPublicKey): EncryptionResult {
val keygen = KeyGenerator.getInstance("AES", bouncyCastleProvider)
keygen.init(128)
val transactionKey = keygen.generateKey()
@@ -152,7 +148,7 @@ object CryptoUtil {
* Encrypt data according to the EBICS E002 encryption process.
*/
fun encryptEbicsE002withTransactionKey(
- data: ByteArray,
+ data: InputStream,
encryptionPublicKey: RSAPublicKey,
transactionKey: SecretKey
): EncryptionResult {
@@ -162,7 +158,7 @@ object CryptoUtil {
)
val ivParameterSpec = IvParameterSpec(ByteArray(16))
symmetricCipher.init(Cipher.ENCRYPT_MODE, transactionKey, ivParameterSpec)
- val encryptedData = symmetricCipher.doFinal(data)
+ val encryptedData = CipherInputStream(data, symmetricCipher).readAllBytes()
val asymmetricCipher = Cipher.getInstance(
"RSA/None/PKCS1Padding",
bouncyCastleProvider
diff --git a/common/src/main/kotlin/Stream.kt b/common/src/main/kotlin/helpers.kt
index f20740d7..cd803f64 100644
--- a/common/src/main/kotlin/Stream.kt
+++ b/common/src/main/kotlin/helpers.kt
@@ -19,12 +19,48 @@
package tech.libeufin.common
-import java.io.FilterInputStream
-import java.io.InputStream
+import java.math.BigInteger
import java.util.*
import java.util.zip.DeflaterInputStream
import java.util.zip.InflaterInputStream
import java.util.zip.ZipInputStream
+import java.io.FilterInputStream
+import java.io.InputStream
+import java.io.ByteArrayOutputStream
+
+fun getQueryParam(uriQueryString: String, param: String): String? {
+ // TODO replace with ktor API ?
+ uriQueryString.split('&').forEach {
+ val kv = it.split('=')
+ if (kv[0] == param)
+ return kv[1]
+ }
+ return null
+}
+
+/* ----- String ----- */
+
+fun String.decodeBase64(): ByteArray = Base64.getDecoder().decode(this)
+fun String.decodeUpHex(): ByteArray = HexFormat.of().withUpperCase().parseHex(this)
+
+fun String.splitOnce(pat: String): Pair<String, String>? {
+ val split = split(pat, limit=2)
+ if (split.size != 2) return null
+ return Pair(split[0], split[1])
+}
+
+/* ----- BigInteger -----*/
+
+fun BigInteger.encodeHex(): String = this.toByteArray().encodeHex()
+fun BigInteger.encodeBase64(): String = this.toByteArray().encodeBase64()
+
+/* ----- ByteArray ----- */
+
+fun ByteArray.encodeHex(): String = HexFormat.of().formatHex(this)
+fun ByteArray.encodeUpHex(): String = HexFormat.of().withUpperCase().formatHex(this)
+fun ByteArray.encodeBase64(): String = Base64.getEncoder().encodeToString(this)
+
+/* ----- InputStream ----- */
/** Unzip an input stream and run [lambda] over each entry */
fun InputStream.unzipEach(lambda: (String, InputStream) -> Unit) {
@@ -46,6 +82,15 @@ fun InputStream.unzipEach(lambda: (String, InputStream) -> Unit) {
fun InputStream.decodeBase64(): InputStream
= Base64.getDecoder().wrap(this)
+/** Decode a base64 an input stream */
+fun InputStream.encodeBase64(): String {
+ val w = ByteArrayOutputStream()
+ val encoded = Base64.getEncoder().wrap(w)
+ transferTo(encoded)
+ encoded.close()
+ return w.toString(Charsets.UTF_8)
+}
+
/** Deflate an input stream */
fun InputStream.deflate(): DeflaterInputStream
= DeflaterInputStream(this)
diff --git a/common/src/main/kotlin/strings.kt b/common/src/main/kotlin/strings.kt
deleted file mode 100644
index 3fa5f564..00000000
--- a/common/src/main/kotlin/strings.kt
+++ /dev/null
@@ -1,90 +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.common
-
-import java.math.BigInteger
-import java.util.*
-
-fun ByteArray.toHexString(): String {
- return this.joinToString("") {
- java.lang.String.format("%02X", it)
- }
-}
-
-private fun toDigit(hexChar: Char): Int {
- val digit = Character.digit(hexChar, 16)
- require(digit != -1) { "Invalid Hexadecimal Character: $hexChar" }
- return digit
-}
-
-private fun hexToByte(hexString: String): Byte {
- val firstDigit: Int = toDigit(hexString[0])
- val secondDigit: Int = toDigit(hexString[1])
- return ((firstDigit shl 4) + secondDigit).toByte()
-}
-
-fun decodeHexString(hexString: String): ByteArray {
- val hs = hexString.replace(" ", "").replace("\n", "")
- require(hs.length % 2 != 1) { "Invalid hexadecimal String supplied." }
- val bytes = ByteArray(hs.length / 2)
- var i = 0
- while (i < hs.length) {
- bytes[i / 2] = hexToByte(hs.substring(i, i + 2))
- i += 2
- }
- return bytes
-}
-
-
-fun ByteArray.encodeBase64(): String {
- return Base64.getEncoder().encodeToString(this)
-}
-
-fun String.decodeBase64(): ByteArray {
- return Base64.getDecoder().decode(this)
-}
-
-// used mostly in RSA math, never as amount.
-fun BigInteger.toUnsignedHexString(): String {
- val signedValue = this.toByteArray()
- require(this.signum() > 0) { "number must be positive" }
- val start = if (signedValue[0] == 0.toByte()) {
- 1
- } else {
- 0
- }
- val bytes = Arrays.copyOfRange(signedValue, start, signedValue.size)
- return bytes.toHexString()
-}
-
-fun getQueryParam(uriQueryString: String, param: String): String? {
- uriQueryString.split('&').forEach {
- val kv = it.split('=')
- if (kv[0] == param)
- return kv[1]
- }
- return null
-}
-
-fun String.splitOnce(pat: String): Pair<String, String>? {
- val split = split(pat, limit=2)
- if (split.size != 2) return null
- return Pair(split[0], split[1])
-} \ No newline at end of file
diff --git a/common/src/test/kotlin/CryptoUtilTest.kt b/common/src/test/kotlin/CryptoUtilTest.kt
index 11d87055..82f18ab9 100644
--- a/common/src/test/kotlin/CryptoUtilTest.kt
+++ b/common/src/test/kotlin/CryptoUtilTest.kt
@@ -66,7 +66,7 @@ class CryptoUtilTest {
fun testEbicsE002() {
val data = "Hello, World!".toByteArray()
val keyPair = CryptoUtil.generateRsaKeyPair(1024)
- val enc = CryptoUtil.encryptEbicsE002(data, keyPair.public)
+ val enc = CryptoUtil.encryptEbicsE002(data.inputStream(), keyPair.public)
val dec = CryptoUtil.decryptEbicsE002(enc, keyPair.private)
assertTrue(data.contentEquals(dec))
}
@@ -86,7 +86,7 @@ class CryptoUtilTest {
/* encrypt with original key */
val data = "Hello, World!".toByteArray(Charsets.UTF_8)
- val secret = CryptoUtil.encryptEbicsE002(data, keyPair.public)
+ val secret = CryptoUtil.encryptEbicsE002(data.inputStream(), keyPair.public)
/* encrypt and decrypt private key */
val encPriv = CryptoUtil.encryptKey(keyPair.private.encoded, "secret")
@@ -103,7 +103,7 @@ class CryptoUtilTest {
@Test
fun testEbicsPublicKeyHashing() {
- val exponentStr = "01 00 01"
+ val exponentStr = "01 00 01".replace(" ", "")
val moduloStr = """
EB BD B8 E3 73 45 60 06 44 A1 AD 6A 25 33 65 F5
9C EB E5 93 E0 51 72 77 90 6B F0 58 A8 89 EB 00
@@ -121,7 +121,7 @@ class CryptoUtilTest {
D5 BE 0D 0E F8 E7 E0 A9 C3 10 51 A1 3E A4 4F 67
5E 75 8C 9D E6 FE 27 B6 3C CF 61 9B 31 D4 D0 22
B9 2E 4C AF 5F D6 4B 1F F0 4D 06 5F 68 EB 0B 71
- """.trimIndent()
+ """.trimIndent().replace(" ", "").replace("\n", "")
val expectedHashStr = """
72 71 D5 83 B4 24 A6 DA 0B 7B 22 24 3B E2 B8 8C
6E A6 0F 9F 76 11 FD 18 BE 2C E8 8B 21 03 A9 41
@@ -129,17 +129,17 @@ class CryptoUtilTest {
val expectedHash = expectedHashStr.replace(" ", "").replace("\n", "").toByteArray(Charsets.UTF_8)
- val pub = CryptoUtil.loadRsaPublicKeyFromComponents(decodeHexString(moduloStr), decodeHexString(exponentStr))
+ val pub = CryptoUtil.loadRsaPublicKeyFromComponents(moduloStr.decodeUpHex(), exponentStr.decodeUpHex())
- println("echoed pub exp: ${pub.publicExponent.toUnsignedHexString()}")
- println("echoed pub mod: ${pub.modulus.toUnsignedHexString()}")
+ println("echoed pub exp: ${pub.publicExponent.encodeHex()}")
+ println("echoed pub mod: ${pub.modulus.encodeHex()}")
val pubHash = CryptoUtil.getEbicsPublicKeyHash(pub)
- println("our pubHash: ${pubHash.toHexString()}")
+ println("our pubHash: ${pubHash.encodeUpHex()}")
println("expected pubHash: ${expectedHash.toString(Charsets.UTF_8)}")
- assertEquals(expectedHash.toString(Charsets.UTF_8), pubHash.toHexString())
+ assertEquals(expectedHash.toString(Charsets.UTF_8), pubHash.encodeUpHex())
}
@Test
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
index a089012a..2b713432 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
@@ -82,7 +82,7 @@ private suspend fun downloadHelper(
doc: SupportedDocument,
processing: (InputStream) -> Unit
) {
- val initXml = Ebics3Impl(
+ val initXml = Ebics3BTS(
ctx.cfg,
ctx.bankKeys,
ctx.clientKeys
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt
index 58e43e2d..e690dab8 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt
@@ -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()}")
@@ -158,9 +158,6 @@ suspend fun doKeysRequestAndUpdateState(
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}")
}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt
index 48d25cd7..ea20967e 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 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,13 @@ 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 submitPain001(
+ xml,
+ ctx.cfg,
+ ctx.clientPrivateKeysFile,
+ ctx.bankPublicKeysFile,
+ ctx.httpClient
+ )
}
/**
@@ -157,43 +104,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) {
- e.fmtLog(logger)
- 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 e2b673dd..a9f44077 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
@@ -75,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 XmlBuilder.toString("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/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
index 905bd223..bbae68ae 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/XMLUtil.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/XMLUtil.kt
@@ -96,22 +96,6 @@ object XMLUtil {
return w.toByteArray()
}
- /**
- * Convert a node to a string without the XML declaration or
- * indentation.
- */
- fun convertNodeToString(node: Node): String {
- /* Make Transformer. */
- val tf = TransformerFactory.newInstance()
- val t = tf.newTransformer()
- t.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes")
- /* Make string writer. */
- val sw = StringWriter()
- /* Extract string. */
- t.transform(DOMSource(node), StreamResult(sw))
- return sw.toString()
- }
-
/** Parse [xml] into a XML DOM */
fun parseIntoDom(xml: InputStream): Document {
val factory = DocumentBuilderFactory.newInstance().apply {
@@ -190,20 +174,4 @@ object XMLUtil {
sig.signedInfo.references[0].validate(dvc)
return valResult
}
-
- fun getNodeFromXpath(doc: Document, query: String): Node {
- val xpath = XPathFactory.newInstance().newXPath()
- val ret = xpath.evaluate(query, doc, XPathConstants.NODE)
- ?: throw EbicsProtocolError(HttpStatusCode.NotFound, "Unsuccessful XPath query string: $query")
- return ret as Node
- }
-
- fun getStringFromXpath(doc: Document, query: String): String {
- val xpath = XPathFactory.newInstance().newXPath()
- val ret = xpath.evaluate(query, doc, XPathConstants.STRING) as String
- if (ret.isEmpty()) {
- throw EbicsProtocolError(HttpStatusCode.NotFound, "Unsuccessful XPath query string: $query")
- }
- return ret
- }
} \ 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
index a0d806ec..cfb93782 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt
@@ -41,7 +41,7 @@ interface XmlBuilder {
fun text(content: String)
companion object {
- fun toString(root: String, f: XmlBuilder.() -> Unit): String {
+ fun toBytes(root: String, f: XmlBuilder.() -> Unit): ByteArray {
val factory = XMLOutputFactory.newFactory()
val stream = StringWriter()
var writer = factory.createXMLStreamWriter(stream)
@@ -54,7 +54,7 @@ interface XmlBuilder {
this.f()
}
writer.writeEndDocument()
- return stream.buffer.toString()
+ return stream.buffer.toString().toByteArray()
}
fun toDom(root: String, schema: String?, f: XmlBuilder.() -> Unit): Document {
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt
index 70ec30ab..9f2d2afd 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt
@@ -25,6 +25,7 @@ package tech.libeufin.nexus.ebics
import org.slf4j.Logger
import org.slf4j.LoggerFactory
+import org.w3c.dom.Document
import tech.libeufin.common.crypto.CryptoUtil
import tech.libeufin.common.*
import tech.libeufin.nexus.*
@@ -53,9 +54,9 @@ private val logger: Logger = LoggerFactory.getLogger("libeufin-nexus-ebics2")
*/
fun parseKeysMgmtResponse(
clientEncryptionKey: RSAPrivateCrtKey,
- xml: InputStream
-): EbicsKeyManagementResponseContent? {
- return XmlDestructor.fromStream(xml, "ebicsKeyManagementResponse") {
+ xml: Document
+): EbicsKeyManagementResponseContent {
+ return XmlDestructor.fromDoc(xml, "ebicsKeyManagementResponse") {
lateinit var technicalReturnCode: EbicsReturnCode
lateinit var bankReturnCode: EbicsReturnCode
lateinit var reportText: String
@@ -89,20 +90,20 @@ fun parseKeysMgmtResponse(
private fun XmlBuilder.RSAKeyXml(key: RSAPrivateCrtKey) {
el("ns2:PubKeyValue") {
el("ds:RSAKeyValue") {
- el("ds:Modulus", key.modulus.toByteArray().encodeBase64())
- el("ds:Exponent", key.publicExponent.toByteArray().encodeBase64())
+ 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.toString(name) {
+ 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)
- }.toByteArray().inputStream().deflate().readAllBytes().encodeBase64() // TODO opti
+ }.inputStream().deflate().encodeBase64()
}
/**
@@ -204,7 +205,7 @@ fun generateHpbMessage(cfg: EbicsSetupConfig, clientKeys: ClientPrivateKeysFile)
attr("authenticate", "true")
el("static") {
el("HostID", cfg.ebicsHostId)
- el("Nonce", nonce.toHexString())
+ el("Nonce", nonce.encodeUpHex())
el("Timestamp", Instant.now().xmlDateTime())
el("PartnerID", cfg.ebicsPartnerId)
el("UserID", cfg.ebicsUserId)
@@ -265,4 +266,10 @@ fun parseEbicsHpbOrder(orderDataRaw: InputStream): HpbResponseData {
authenticationVersion = authenticationVersion
)
}
-} \ No newline at end of file
+}
+
+data class EbicsKeyManagementResponseContent(
+ val technicalReturnCode: EbicsReturnCode,
+ val bankReturnCode: EbicsReturnCode?,
+ val orderData: ByteArray?
+) \ 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
index 7cdbc2d4..d930104f 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt
@@ -49,14 +49,14 @@ fun iniRequest(
cfg: EbicsSetupConfig,
clientKeys: ClientPrivateKeysFile
): ByteArray {
- val temp = XmlBuilder.toString("ns2:SignaturePubKeyOrderData") {
+ val temp = XmlBuilder.toBytes("ns2:SignaturePubKeyOrderData") {
attr("xmlns:ds", "http://www.w3.org/2000/09/xmldsig#")
attr("xmlns:ns2", "http://www.ebics.org/S001")
el("ns2:SignaturePubKeyInfo") {
el("ns2:PubKeyValue") {
el("ds:RSAKeyValue") {
- el("ds:Modulus", clientKeys.signature_private_key.modulus.toByteArray().encodeBase64())
- el("ds:Exponent", clientKeys.signature_private_key.publicExponent.toByteArray().encodeBase64())
+ el("ds:Modulus", clientKeys.signature_private_key.modulus.encodeBase64())
+ el("ds:Exponent", clientKeys.signature_private_key.publicExponent.encodeBase64())
}
}
el("ns2:SignatureVersion", "A006")
@@ -66,7 +66,7 @@ fun iniRequest(
}
// TODO in ebics:H005 we MUST use x509 certificates ...
println(temp)
- val inner = temp.toByteArray().inputStream().deflate().readAllBytes().encodeBase64()
+ val inner = temp.inputStream().deflate().encodeBase64()
val doc = XmlBuilder.toDom("ebicsUnsecuredRequest", "urn:org:ebics:H005") {
attr("http://www.w3.org/2000/xmlns/", "xmlns", "urn:org:ebics:H005")
attr("http://www.w3.org/2000/xmlns/", "xmlns:ds", "http://www.w3.org/2000/09/xmldsig#")
@@ -88,28 +88,28 @@ fun iniRequest(
return XMLUtil.convertDomToBytes(doc)
}
-class Ebics3Impl(
+/** EBICS 3 protocol for business transactions */
+class Ebics3BTS(
private val cfg: EbicsSetupConfig,
private val bankKeys: BankPublicKeysFile,
private val clientKeys: ClientPrivateKeysFile
) {
- private fun signedRequest(lambda: XmlBuilder.() -> Unit): ByteArray {
- val doc = XmlBuilder.toDom("ebicsRequest", "urn:org:ebics:H005") {
- attr("http://www.w3.org/2000/xmlns/", "xmlns", "urn:org:ebics:H005")
- attr("http://www.w3.org/2000/xmlns/", "xmlns:ds", "http://www.w3.org/2000/09/xmldsig#")
- attr("Version", "H005")
- attr("Revision", "1")
- lambda()
+ /* ----- Ergonomic entrypoints ----- */
+
+ fun downloadInitializationDoc(whichDoc: SupportedDocument, startDate: Instant? = null, endDate: Instant? = null): ByteArray {
+ val (orderType, service) = when (whichDoc) {
+ SupportedDocument.PAIN_002 -> Pair("BTD", Ebics3Service("PSR", "CH", "pain.002", "10", "ZIP"))
+ SupportedDocument.CAMT_052 -> Pair("BTD", Ebics3Service("STM", "CH", "camt.052", "08", "ZIP"))
+ SupportedDocument.CAMT_053 -> Pair("BTD", Ebics3Service("EOP", "CH", "camt.053", "08", "ZIP"))
+ SupportedDocument.CAMT_054 -> Pair("BTD", Ebics3Service("REP", "CH", "camt.054", "08", "ZIP"))
+ SupportedDocument.PAIN_002_LOGS -> Pair("HAC", null)
}
- XMLUtil.signEbicsDocument(
- doc,
- clientKeys.authentication_private_key,
- withEbics3 = true
- )
- return XMLUtil.convertDomToBytes(doc)
+ return downloadInitialization(orderType, service, startDate, endDate)
}
+ /* ----- Upload ----- */
+
fun uploadInitialization(service: Ebics3Service, preparedUploadData: PreparedUploadData): ByteArray {
val nonce = getNonce(128)
return signedRequest {
@@ -117,7 +117,7 @@ class Ebics3Impl(
attr("authenticate", "true")
el("static") {
el("HostID", cfg.ebicsHostId)
- el("Nonce", nonce.toHexString())
+ el("Nonce", nonce.encodeUpHex())
el("Timestamp", Instant.now().xmlDateTime())
el("PartnerID", cfg.ebicsPartnerId)
el("UserID", cfg.ebicsUserId)
@@ -137,20 +137,7 @@ class Ebics3Impl(
el("SignatureFlag", "true")
}
}
- 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")
+ bankDigest()
el("NumSegments", "1") // TODO test upload of many segment
}
@@ -208,16 +195,7 @@ class Ebics3Impl(
}
}
- fun downloadInitializationDoc(whichDoc: SupportedDocument, startDate: Instant? = null, endDate: Instant? = null): ByteArray {
- val (orderType, service) = when (whichDoc) {
- SupportedDocument.PAIN_002 -> Pair("BTD", Ebics3Service("PSR", "CH", "pain.002", "10", "ZIP"))
- SupportedDocument.CAMT_052 -> Pair("BTD", Ebics3Service("STM", "CH", "camt.052", "08", "ZIP"))
- SupportedDocument.CAMT_053 -> Pair("BTD", Ebics3Service("EOP", "CH", "camt.053", "08", "ZIP"))
- SupportedDocument.CAMT_054 -> Pair("BTD", Ebics3Service("REP", "CH", "camt.054", "08", "ZIP"))
- SupportedDocument.PAIN_002_LOGS -> Pair("HAC", null)
- }
- return downloadInitialization(orderType, service, startDate, endDate)
- }
+ /* ----- Download ----- */
fun downloadInitialization(orderType: String, service: Ebics3Service? = null, startDate: Instant? = null, endDate: Instant? = null): ByteArray {
val nonce = getNonce(128)
@@ -226,7 +204,7 @@ class Ebics3Impl(
attr("authenticate", "true")
el("static") {
el("HostID", cfg.ebicsHostId)
- el("Nonce", nonce.toHexString())
+ el("Nonce", nonce.encodeHex())
el("Timestamp", Instant.now().xmlDateTime())
el("PartnerID", cfg.ebicsPartnerId)
el("UserID", cfg.ebicsUserId)
@@ -260,24 +238,9 @@ class Ebics3Impl(
}
}
}
- 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")
- }
- el("mutable") {
- el("TransactionPhase", "Initialisation")
+ bankDigest()
}
+ el("mutable/TransactionPhase", "Initialisation")
}
el("AuthSignature")
el("body")
@@ -330,75 +293,118 @@ class Ebics3Impl(
}
}
}
-}
-fun signOrderEbics3(
- orderBlob: ByteArray,
- signKey: RSAPrivateCrtKey,
- partnerId: String,
- userId: String
-): ByteArray {
- return XmlBuilder.toString("UserSignatureData") {
- attr("xmlns", "http://www.ebics.org/S002")
- el("OrderSignatureData") {
- el("SignatureVersion", "A006")
- el("SignatureValue", CryptoUtil.signEbicsA006(
- CryptoUtil.digestEbicsOrderA006(orderBlob),
- signKey
- ).encodeBase64())
- el("PartnerID", partnerId)
- el("UserID", userId)
- }
- }.toByteArray()
-}
+ /* ----- Helpers ----- */
+ /** Generate a signed H005 ebicsRequest */
+ private fun signedRequest(lambda: XmlBuilder.() -> Unit): ByteArray {
+ val doc = XmlBuilder.toDom("ebicsRequest", "urn:org:ebics:H005") {
+ attr("http://www.w3.org/2000/xmlns/", "xmlns", "urn:org:ebics:H005")
+ attr("http://www.w3.org/2000/xmlns/", "xmlns:ds", "http://www.w3.org/2000/09/xmldsig#")
+ attr("Version", "H005")
+ attr("Revision", "1")
+ lambda()
+ }
+ XMLUtil.signEbicsDocument(
+ doc,
+ clientKeys.authentication_private_key,
+ withEbics3 = true
+ )
+ return XMLUtil.convertDomToBytes(doc)
+ }
-fun parseEbics3Response(response: Document): EbicsResponseContent {
- // TODO better ebics response type
- return XmlDestructor.fromDoc(response, "ebicsResponse") {
- var transactionID: String? = null
- var numSegments: Int? = null
- lateinit var technicalReturnCode: EbicsReturnCode
- lateinit var bankReturnCode: EbicsReturnCode
- lateinit var reportText: String
- var orderID: String? = null
- var segmentNumber: Int? = null
- var orderDataEncChunk: String? = null
- var dataEncryptionInfo: DataEncryptionInfo? = null
- one("header") {
- one("static") {
- transactionID = opt("TransactionID")?.text()
- numSegments = opt("NumSegments")?.text()?.toInt()
+ 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())
}
- one("mutable") {
- segmentNumber = opt("SegmentNumber")?.text()?.toInt()
- orderID = opt("OrderID")?.text()
- technicalReturnCode = EbicsReturnCode.lookup(one("ReturnCode").text())
- reportText = one("ReportText").text()
+ 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
}
- one("body") {
- opt("DataTransfer") {
- orderDataEncChunk = one("OrderData").text()
- dataEncryptionInfo = opt("DataEncryptionInfo") {
- DataEncryptionInfo(
- one("TransactionKey").text().decodeBase64(),
- one("EncryptionPubKeyDigest").text().decodeBase64()
- )
+ el("SecurityMedium", "0000")
+ }
+
+ companion object {
+ fun parseResponse(doc: Document): EbicsResponse {
+ 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: String? = 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()
+ dataEncryptionInfo = opt("DataEncryptionInfo") {
+ DataEncryptionInfo(
+ one("TransactionKey").text().decodeBase64(),
+ one("EncryptionPubKeyDigest").text().decodeBase64()
+ )
+ }
+ }
+ bankCode = EbicsReturnCode.lookup(one("ReturnCode").text())
}
+ EbicsResponse(
+ bankCode = bankCode,
+ technicalCode = technicalCode,
+ content = EbicsResponseContent(
+ transactionID = transactionID,
+ orderID = orderID,
+ payloadChunk = payloadChunk,
+ dataEncryptionInfo = dataEncryptionInfo,
+ numSegments = numSegments,
+ segmentNumber = segmentNumber
+ )
+ )
}
- bankReturnCode = EbicsReturnCode.lookup(one("ReturnCode").text())
}
- EbicsResponseContent(
- transactionID = transactionID,
- orderID = orderID,
- bankReturnCode = bankReturnCode,
- technicalReturnCode = technicalReturnCode,
- reportText = reportText,
- orderDataEncChunk = orderDataEncChunk,
- dataEncryptionInfo = dataEncryptionInfo,
- numSegments = numSegments,
- segmentNumber = segmentNumber
- )
}
-} \ No newline at end of file
+}
+
+
+data class EbicsResponse(
+ val technicalCode: EbicsReturnCode,
+ val bankCode: EbicsReturnCode,
+ private val content: EbicsResponseContent
+) {
+ /** Checks that return codes are both EBICS_OK or throw an exception */
+ fun okOrFail(phase: String): EbicsResponseContent {
+ 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
+ }
+}
+
+data class EbicsResponseContent(
+ val transactionID: String?,
+ val orderID: String?,
+ val dataEncryptionInfo: DataEncryptionInfo?,
+ val payloadChunk: String?,
+ 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 9144997e..79fcb30b 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.*
@@ -55,6 +50,8 @@ import java.time.format.DateTimeFormatter
import java.util.*
import kotlinx.coroutines.*
import java.security.SecureRandom
+import org.w3c.dom.Document
+import org.xml.sax.SAXException
/**
* Available EBICS versions.
@@ -98,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.
*
@@ -105,100 +109,26 @@ 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) {
- println(res.bodyAsText())
- 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): 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("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("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("invalid XML bank reponse", e)
+ } catch (e: Exception) {
+ throw EbicsError.Transport("failed read bank response", e)
}
- pdfWriter.flush()
- return po.toByteArray()
}
/**
@@ -216,52 +146,23 @@ suspend fun postEbics(
cfg: EbicsSetupConfig,
bankKeys: BankPublicKeysFile,
xmlReq: ByteArray
-): 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
- )
- }
+): EbicsResponse {
+ val doc = client.postToBank(cfg.hostBaseUrl, xmlReq)
if (!XMLUtil.verifyEbicsDocument(
doc,
bankKeys.bank_authentication_public_key,
true
)) {
- throw EbicsSideException(
- "Bank signature did not verify",
- sideEc = EbicsSideError.BANK_SIGNATURE_DIDNT_VERIFY
- )
+ throw EbicsError.Protocol("bank signature did not verify")
+ }
+ try {
+ return Ebics3BTS.parseResponse(doc)
+ } catch (e: Exception) {
+ throw EbicsError.Protocol("invalid ebics response", e)
}
-
- return parseEbics3Response(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.
@@ -283,138 +184,96 @@ suspend fun ebicsDownload(
reqXml: ByteArray,
processing: (InputStream) -> Unit
) = coroutineScope {
- val impl = Ebics3Impl(
- cfg,
- bankKeys,
- clientKeys
- )
- val scope = this
+ val impl = Ebics3BTS(cfg, bankKeys, clientKeys)
+ 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)
- 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 initResp = postEbics(client, cfg, bankKeys, reqXml)
+ 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 = impl.downloadTransfer(x, howManySegments, tId)
-
- val transResp = postEbics(client, cfg, bankKeys, transReq)
- if (!areCodesOk(transResp)) {
- throw EbicsSideException(
- "EBICS transfer segment #$x failed.",
- sideEc = EbicsSideError.TRANSFER_SEGMENT_FAILED
- )
- }
- val chunk = transResp.orderDataEncChunk
- if (chunk == null) {
- throw Exception("EBICS transfer phase lacks chunk #$x, failing.")
- }
- ebicsChunks.add(chunk)
+ val dataEncryptionInfo = requireNotNull(initContent.dataEncryptionInfo) {
+ "Download init phase: missing EncryptionInfo"
}
- suspend fun receipt(success: Boolean) {
- val receiptXml = impl.downloadReceipt(tId, success)
- // Sending the receipt to the bank.
+ logger.debug("Download init phase for transaction '$tId'")
+
+ /** Send download receipt */
+ suspend fun receipt(success: Boolean) {
postEbics(
client,
cfg,
bankKeys,
- receiptXml
- )
+ impl.downloadReceipt(tId, success)
+ ).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()
+ }
+ }
+
+ // Transfer phase
+ val ebicsChunks = mutableListOf(firstDataChunk)
+ for (x in 2 .. howManySegments) {
+ checkCancellation()
+ val transReq = impl.downloadTransfer(x, howManySegments, tId)
+ val transResp = postEbics(client, cfg, bankKeys, transReq).okOrFail("Download transfer phase")
+ val chunk = requireNotNull(transResp.payloadChunk) {
+ "Download transfer phase: missing encrypted chunk"
+ }
+ ebicsChunks.add(chunk)
}
- if (scope.isActive) {
- // all chunks gotten, shaping a meaningful response now.
- val payloadBytes = decryptAndDecompressPayload(
+
+ checkCancellation()
+
+ // Decompress encrypted chunks
+ val payloadBytes = 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(payloadBytes)
}
+
+ // 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.
@@ -430,27 +289,31 @@ fun prepareUploadPayload(
bankKeys: BankPublicKeysFile,
payload: ByteArray,
): PreparedUploadData {
- val innerSignedEbicsXml = signOrderEbics3( // A006 signature.
- payload,
- clientKeys.signature_private_key,
- cfg.ebicsPartnerId,
- cfg.ebicsUserId
- )
+ 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 encryptionResult = CryptoUtil.encryptEbicsE002(
- innerSignedEbicsXml.inputStream().deflate().readAllBytes(),
+ innerSignedEbicsXml.inputStream().deflate(),
bankKeys.bank_encryption_public_key
)
- val plainTransactionKey = encryptionResult.plainTransactionKey
- ?: throw Exception("Could not generate the transaction key, cannot encrypt the payload!")
// 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
@@ -461,32 +324,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.
@@ -505,60 +342,27 @@ suspend fun doEbicsUpload(
bankKeys: BankPublicKeysFile,
service: Ebics3Service,
payload: ByteArray,
-): EbicsResponseContent = withContext(NonCancellable) {
- val impl = Ebics3Impl(cfg, bankKeys, clientKeys)
+): String = withContext(NonCancellable) {
+ val impl = Ebics3BTS(cfg, bankKeys, clientKeys)
// TODO use a lambda and pass the order detail there for atomicity ?
val preparedPayload = prepareUploadPayload(cfg, clientKeys, bankKeys, payload)
+
+ // Init phase
val initXml = impl.uploadInitialization(service, preparedPayload)
- val initResp = postEbics( // may throw EbicsEarlyException
- client,
- cfg,
- bankKeys,
- initXml
- )
- 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 initResp = postEbics(client, cfg, bankKeys, initXml).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 = postEbics(
- client,
- cfg,
- bankKeys,
- transferXml
- )
- logger.debug("Download init phase done. EBICS- and bank-technical codes are: ${transferResp.technicalReturnCode}, ${transferResp.bankReturnCode}")
- 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 transferResp = postEbics(client, cfg, bankKeys, transferXml).okOrFail("Upload transfer phase")
+ val orderId = requireNotNull(transferResp.orderID) {
+ "Upload transfer phase: missing order ID"
+ }
+ orderId
}
-
-data class EbicsProtocolError(
- val httpStatusCode: HttpStatusCode,
- val reason: String,
- /**
- * This class is also used when Nexus finds itself
- * in an inconsistent state, without interacting with the
- * bank. In this case, the EBICS code below can be left
- * null.
- */
- val ebicsTechnicalCode: EbicsReturnCode? = null
-) : Exception(reason)
-
/**
* @param size in bits
*/
@@ -569,113 +373,16 @@ fun getNonce(size: Int): ByteArray {
return ret
}
-data class PreparedUploadData(
+class PreparedUploadData(
val transactionKey: ByteArray,
val userSignatureDataEncrypted: ByteArray,
val dataDigest: ByteArray,
val encryptedPayloadChunks: List<String>
-) {
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
-
- other as PreparedUploadData
-
- if (!transactionKey.contentEquals(other.transactionKey)) return false
- if (!userSignatureDataEncrypted.contentEquals(other.userSignatureDataEncrypted)) return false
- if (encryptedPayloadChunks != other.encryptedPayloadChunks) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- var result = transactionKey.contentHashCode()
- result = 31 * result + userSignatureDataEncrypted.contentHashCode()
- result = 31 * result + encryptedPayloadChunks.hashCode()
- return result
- }
-}
+)
-data class DataEncryptionInfo(
+class DataEncryptionInfo(
val transactionKey: ByteArray,
val bankPubDigest: ByteArray
-) {
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
-
- other as DataEncryptionInfo
-
- if (!transactionKey.contentEquals(other.transactionKey)) return false
- if (!bankPubDigest.contentEquals(other.bankPubDigest)) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- var result = transactionKey.contentHashCode()
- result = 31 * result + bankPubDigest.contentHashCode()
- return result
- }
-}
-
-
-// TODO import missing using a script
-@Suppress("SpellCheckingInspection")
-enum class EbicsReturnCode(val errorCode: 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_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_PROCESSING_ERROR("091116"),
- EBICS_ACCOUNT_AUTHORISATION_FAILED("091302"),
- EBICS_AMOUNT_CHECK_FAILED("091303");
-
- companion object {
- fun lookup(errorCode: String): EbicsReturnCode {
- for (x in entries) {
- if (x.errorCode == errorCode) {
- return x
- }
- }
- throw Exception(
- "Unknown EBICS status code: $errorCode"
- )
- }
- }
-}
-
-data class EbicsResponseContent(
- val transactionID: String?,
- val orderID: String?,
- val dataEncryptionInfo: DataEncryptionInfo?,
- val orderDataEncChunk: String?,
- val technicalReturnCode: EbicsReturnCode,
- val bankReturnCode: EbicsReturnCode,
- val reportText: String,
- val segmentNumber: Int?,
- // Only present in init phase
- val numSegments: Int?
-)
-
-data class EbicsKeyManagementResponseContent(
- val technicalReturnCode: EbicsReturnCode,
- val bankReturnCode: EbicsReturnCode?,
- val orderData: ByteArray?
)
/**
@@ -694,7 +401,7 @@ data class EbicsKeyManagementResponseContent(
* @param httpClient HTTP client to connect to the bank.
*/
suspend fun submitPain001(
- pain001xml: String,
+ pain001xml: ByteArray,
cfg: EbicsSetupConfig,
clientKeys: ClientPrivateKeysFile,
bankkeys: BankPublicKeysFile,
@@ -707,17 +414,69 @@ suspend fun submitPain001(
messageVersion = "09",
container = null
)
- val maybeUploaded = doEbicsUpload(
+ return doEbicsUpload(
httpClient,
cfg,
clientKeys,
bankkeys,
service,
- pain001xml.toByteArray(Charsets.UTF_8),
+ pain001xml,
)
- 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!!
+}
+
+// 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_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/test/kotlin/Ebics.kt b/nexus/src/test/kotlin/EbicsTest.kt
index e2a2ab84..c22a469c 100644
--- a/nexus/src/test/kotlin/Ebics.kt
+++ b/nexus/src/test/kotlin/EbicsTest.kt
@@ -24,36 +24,40 @@ import tech.libeufin.nexus.*
import tech.libeufin.nexus.ebics.*
import kotlin.io.path.Path
import kotlin.io.path.writeBytes
-import kotlin.test.assertEquals
+import kotlin.test.*
-class Ebics {
+class EbicsTest {
// POSTs an EBICS message to the mock bank. Tests
// the main branches: unreachable bank, non-200 status
// code, and 200.
@Test
- fun postMessage() = conf { config ->
- val client404 = getMockedClient {
- respondError(HttpStatusCode.NotFound)
- }
- val clientNoResponse = getMockedClient {
- throw Exception("Network issue.")
- }
- val clientOk = getMockedClient {
- respondOk("Not EBICS anyway.")
+ fun postMessage() = conf { config ->
+ assertFailsWith<EbicsError.Transport> {
+ getMockedClient {
+ respondError(HttpStatusCode.NotFound)
+ }.postToBank("http://ignored.example.com/", ByteArray(0))
+ }.run {
+ assertEquals("bank HTTP error: 404 Not Found", message)
}
- runCatching {
- client404.postToBank("http://ignored.example.com/", "ignored".toByteArray())
+ assertFailsWith<EbicsError.Transport> {
+ getMockedClient {
+ throw Exception("Simulate failure")
+ }.postToBank("http://ignored.example.com/", ByteArray(0))
}.run {
- val exp = exceptionOrNull()!!
- assertEquals(exp.message, "Invalid response status: 404 Not Found")
+ assertEquals("failed to contact bank", message)
+ assertEquals("Simulate failure", cause!!.message)
}
- runCatching {
- clientNoResponse.postToBank("http://ignored.example.com/", "ignored".toByteArray())
+ assertFailsWith<EbicsError.Protocol> {
+ getMockedClient {
+ respondOk("<ebics broken></ebics>")
+ }.postToBank("http://ignored.example.com/", ByteArray(0))
}.run {
- val exp = exceptionOrNull()!!
- assertEquals(exp.message, "Network issue.")
+ assertEquals("invalid XML bank reponse", message)
+ assertEquals("Attribute name \"broken\" associated with an element type \"ebics\" must be followed by the ' = ' character.", cause!!.message)
}
- clientOk.postToBank("http://ignored.example.com/", "ignored".toByteArray())
+ getMockedClient {
+ respondOk("<ebics></ebics>")
+ }.postToBank("http://ignored.example.com/", ByteArray(0))
}
// Tests that internal repr. of keys lead to valid PDF.
diff --git a/nexus/src/test/kotlin/XmlCombinatorsTest.kt b/nexus/src/test/kotlin/XmlCombinatorsTest.kt
index b14920e3..aeb8b327 100644
--- a/nexus/src/test/kotlin/XmlCombinatorsTest.kt
+++ b/nexus/src/test/kotlin/XmlCombinatorsTest.kt
@@ -24,7 +24,7 @@ import kotlin.test.assertEquals
class XmlCombinatorsTest {
fun testBuilder(expected: String, root: String, builder: XmlBuilder.() -> Unit) {
- val toString = XmlBuilder.toString(root, builder)
+ val toBytes = XmlBuilder.toBytes(root, builder)
val toDom = XmlBuilder.toDom(root, null, builder)
//assertEquals(expected, toString) TODO fix empty tag being closed only with toString
assertEquals(expected, XMLUtil.convertDomToBytes(toDom).toString(Charsets.UTF_8))