diff options
author | Antoine A <> | 2024-03-08 17:05:36 +0100 |
---|---|---|
committer | Antoine A <> | 2024-03-08 17:05:36 +0100 |
commit | 8b0ca3dbd7359a402b1671a7cb0fa37b92543d1e (patch) | |
tree | 8b18ca561bceb8e603d765cd56b1da0b169b65c8 | |
parent | 854803e6510eed234844c1d58930ff74ee4f054e (diff) | |
download | libeufin-8b0ca3dbd7359a402b1671a7cb0fa37b92543d1e.tar.gz libeufin-8b0ca3dbd7359a402b1671a7cb0fa37b92543d1e.tar.bz2 libeufin-8b0ca3dbd7359a402b1671a7cb0fa37b92543d1e.zip |
Clean & reafactor & optimize
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)) |