libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit b86b5a2fd7e13adeaa5d49f7ee459a584730b30e
parent 3d8ad9ef15c56c172e8603eb6aafa73201ad7298
Author: Florian Dold <florian.dold@gmail.com>
Date:   Mon, 15 Jun 2020 14:36:22 +0530

start separating out EBICS handling from rest of nexus

Diffstat:
Mintegration-tests/test-ebics-highlevel.py | 2--
Dnexus/src/main/kotlin/tech/libeufin/nexus/EbicsClient.kt | 276-------------------------------------------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Helpers.kt | 128++-----------------------------------------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/JSON.kt | 2+-
Mnexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 383+++++++++++--------------------------------------------------------------------
Anexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsClient.kt | 277+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt | 483+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/taler.kt | 2++
Msandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt | 1+
9 files changed, 820 insertions(+), 734 deletions(-)

diff --git a/integration-tests/test-ebics-highlevel.py b/integration-tests/test-ebics-highlevel.py @@ -162,7 +162,6 @@ assertResponse( assertResponse( post( f"http://localhost:5001/bank-accounts/{BANK_ACCOUNT_LABEL}/fetch-transactions", - json=dict(), headers=dict(Authorization=USER_AUTHORIZATION_HEADER), ) ) @@ -210,7 +209,6 @@ assertResponse( assertResponse( post( f"http://localhost:5001/bank-accounts/{BANK_ACCOUNT_LABEL}/fetch-transactions", - json=dict(), headers=dict(Authorization=USER_AUTHORIZATION_HEADER), ) ) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsClient.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsClient.kt @@ -1,276 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020 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/> - */ - -/** - * High-level interface for the EBICS protocol. - */ -package tech.libeufin.nexus - -import io.ktor.client.HttpClient -import io.ktor.client.request.post -import io.ktor.http.HttpStatusCode -import tech.libeufin.util.* -import java.util.* - -private suspend inline fun HttpClient.postToBank(url: String, body: String): String { - logger.debug("Posting: $body") - val response: String = try { - this.post<String>( - urlString = url, - block = { - this.body = body - } - ) - } catch (e: Exception) { - throw NexusError(HttpStatusCode.InternalServerError, "Cannot reach the bank") - } - logger.debug("Receiving: $response") - return response -} - -sealed class EbicsDownloadResult - -class EbicsDownloadSuccessResult( - val orderData: ByteArray -) : EbicsDownloadResult() - -/** - * Some bank-technical error occured. - */ -class EbicsDownloadBankErrorResult( - val returnCode: EbicsReturnCode -) : EbicsDownloadResult() - -/** - * Do an EBICS download transaction. This includes the initialization phase, transaction phase - * and receipt phase. - */ -suspend fun doEbicsDownloadTransaction( - client: HttpClient, - subscriberDetails: EbicsClientSubscriberDetails, - orderType: String, - orderParams: EbicsOrderParams -): EbicsDownloadResult { - - // Initialization phase - val initDownloadRequestStr = createEbicsRequestForDownloadInitialization(subscriberDetails, orderType, orderParams) - val payloadChunks = LinkedList<String>() - val initResponseStr = client.postToBank(subscriberDetails.ebicsUrl, initDownloadRequestStr) - - val initResponse = parseAndValidateEbicsResponse(subscriberDetails, initResponseStr) - - when (initResponse.technicalReturnCode) { - EbicsReturnCode.EBICS_OK -> { - // Success, nothing to do! - } - else -> { - throw NexusError( - HttpStatusCode.InternalServerError, - "unexpected return code ${initResponse.technicalReturnCode}" - ) - } - } - - when (initResponse.bankReturnCode) { - EbicsReturnCode.EBICS_OK -> { - // Success, nothing to do! - } - else -> { - logger.warn("Bank return code was: ${initResponse.bankReturnCode}") - return EbicsDownloadBankErrorResult(initResponse.bankReturnCode) - } - } - - val transactionID = - initResponse.transactionID ?: throw NexusError( - HttpStatusCode.InternalServerError, - "initial response must contain transaction ID" - ) - - val encryptionInfo = initResponse.dataEncryptionInfo - ?: throw NexusError(HttpStatusCode.InternalServerError, "initial response did not contain encryption info") - - val initOrderDataEncChunk = initResponse.orderDataEncChunk - ?: throw NexusError( - HttpStatusCode.InternalServerError, - "initial response for download transaction does not contain data transfer" - ) - - payloadChunks.add(initOrderDataEncChunk) - - val numSegments = initResponse.numSegments - ?: throw NexusError(HttpStatusCode.FailedDependency, "missing segment number in EBICS download init response") - - // Transfer phase - - for (x in 2 .. numSegments) { - val transferReqStr = - createEbicsRequestForDownloadTransferPhase(subscriberDetails, transactionID, x, numSegments) - val transferResponseStr = client.postToBank(subscriberDetails.ebicsUrl, transferReqStr) - val transferResponse = parseAndValidateEbicsResponse(subscriberDetails, transferResponseStr) - when (transferResponse.technicalReturnCode) { - EbicsReturnCode.EBICS_OK -> { - // Success, nothing to do! - } - else -> { - throw NexusError( - HttpStatusCode.FailedDependency, - "unexpected technical return code ${transferResponse.technicalReturnCode}" - ) - } - } - when (transferResponse.bankReturnCode) { - EbicsReturnCode.EBICS_OK -> { - // Success, nothing to do! - } - else -> { - logger.warn("Bank return code was: ${transferResponse.bankReturnCode}") - return EbicsDownloadBankErrorResult(transferResponse.bankReturnCode) - } - } - val transferOrderDataEncChunk = transferResponse.orderDataEncChunk - ?: throw NexusError( - HttpStatusCode.InternalServerError, - "transfer response for download transaction does not contain data transfer" - ) - payloadChunks.add(transferOrderDataEncChunk) - } - - val respPayload = decryptAndDecompressResponse(subscriberDetails, encryptionInfo, payloadChunks) - - // Acknowledgement phase - - val ackRequest = createEbicsRequestForDownloadReceipt(subscriberDetails, transactionID) - val ackResponseStr = client.postToBank( - subscriberDetails.ebicsUrl, - ackRequest - ) - val ackResponse = parseAndValidateEbicsResponse(subscriberDetails, ackResponseStr) - when (ackResponse.technicalReturnCode) { - EbicsReturnCode.EBICS_DOWNLOAD_POSTPROCESS_DONE -> { - } - else -> { - throw NexusError(HttpStatusCode.InternalServerError, "unexpected return code") - } - } - return EbicsDownloadSuccessResult(respPayload) -} - - -suspend fun doEbicsUploadTransaction( - client: HttpClient, - subscriberDetails: EbicsClientSubscriberDetails, - orderType: String, - payload: ByteArray, - orderParams: EbicsOrderParams -) { - if (subscriberDetails.bankEncPub == null) { - throw NexusError(HttpStatusCode.BadRequest, "bank encryption key unknown, request HPB first") - } - val preparedUploadData = prepareUploadPayload(subscriberDetails, payload) - val req = createEbicsRequestForUploadInitialization(subscriberDetails, orderType, orderParams, preparedUploadData) - val responseStr = client.postToBank(subscriberDetails.ebicsUrl, req) - - val initResponse = parseAndValidateEbicsResponse(subscriberDetails, responseStr) - if (initResponse.technicalReturnCode != EbicsReturnCode.EBICS_OK) { - throw NexusError(HttpStatusCode.InternalServerError, reason = "unexpected return code") - } - - val transactionID = - initResponse.transactionID ?: throw NexusError( - HttpStatusCode.InternalServerError, - "init response must have transaction ID" - ) - - logger.debug("INIT phase passed!") - /* now send actual payload */ - - val tmp = createEbicsRequestForUploadTransferPhase( - subscriberDetails, - transactionID, - preparedUploadData, - 0 - ) - - val txRespStr = client.postToBank( - subscriberDetails.ebicsUrl, - tmp - ) - - val txResp = parseAndValidateEbicsResponse(subscriberDetails, txRespStr) - - when (txResp.technicalReturnCode) { - EbicsReturnCode.EBICS_OK -> { - } - else -> { - throw NexusError(HttpStatusCode.InternalServerError, "unexpected return code") - } - } -} - -suspend fun doEbicsHostVersionQuery(client: HttpClient, ebicsBaseUrl: String, ebicsHostId: String): EbicsHevDetails { - val ebicsHevRequest = makeEbicsHEVRequestRaw(ebicsHostId) - val resp = client.postToBank(ebicsBaseUrl, ebicsHevRequest) - val versionDetails = parseEbicsHEVResponse(resp) - return versionDetails -} - -suspend fun doEbicsIniRequest( - client: HttpClient, - subscriberDetails: EbicsClientSubscriberDetails -): EbicsKeyManagementResponseContent { - val request = makeEbicsIniRequest(subscriberDetails) - val respStr = client.postToBank( - subscriberDetails.ebicsUrl, - request - ) - val resp = parseAndDecryptEbicsKeyManagementResponse(subscriberDetails, respStr) - return resp -} - -suspend fun doEbicsHiaRequest( - client: HttpClient, - subscriberDetails: EbicsClientSubscriberDetails -): EbicsKeyManagementResponseContent { - val request = makeEbicsHiaRequest(subscriberDetails) - val respStr = client.postToBank( - subscriberDetails.ebicsUrl, - request - ) - val resp = parseAndDecryptEbicsKeyManagementResponse(subscriberDetails, respStr) - return resp -} - - -suspend fun doEbicsHpbRequest( - client: HttpClient, - subscriberDetails: EbicsClientSubscriberDetails -): HpbResponseData { - val request = makeEbicsHpbRequest(subscriberDetails) - val respStr = client.postToBank( - subscriberDetails.ebicsUrl, - request - ) - val parsedResponse = parseAndDecryptEbicsKeyManagementResponse(subscriberDetails, respStr) - val orderData = parsedResponse.orderData ?: throw EbicsProtocolError( - HttpStatusCode.BadGateway, - "Cannot find data in a HPB response" - ) - return parseEbicsHpbOrder(orderData) -} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Helpers.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Helpers.kt @@ -101,19 +101,6 @@ fun getEbicsSubscriberDetailsInternal(subscriber: EbicsSubscriberEntity): EbicsC } /** - * Retrieve Ebics subscriber details given a bank connection. - */ -fun getEbicsSubscriberDetails(bankConnectionId: String): EbicsClientSubscriberDetails { - val transport = NexusBankConnectionEntity.findById(bankConnectionId) - if (transport == null) { - throw NexusError(HttpStatusCode.NotFound, "transport not found") - } - val subscriber = EbicsSubscriberEntity.find { EbicsSubscribersTable.nexusBankConnection eq transport.id }.first() - // transport exists and belongs to caller. - return getEbicsSubscriberDetailsInternal(subscriber) -} - -/** * Check if the transaction is already found in the database. */ private fun isDuplicate(acctSvcrRef: String): Boolean { @@ -194,93 +181,6 @@ fun ingestBankMessagesIntoAccount( } } -private data class EbicsFetchSpec( - val orderType: String, - val orderParams: EbicsOrderParams -) - -suspend fun fetchEbicsBySpec(fetchSpec: FetchSpecJson, client: HttpClient, bankConnectionId: String) { - val subscriberDetails = getEbicsSubscriberDetails(bankConnectionId) - val specs = mutableListOf<EbicsFetchSpec>() - when (fetchSpec) { - is FetchSpecLatestJson -> { - val p = EbicsStandardOrderParams() - when (fetchSpec.level) { - FetchLevel.ALL -> { - specs.add(EbicsFetchSpec("C52", p)) - specs.add(EbicsFetchSpec("C53", p)) - } - FetchLevel.REPORT -> { - specs.add(EbicsFetchSpec("C52", p)) - } - FetchLevel.STATEMENT -> { - specs.add(EbicsFetchSpec("C53", p)) - } - } - } - } - for (spec in specs) { - fetchEbicsC5x(spec.orderType, client, bankConnectionId, spec.orderParams, subscriberDetails) - } -} - -/** - * Fetch EBICS C5x and store it locally, but do not update bank accounts. - */ -private suspend fun fetchEbicsC5x( - historyType: String, - client: HttpClient, - bankConnectionId: String, - orderParams: EbicsOrderParams, - subscriberDetails: EbicsClientSubscriberDetails -) { - val response = doEbicsDownloadTransaction( - client, - subscriberDetails, - historyType, - orderParams - ) - when (historyType) { - "C52" -> { - } - "C53" -> { - } - else -> { - throw NexusError(HttpStatusCode.BadRequest, "history type '$historyType' not supported") - } - } - when (response) { - is EbicsDownloadSuccessResult -> { - response.orderData.unzipWithLambda { - logger.debug("Camt entry: ${it.second}") - val camt53doc = XMLUtil.parseStringIntoDom(it.second) - val msgId = camt53doc.pickStringWithRootNs("/*[1]/*[1]/root:GrpHdr/root:MsgId") - logger.info("msg id $msgId") - transaction { - val conn = NexusBankConnectionEntity.findById(bankConnectionId) - if (conn == null) { - throw NexusError(HttpStatusCode.InternalServerError, "bank connection missing") - } - val oldMsg = NexusBankMessageEntity.find { NexusBankMessagesTable.messageId eq msgId }.firstOrNull() - if (oldMsg == null) { - NexusBankMessageEntity.new { - this.bankConnection = conn - this.code = historyType - this.messageId = msgId - this.message = ExposedBlob(it.second.toByteArray(Charsets.UTF_8)) - } - } - } - } - } - is EbicsDownloadBankErrorResult -> { - throw NexusError( - HttpStatusCode.BadGateway, - response.returnCode.errorCode - ) - } - } -} /** * Create a PAIN.001 XML document according to the input data. @@ -472,29 +372,4 @@ fun extractUserAndPassword(authorizationHeader: String): Pair<String, String> { ) } return Pair(username, password) -} - -/** - * Test HTTP basic auth. Throws error if password is wrong, - * and makes sure that the user exists in the system. - * - * @param authorization the Authorization:-header line. - * @return user id - */ -fun authenticateRequest(request: ApplicationRequest): NexusUserEntity { - val authorization = request.headers["Authorization"] - val headerLine = if (authorization == null) throw NexusError( - HttpStatusCode.BadRequest, "Authentication:-header line not found" - ) else authorization - val (username, password) = extractUserAndPassword(headerLine) - val user = NexusUserEntity.find { - NexusUsersTable.id eq username - }.firstOrNull() - if (user == null) { - throw NexusError(HttpStatusCode.Unauthorized, "Unknown user '$username'") - } - if (!CryptoUtil.checkpw(password, user.passwordHash)) { - throw NexusError(HttpStatusCode.Forbidden, "Wrong password") - } - return user -} +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/JSON.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/JSON.kt @@ -28,7 +28,7 @@ import tech.libeufin.util.* import java.time.LocalDate import java.time.LocalDateTime -data class EbicsBackupRequestJson( +data class BackupRequestJson( val passphrase: String ) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -67,16 +67,14 @@ import org.jetbrains.exposed.sql.transactions.transaction import org.slf4j.Logger import org.slf4j.LoggerFactory import org.slf4j.event.Level +import tech.libeufin.nexus.ebics.* import tech.libeufin.util.* import tech.libeufin.util.CryptoUtil.hashpw -import tech.libeufin.util.ebics_h004.HTDResponseOrderData import java.io.PrintWriter import java.io.StringWriter import java.net.URLEncoder import java.time.Duration -import java.util.* import java.util.zip.InflaterInputStream -import javax.crypto.EncryptedPrivateKeyInfo data class NexusError(val statusCode: HttpStatusCode, val reason: String) : Exception("$reason (HTTP status $statusCode)") @@ -135,65 +133,32 @@ suspend inline fun <reified T : Any> ApplicationCall.receiveJson(): T { } } -fun createEbicsBankConnectionFromBackup( - bankConnectionName: String, - user: NexusUserEntity, - passphrase: String?, - backup: JsonNode -) { - if (passphrase === null) { - throw NexusError(HttpStatusCode.BadRequest, "EBICS backup needs passphrase") - } - val bankConn = NexusBankConnectionEntity.new(bankConnectionName) { - owner = user - type = "ebics" - } - val ebicsBackup = jacksonObjectMapper().treeToValue(backup, EbicsKeysBackupJson::class.java) - val (authKey, encKey, sigKey) = try { - Triple( - CryptoUtil.decryptKey( - EncryptedPrivateKeyInfo(base64ToBytes(ebicsBackup.authBlob)), - passphrase - ), - CryptoUtil.decryptKey( - EncryptedPrivateKeyInfo(base64ToBytes(ebicsBackup.encBlob)), - passphrase - ), - CryptoUtil.decryptKey( - EncryptedPrivateKeyInfo(base64ToBytes(ebicsBackup.sigBlob)), - passphrase - ) - ) - } catch (e: Exception) { - e.printStackTrace() - logger.info("Restoring keys failed, probably due to wrong passphrase") - throw NexusError( - HttpStatusCode.BadRequest, - "Bad backup given" - ) +/** + * Test HTTP basic auth. Throws error if password is wrong, + * and makes sure that the user exists in the system. + * + * @param authorization the Authorization:-header line. + * @return user id + */ +fun authenticateRequest(request: ApplicationRequest): NexusUserEntity { + val authorization = request.headers["Authorization"] + val headerLine = if (authorization == null) throw NexusError( + HttpStatusCode.BadRequest, "Authentication:-header line not found" + ) else authorization + val (username, password) = extractUserAndPassword(headerLine) + val user = NexusUserEntity.find { + NexusUsersTable.id eq username + }.firstOrNull() + if (user == null) { + throw NexusError(HttpStatusCode.Unauthorized, "Unknown user '$username'") } - try { - EbicsSubscriberEntity.new { - ebicsURL = ebicsBackup.ebicsURL - hostID = ebicsBackup.hostID - partnerID = ebicsBackup.partnerID - userID = ebicsBackup.userID - signaturePrivateKey = ExposedBlob(sigKey.encoded) - encryptionPrivateKey = ExposedBlob((encKey.encoded)) - authenticationPrivateKey = ExposedBlob((authKey.encoded)) - nexusBankConnection = bankConn - ebicsIniState = EbicsInitState.UNKNOWN - ebicsHiaState = EbicsInitState.UNKNOWN - } - } catch (e: Exception) { - throw NexusError( - HttpStatusCode.BadRequest, - "exception: $e" - ) + if (!CryptoUtil.checkpw(password, user.passwordHash)) { + throw NexusError(HttpStatusCode.Forbidden, "Wrong password") } - return + return user } + fun createLoopbackBankConnection(bankConnectionName: String, user: NexusUserEntity, data: JsonNode) { val bankConn = NexusBankConnectionEntity.new(bankConnectionName) { owner = user @@ -511,12 +476,10 @@ fun serverMain(dbName: String) { return@get } - post("/bank-connection-protocols/ebics/test-host") { - val r = call.receiveJson<EbicsHostTestRequest>() - val qr = doEbicsHostVersionQuery(client, r.ebicsBaseUrl, r.ebicsHostId) - call.respond(HttpStatusCode.OK, qr) - return@post + route("/bank-connection-protocols/ebics") { + ebicsBankProtocolRoutes(client) } + /** * Shows the bank accounts belonging to the requesting user. */ @@ -570,24 +533,16 @@ fun serverMain(dbName: String) { } val defaultBankConnection = bankAccount.defaultBankConnection ?: throw NexusError(HttpStatusCode.NotFound, "needs a default connection") - val subscriberDetails = getEbicsSubscriberDetails(defaultBankConnection.id.value) return@transaction object { val pain001document = createPain001document(preparedPayment) val bankConnectionType = defaultBankConnection.type - val subscriberDetails = subscriberDetails + val connId = defaultBankConnection.id.value } } // type and name aren't null when (res.bankConnectionType) { "ebics" -> { - logger.debug("Uploading PAIN.001: ${res.pain001document}") - doEbicsUploadTransaction( - client, - res.subscriberDetails, - "CCT", - res.pain001document.toByteArray(Charsets.UTF_8), - EbicsStandardOrderParams() - ) + submitEbicsPaymentInitiation(client, res.connId, res.pain001document) } else -> throw NexusError( HttpStatusCode.NotFound, @@ -771,61 +726,29 @@ fun serverMain(dbName: String) { val resp = transaction { val user = authenticateRequest(call.request) val conn = requireBankConnection(call, "connid") - if (conn.type != "ebics") { - throw NexusError( - HttpStatusCode.BadRequest, - "bank connection is not of type 'ebics' (but '${conn.type}')" - ) + when (conn.type) { + "ebics" -> { + getEbicsConnectionDetails(conn) + } + else -> { + throw NexusError( + HttpStatusCode.BadRequest, + "bank connection is not of type 'ebics' (but '${conn.type}')" + ) + } } - val ebicsSubscriber = getEbicsSubscriberDetails(conn.id.value) - val mapper = ObjectMapper() - val details = mapper.createObjectNode() - details.put("ebicsUrl", ebicsSubscriber.ebicsUrl) - details.put("ebicsHostId", ebicsSubscriber.hostId) - details.put("partnerId", ebicsSubscriber.partnerId) - details.put("userId", ebicsSubscriber.userId) - val node = mapper.createObjectNode() - node.put("type", conn.type) - node.put("owner", conn.owner.id.value) - node.set<JsonNode>("details", details) - node } call.respond(resp) } post("/bank-connections/{connid}/export-backup") { - val body = call.receive<EbicsBackupRequestJson>() - val response = transaction { - val user = authenticateRequest(call.request) + transaction { authenticateRequest(call.request) } + val body = call.receive<BackupRequestJson>() + val response = run { val conn = requireBankConnection(call, "connid") when (conn.type) { "ebics" -> { - val subscriber = getEbicsSubscriberDetails(conn.id.value) - EbicsKeysBackupJson( - type = "ebics", - userID = subscriber.userId, - hostID = subscriber.hostId, - partnerID = subscriber.partnerId, - ebicsURL = subscriber.ebicsUrl, - authBlob = bytesToBase64( - CryptoUtil.encryptKey( - subscriber.customerAuthPriv.encoded, - body.passphrase - ) - ), - encBlob = bytesToBase64( - CryptoUtil.encryptKey( - subscriber.customerEncPriv.encoded, - body.passphrase - ) - ), - sigBlob = bytesToBase64( - CryptoUtil.encryptKey( - subscriber.customerSignPriv.encoded, - body.passphrase - ) - ) - ) + exportEbicsKeyBackup(conn.id.value, body.passphrase) } else -> { throw NexusError( @@ -843,62 +766,13 @@ fun serverMain(dbName: String) { } post("/bank-connections/{connid}/connect") { - val subscriber = transaction { - val user = authenticateRequest(call.request) - val conn = requireBankConnection(call, "connid") - if (conn.type != "ebics") { - throw NexusError( - HttpStatusCode.BadRequest, - "bank connection is not of type 'ebics' (but '${conn.type}')" - ) - } - getEbicsSubscriberDetails(conn.id.value) - } - if (subscriber.bankAuthPub != null && subscriber.bankEncPub != null) { - call.respond(object { - val ready = true - }) - return@post - } - - val iniDone = when (subscriber.ebicsIniState) { - EbicsInitState.NOT_SENT, EbicsInitState.UNKNOWN -> { - val iniResp = doEbicsIniRequest(client, subscriber) - iniResp.bankReturnCode == EbicsReturnCode.EBICS_OK && iniResp.technicalReturnCode == EbicsReturnCode.EBICS_OK - } - else -> { - false - } - } - val hiaDone = when (subscriber.ebicsHiaState) { - EbicsInitState.NOT_SENT, EbicsInitState.UNKNOWN -> { - val hiaResp = doEbicsHiaRequest(client, subscriber) - hiaResp.bankReturnCode == EbicsReturnCode.EBICS_OK && hiaResp.technicalReturnCode == EbicsReturnCode.EBICS_OK - } - else -> { - false - } - } - val hpbData = try { - doEbicsHpbRequest(client, subscriber) - } catch (e: EbicsProtocolError) { - logger.warn("failed hpb request", e) - null + val conn = transaction { + authenticateRequest(call.request) + requireBankConnection(call, "connid") } - transaction { - val conn = requireBankConnection(call, "connid") - val subscriberEntity = - EbicsSubscriberEntity.find { EbicsSubscribersTable.nexusBankConnection eq conn.id }.first() - if (iniDone) { - subscriberEntity.ebicsIniState = EbicsInitState.SENT - } - if (hiaDone) { - subscriberEntity.ebicsHiaState = EbicsInitState.SENT - } - if (hpbData != null) { - subscriberEntity.bankAuthenticationPublicKey = - ExposedBlob((hpbData.authenticationPubKey.encoded)) - subscriberEntity.bankEncryptionPublicKey = ExposedBlob((hpbData.encryptionPubKey.encoded)) + when (conn.type) { + "ebics" -> { + connectEbics(client, conn.id.value) } } call.respond(object {}) @@ -933,157 +807,6 @@ fun serverMain(dbName: String) { call.respondBytes(ret.msgContent, ContentType("application", "xml")) } - post("/bank-connections/{connid}/ebics/send-ini") { - val subscriber = transaction { - val user = authenticateRequest(call.request) - val conn = requireBankConnection(call, "connid") - if (conn.type != "ebics") { - throw NexusError( - HttpStatusCode.BadRequest, - "bank connection is not of type 'ebics' (but '${conn.type}')" - ) - } - getEbicsSubscriberDetails(conn.id.value) - } - val resp = doEbicsIniRequest(client, subscriber) - call.respond(resp) - } - - post("/bank-connections/{connid}/ebics/send-hia") { - val subscriber = transaction { - val user = authenticateRequest(call.request) - val conn = requireBankConnection(call, "connid") - if (conn.type != "ebics") { - throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") - } - getEbicsSubscriberDetails(conn.id.value) - } - val resp = doEbicsHiaRequest(client, subscriber) - call.respond(resp) - } - - post("/bank-connections/{connid}/ebics/send-hev") { - val subscriber = transaction { - val user = authenticateRequest(call.request) - val conn = requireBankConnection(call, "connid") - if (conn.type != "ebics") { - throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") - } - getEbicsSubscriberDetails(conn.id.value) - } - val resp = doEbicsHostVersionQuery(client, subscriber.ebicsUrl, subscriber.hostId) - call.respond(resp) - } - - post("/bank-connections/{connid}/ebics/send-hpb") { - val subscriberDetails = transaction { - val user = authenticateRequest(call.request) - val conn = requireBankConnection(call, "connid") - if (conn.type != "ebics") { - throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") - } - getEbicsSubscriberDetails(conn.id.value) - } - val hpbData = doEbicsHpbRequest(client, subscriberDetails) - transaction { - val conn = requireBankConnection(call, "connid") - val subscriber = - EbicsSubscriberEntity.find { EbicsSubscribersTable.nexusBankConnection eq conn.id }.first() - subscriber.bankAuthenticationPublicKey = ExposedBlob((hpbData.authenticationPubKey.encoded)) - subscriber.bankEncryptionPublicKey = ExposedBlob((hpbData.encryptionPubKey.encoded)) - } - call.respond(object {}) - } - - /** - * Directly import accounts. Used for testing. - */ - post("/bank-connections/{connid}/ebics/import-accounts") { - val subscriberDetails = transaction { - val user = authenticateRequest(call.request) - val conn = requireBankConnection(call, "connid") - if (conn.type != "ebics") { - throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") - } - getEbicsSubscriberDetails(conn.id.value) - } - val response = doEbicsDownloadTransaction( - client, subscriberDetails, "HTD", EbicsStandardOrderParams() - ) - when (response) { - is EbicsDownloadBankErrorResult -> { - throw NexusError( - HttpStatusCode.BadGateway, - response.returnCode.errorCode - ) - } - is EbicsDownloadSuccessResult -> { - val payload = XMLUtil.convertStringToJaxb<HTDResponseOrderData>( - response.orderData.toString(Charsets.UTF_8) - ) - transaction { - val conn = requireBankConnection(call, "connid") - payload.value.partnerInfo.accountInfoList?.forEach { - val bankAccount = NexusBankAccountEntity.new(id = it.id) { - accountHolder = it.accountHolder ?: "NOT-GIVEN" - iban = extractFirstIban(it.accountNumberList) - ?: throw NexusError(HttpStatusCode.NotFound, reason = "bank gave no IBAN") - bankCode = extractFirstBic(it.bankCodeList) ?: throw NexusError( - HttpStatusCode.NotFound, - reason = "bank gave no BIC" - ) - defaultBankConnection = conn - highestSeenBankMessageId = 0 - } - } - } - response.orderData.toString(Charsets.UTF_8) - } - } - call.respond(object {}) - } - - post("/bank-connections/{connid}/ebics/download/{msgtype}") { - val orderType = requireNotNull(call.parameters["msgtype"]).toUpperCase(Locale.ROOT) - if (orderType.length != 3) { - throw NexusError(HttpStatusCode.BadRequest, "ebics order type must be three characters") - } - val paramsJson = call.receiveOrNull<EbicsStandardOrderParamsDateJson>() - val orderParams = if (paramsJson == null) { - EbicsStandardOrderParams() - } else { - paramsJson.toOrderParams() - } - val subscriberDetails = transaction { - val user = authenticateRequest(call.request) - val conn = requireBankConnection(call, "connid") - if (conn.type != "ebics") { - throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") - } - getEbicsSubscriberDetails(conn.id.value) - } - val response = doEbicsDownloadTransaction( - client, - subscriberDetails, - orderType, - orderParams - ) - when (response) { - is EbicsDownloadSuccessResult -> { - call.respondText( - response.orderData.toString(Charsets.UTF_8), - ContentType.Text.Plain, - HttpStatusCode.OK - ) - } - is EbicsDownloadBankErrorResult -> { - call.respond( - HttpStatusCode.BadGateway, - EbicsErrorJson(EbicsErrorDetailJson("bankError", response.returnCode.errorCode)) - ) - } - } - } post("/facades") { val body = call.receive<FacadeInfo>() val newFacade = transaction { @@ -1106,10 +829,12 @@ fun serverMain(dbName: String) { return@post } - route("/facades/{fcid}") { - route("taler") { - talerFacadeRoutes(this) - } + route("/bank-connections/{connid}/ebics") { + ebicsBankConnectionRoutes(client) + } + + route("/facades/{fcid}/taler") { + talerFacadeRoutes(this) } /** * Hello endpoint. diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsClient.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsClient.kt @@ -0,0 +1,277 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2020 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/> + */ + +/** + * High-level interface for the EBICS protocol. + */ +package tech.libeufin.nexus.ebics + +import io.ktor.client.HttpClient +import io.ktor.client.request.post +import io.ktor.http.HttpStatusCode +import tech.libeufin.nexus.NexusError +import tech.libeufin.util.* +import java.util.* + +private suspend inline fun HttpClient.postToBank(url: String, body: String): String { + logger.debug("Posting: $body") + val response: String = try { + this.post<String>( + urlString = url, + block = { + this.body = body + } + ) + } catch (e: Exception) { + throw NexusError(HttpStatusCode.InternalServerError, "Cannot reach the bank") + } + logger.debug("Receiving: $response") + return response +} + +sealed class EbicsDownloadResult + +class EbicsDownloadSuccessResult( + val orderData: ByteArray +) : EbicsDownloadResult() + +/** + * Some bank-technical error occured. + */ +class EbicsDownloadBankErrorResult( + val returnCode: EbicsReturnCode +) : EbicsDownloadResult() + +/** + * Do an EBICS download transaction. This includes the initialization phase, transaction phase + * and receipt phase. + */ +suspend fun doEbicsDownloadTransaction( + client: HttpClient, + subscriberDetails: EbicsClientSubscriberDetails, + orderType: String, + orderParams: EbicsOrderParams +): EbicsDownloadResult { + + // Initialization phase + val initDownloadRequestStr = createEbicsRequestForDownloadInitialization(subscriberDetails, orderType, orderParams) + val payloadChunks = LinkedList<String>() + val initResponseStr = client.postToBank(subscriberDetails.ebicsUrl, initDownloadRequestStr) + + val initResponse = parseAndValidateEbicsResponse(subscriberDetails, initResponseStr) + + when (initResponse.technicalReturnCode) { + EbicsReturnCode.EBICS_OK -> { + // Success, nothing to do! + } + else -> { + throw NexusError( + HttpStatusCode.InternalServerError, + "unexpected return code ${initResponse.technicalReturnCode}" + ) + } + } + + when (initResponse.bankReturnCode) { + EbicsReturnCode.EBICS_OK -> { + // Success, nothing to do! + } + else -> { + logger.warn("Bank return code was: ${initResponse.bankReturnCode}") + return EbicsDownloadBankErrorResult(initResponse.bankReturnCode) + } + } + + val transactionID = + initResponse.transactionID ?: throw NexusError( + HttpStatusCode.InternalServerError, + "initial response must contain transaction ID" + ) + + val encryptionInfo = initResponse.dataEncryptionInfo + ?: throw NexusError(HttpStatusCode.InternalServerError, "initial response did not contain encryption info") + + val initOrderDataEncChunk = initResponse.orderDataEncChunk + ?: throw NexusError( + HttpStatusCode.InternalServerError, + "initial response for download transaction does not contain data transfer" + ) + + payloadChunks.add(initOrderDataEncChunk) + + val numSegments = initResponse.numSegments + ?: throw NexusError(HttpStatusCode.FailedDependency, "missing segment number in EBICS download init response") + + // Transfer phase + + for (x in 2 .. numSegments) { + val transferReqStr = + createEbicsRequestForDownloadTransferPhase(subscriberDetails, transactionID, x, numSegments) + val transferResponseStr = client.postToBank(subscriberDetails.ebicsUrl, transferReqStr) + val transferResponse = parseAndValidateEbicsResponse(subscriberDetails, transferResponseStr) + when (transferResponse.technicalReturnCode) { + EbicsReturnCode.EBICS_OK -> { + // Success, nothing to do! + } + else -> { + throw NexusError( + HttpStatusCode.FailedDependency, + "unexpected technical return code ${transferResponse.technicalReturnCode}" + ) + } + } + when (transferResponse.bankReturnCode) { + EbicsReturnCode.EBICS_OK -> { + // Success, nothing to do! + } + else -> { + logger.warn("Bank return code was: ${transferResponse.bankReturnCode}") + return EbicsDownloadBankErrorResult(transferResponse.bankReturnCode) + } + } + val transferOrderDataEncChunk = transferResponse.orderDataEncChunk + ?: throw NexusError( + HttpStatusCode.InternalServerError, + "transfer response for download transaction does not contain data transfer" + ) + payloadChunks.add(transferOrderDataEncChunk) + } + + val respPayload = decryptAndDecompressResponse(subscriberDetails, encryptionInfo, payloadChunks) + + // Acknowledgement phase + + val ackRequest = createEbicsRequestForDownloadReceipt(subscriberDetails, transactionID) + val ackResponseStr = client.postToBank( + subscriberDetails.ebicsUrl, + ackRequest + ) + val ackResponse = parseAndValidateEbicsResponse(subscriberDetails, ackResponseStr) + when (ackResponse.technicalReturnCode) { + EbicsReturnCode.EBICS_DOWNLOAD_POSTPROCESS_DONE -> { + } + else -> { + throw NexusError(HttpStatusCode.InternalServerError, "unexpected return code") + } + } + return EbicsDownloadSuccessResult(respPayload) +} + + +suspend fun doEbicsUploadTransaction( + client: HttpClient, + subscriberDetails: EbicsClientSubscriberDetails, + orderType: String, + payload: ByteArray, + orderParams: EbicsOrderParams +) { + if (subscriberDetails.bankEncPub == null) { + throw NexusError(HttpStatusCode.BadRequest, "bank encryption key unknown, request HPB first") + } + val preparedUploadData = prepareUploadPayload(subscriberDetails, payload) + val req = createEbicsRequestForUploadInitialization(subscriberDetails, orderType, orderParams, preparedUploadData) + val responseStr = client.postToBank(subscriberDetails.ebicsUrl, req) + + val initResponse = parseAndValidateEbicsResponse(subscriberDetails, responseStr) + if (initResponse.technicalReturnCode != EbicsReturnCode.EBICS_OK) { + throw NexusError(HttpStatusCode.InternalServerError, reason = "unexpected return code") + } + + val transactionID = + initResponse.transactionID ?: throw NexusError( + HttpStatusCode.InternalServerError, + "init response must have transaction ID" + ) + + logger.debug("INIT phase passed!") + /* now send actual payload */ + + val tmp = createEbicsRequestForUploadTransferPhase( + subscriberDetails, + transactionID, + preparedUploadData, + 0 + ) + + val txRespStr = client.postToBank( + subscriberDetails.ebicsUrl, + tmp + ) + + val txResp = parseAndValidateEbicsResponse(subscriberDetails, txRespStr) + + when (txResp.technicalReturnCode) { + EbicsReturnCode.EBICS_OK -> { + } + else -> { + throw NexusError(HttpStatusCode.InternalServerError, "unexpected return code") + } + } +} + +suspend fun doEbicsHostVersionQuery(client: HttpClient, ebicsBaseUrl: String, ebicsHostId: String): EbicsHevDetails { + val ebicsHevRequest = makeEbicsHEVRequestRaw(ebicsHostId) + val resp = client.postToBank(ebicsBaseUrl, ebicsHevRequest) + val versionDetails = parseEbicsHEVResponse(resp) + return versionDetails +} + +suspend fun doEbicsIniRequest( + client: HttpClient, + subscriberDetails: EbicsClientSubscriberDetails +): EbicsKeyManagementResponseContent { + val request = makeEbicsIniRequest(subscriberDetails) + val respStr = client.postToBank( + subscriberDetails.ebicsUrl, + request + ) + val resp = parseAndDecryptEbicsKeyManagementResponse(subscriberDetails, respStr) + return resp +} + +suspend fun doEbicsHiaRequest( + client: HttpClient, + subscriberDetails: EbicsClientSubscriberDetails +): EbicsKeyManagementResponseContent { + val request = makeEbicsHiaRequest(subscriberDetails) + val respStr = client.postToBank( + subscriberDetails.ebicsUrl, + request + ) + val resp = parseAndDecryptEbicsKeyManagementResponse(subscriberDetails, respStr) + return resp +} + + +suspend fun doEbicsHpbRequest( + client: HttpClient, + subscriberDetails: EbicsClientSubscriberDetails +): HpbResponseData { + val request = makeEbicsHpbRequest(subscriberDetails) + val respStr = client.postToBank( + subscriberDetails.ebicsUrl, + request + ) + val parsedResponse = parseAndDecryptEbicsKeyManagementResponse(subscriberDetails, respStr) + val orderData = parsedResponse.orderData ?: throw EbicsProtocolError( + HttpStatusCode.BadGateway, + "Cannot find data in a HPB response" + ) + return parseEbicsHpbOrder(orderData) +} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt @@ -0,0 +1,482 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2020 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/> + */ + +/** + * Handlers for EBICS-related endpoints offered by the nexus for EBICS + * connections. + */ +package tech.libeufin.nexus.ebics + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.ktor.application.Application +import io.ktor.application.ApplicationCall +import io.ktor.application.call +import io.ktor.client.HttpClient +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.request.receive +import io.ktor.request.receiveOrNull +import io.ktor.response.respond +import io.ktor.response.respondText +import io.ktor.routing.Route +import io.ktor.routing.Routing +import io.ktor.routing.post +import io.ktor.util.pipeline.PipelineContext +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.statements.api.ExposedBlob +import org.jetbrains.exposed.sql.transactions.transaction +import tech.libeufin.nexus.* +import tech.libeufin.nexus.logger +import tech.libeufin.util.* +import tech.libeufin.util.ebics_h004.HTDResponseOrderData +import java.util.* +import javax.crypto.EncryptedPrivateKeyInfo + + +private data class EbicsFetchSpec( + val orderType: String, + val orderParams: EbicsOrderParams +) + +suspend fun fetchEbicsBySpec(fetchSpec: FetchSpecJson, client: HttpClient, bankConnectionId: String) { + val subscriberDetails = transaction { getEbicsSubscriberDetails(bankConnectionId) } + val specs = mutableListOf<EbicsFetchSpec>() + when (fetchSpec) { + is FetchSpecLatestJson -> { + val p = EbicsStandardOrderParams() + when (fetchSpec.level) { + FetchLevel.ALL -> { + specs.add(EbicsFetchSpec("C52", p)) + specs.add(EbicsFetchSpec("C53", p)) + } + FetchLevel.REPORT -> { + specs.add(EbicsFetchSpec("C52", p)) + } + FetchLevel.STATEMENT -> { + specs.add(EbicsFetchSpec("C53", p)) + } + } + } + } + for (spec in specs) { + fetchEbicsC5x(spec.orderType, client, bankConnectionId, spec.orderParams, subscriberDetails) + } +} + +/** + * Fetch EBICS C5x and store it locally, but do not update bank accounts. + */ +private suspend fun fetchEbicsC5x( + historyType: String, + client: HttpClient, + bankConnectionId: String, + orderParams: EbicsOrderParams, + subscriberDetails: EbicsClientSubscriberDetails +) { + val response = doEbicsDownloadTransaction( + client, + subscriberDetails, + historyType, + orderParams + ) + when (historyType) { + "C52" -> { + } + "C53" -> { + } + else -> { + throw NexusError(HttpStatusCode.BadRequest, "history type '$historyType' not supported") + } + } + when (response) { + is EbicsDownloadSuccessResult -> { + response.orderData.unzipWithLambda { + logger.debug("Camt entry: ${it.second}") + val camt53doc = XMLUtil.parseStringIntoDom(it.second) + val msgId = camt53doc.pickStringWithRootNs("/*[1]/*[1]/root:GrpHdr/root:MsgId") + logger.info("msg id $msgId") + transaction { + val conn = NexusBankConnectionEntity.findById(bankConnectionId) + if (conn == null) { + throw NexusError(HttpStatusCode.InternalServerError, "bank connection missing") + } + val oldMsg = NexusBankMessageEntity.find { NexusBankMessagesTable.messageId eq msgId }.firstOrNull() + if (oldMsg == null) { + NexusBankMessageEntity.new { + this.bankConnection = conn + this.code = historyType + this.messageId = msgId + this.message = ExposedBlob(it.second.toByteArray(Charsets.UTF_8)) + } + } + } + } + } + is EbicsDownloadBankErrorResult -> { + throw NexusError( + HttpStatusCode.BadGateway, + response.returnCode.errorCode + ) + } + } +} + + +fun createEbicsBankConnectionFromBackup( + bankConnectionName: String, + user: NexusUserEntity, + passphrase: String?, + backup: JsonNode +) { + if (passphrase === null) { + throw NexusError(HttpStatusCode.BadRequest, "EBICS backup needs passphrase") + } + val bankConn = NexusBankConnectionEntity.new(bankConnectionName) { + owner = user + type = "ebics" + } + val ebicsBackup = jacksonObjectMapper().treeToValue(backup, EbicsKeysBackupJson::class.java) + val (authKey, encKey, sigKey) = try { + Triple( + CryptoUtil.decryptKey( + EncryptedPrivateKeyInfo(base64ToBytes(ebicsBackup.authBlob)), + passphrase + ), + CryptoUtil.decryptKey( + EncryptedPrivateKeyInfo(base64ToBytes(ebicsBackup.encBlob)), + passphrase + ), + CryptoUtil.decryptKey( + EncryptedPrivateKeyInfo(base64ToBytes(ebicsBackup.sigBlob)), + passphrase + ) + ) + } catch (e: Exception) { + e.printStackTrace() + logger.info("Restoring keys failed, probably due to wrong passphrase") + throw NexusError( + HttpStatusCode.BadRequest, + "Bad backup given" + ) + } + try { + EbicsSubscriberEntity.new { + ebicsURL = ebicsBackup.ebicsURL + hostID = ebicsBackup.hostID + partnerID = ebicsBackup.partnerID + userID = ebicsBackup.userID + signaturePrivateKey = ExposedBlob(sigKey.encoded) + encryptionPrivateKey = ExposedBlob((encKey.encoded)) + authenticationPrivateKey = ExposedBlob((authKey.encoded)) + nexusBankConnection = bankConn + ebicsIniState = EbicsInitState.UNKNOWN + ebicsHiaState = EbicsInitState.UNKNOWN + } + } catch (e: Exception) { + throw NexusError( + HttpStatusCode.BadRequest, + "exception: $e" + ) + } + return +} + +/** + * Retrieve Ebics subscriber details given a bank connection. + */ +private fun getEbicsSubscriberDetails(bankConnectionId: String): EbicsClientSubscriberDetails { + val transport = NexusBankConnectionEntity.findById(bankConnectionId) + if (transport == null) { + throw NexusError(HttpStatusCode.NotFound, "transport not found") + } + val subscriber = EbicsSubscriberEntity.find { EbicsSubscribersTable.nexusBankConnection eq transport.id }.first() + // transport exists and belongs to caller. + return getEbicsSubscriberDetailsInternal(subscriber) +} + +fun Route.ebicsBankProtocolRoutes(client: HttpClient) { + post("test-host") { + val r = call.receiveJson<EbicsHostTestRequest>() + val qr = doEbicsHostVersionQuery(client, r.ebicsBaseUrl, r.ebicsHostId) + call.respond(HttpStatusCode.OK, qr) + return@post + } +} + +fun Route.ebicsBankConnectionRoutes(client: HttpClient) { + post("/send-ini") { + val subscriber = transaction { + val user = authenticateRequest(call.request) + val conn = requireBankConnection(call, "connid") + if (conn.type != "ebics") { + throw NexusError( + HttpStatusCode.BadRequest, + "bank connection is not of type 'ebics' (but '${conn.type}')" + ) + } + getEbicsSubscriberDetails(conn.id.value) + } + val resp = doEbicsIniRequest(client, subscriber) + call.respond(resp) + } + + post("/send-hia") { + val subscriber = transaction { + val user = authenticateRequest(call.request) + val conn = requireBankConnection(call, "connid") + if (conn.type != "ebics") { + throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") + } + getEbicsSubscriberDetails(conn.id.value) + } + val resp = doEbicsHiaRequest(client, subscriber) + call.respond(resp) + } + + post("/send-hev") { + val subscriber = transaction { + val user = authenticateRequest(call.request) + val conn = requireBankConnection(call, "connid") + if (conn.type != "ebics") { + throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") + } + getEbicsSubscriberDetails(conn.id.value) + } + val resp = doEbicsHostVersionQuery(client, subscriber.ebicsUrl, subscriber.hostId) + call.respond(resp) + } + + post("/send-hpb") { + val subscriberDetails = transaction { + val user = authenticateRequest(call.request) + val conn = requireBankConnection(call, "connid") + if (conn.type != "ebics") { + throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") + } + getEbicsSubscriberDetails(conn.id.value) + } + val hpbData = doEbicsHpbRequest(client, subscriberDetails) + transaction { + val conn = requireBankConnection(call, "connid") + val subscriber = + EbicsSubscriberEntity.find { EbicsSubscribersTable.nexusBankConnection eq conn.id }.first() + subscriber.bankAuthenticationPublicKey = ExposedBlob((hpbData.authenticationPubKey.encoded)) + subscriber.bankEncryptionPublicKey = ExposedBlob((hpbData.encryptionPubKey.encoded)) + } + call.respond(object {}) + } + + /** + * Directly import accounts. Used for testing. + */ + post("/import-accounts") { + val subscriberDetails = transaction { + val user = authenticateRequest(call.request) + val conn = requireBankConnection(call, "connid") + if (conn.type != "ebics") { + throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") + } + getEbicsSubscriberDetails(conn.id.value) + } + val response = doEbicsDownloadTransaction( + client, subscriberDetails, "HTD", EbicsStandardOrderParams() + ) + when (response) { + is EbicsDownloadBankErrorResult -> { + throw NexusError( + HttpStatusCode.BadGateway, + response.returnCode.errorCode + ) + } + is EbicsDownloadSuccessResult -> { + val payload = XMLUtil.convertStringToJaxb<HTDResponseOrderData>( + response.orderData.toString(Charsets.UTF_8) + ) + transaction { + val conn = requireBankConnection(call, "connid") + payload.value.partnerInfo.accountInfoList?.forEach { + val bankAccount = NexusBankAccountEntity.new(id = it.id) { + accountHolder = it.accountHolder ?: "NOT-GIVEN" + iban = extractFirstIban(it.accountNumberList) + ?: throw NexusError(HttpStatusCode.NotFound, reason = "bank gave no IBAN") + bankCode = extractFirstBic(it.bankCodeList) ?: throw NexusError( + HttpStatusCode.NotFound, + reason = "bank gave no BIC" + ) + defaultBankConnection = conn + highestSeenBankMessageId = 0 + } + } + } + response.orderData.toString(Charsets.UTF_8) + } + } + call.respond(object {}) + } + + post("/download/{msgtype}") { + val orderType = requireNotNull(call.parameters["msgtype"]).toUpperCase(Locale.ROOT) + if (orderType.length != 3) { + throw NexusError(HttpStatusCode.BadRequest, "ebics order type must be three characters") + } + val paramsJson = call.receiveOrNull<EbicsStandardOrderParamsDateJson>() + val orderParams = if (paramsJson == null) { + EbicsStandardOrderParams() + } else { + paramsJson.toOrderParams() + } + val subscriberDetails = transaction { + val user = authenticateRequest(call.request) + val conn = requireBankConnection(call, "connid") + if (conn.type != "ebics") { + throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") + } + getEbicsSubscriberDetails(conn.id.value) + } + val response = doEbicsDownloadTransaction( + client, + subscriberDetails, + orderType, + orderParams + ) + when (response) { + is EbicsDownloadSuccessResult -> { + call.respondText( + response.orderData.toString(Charsets.UTF_8), + ContentType.Text.Plain, + HttpStatusCode.OK + ) + } + is EbicsDownloadBankErrorResult -> { + call.respond( + HttpStatusCode.BadGateway, + EbicsErrorJson(EbicsErrorDetailJson("bankError", response.returnCode.errorCode)) + ) + } + } + } +} + +fun exportEbicsKeyBackup(bankConnectionId: String, passphrase: String): Any { + val subscriber = transaction { getEbicsSubscriberDetails(bankConnectionId) } + return EbicsKeysBackupJson( + type = "ebics", + userID = subscriber.userId, + hostID = subscriber.hostId, + partnerID = subscriber.partnerId, + ebicsURL = subscriber.ebicsUrl, + authBlob = bytesToBase64( + CryptoUtil.encryptKey( + subscriber.customerAuthPriv.encoded, + passphrase + ) + ), + encBlob = bytesToBase64( + CryptoUtil.encryptKey( + subscriber.customerEncPriv.encoded, + passphrase + ) + ), + sigBlob = bytesToBase64( + CryptoUtil.encryptKey( + subscriber.customerSignPriv.encoded, + passphrase + ) + ) + ) +} + +suspend fun submitEbicsPaymentInitiation(client: HttpClient, connId: String, pain001Document: String) { + val ebicsSubscriberDetails = transaction { getEbicsSubscriberDetails(connId) } + logger.debug("Uploading PAIN.001: ${pain001Document}") + doEbicsUploadTransaction( + client, + ebicsSubscriberDetails, + "CCT", + pain001Document.toByteArray(Charsets.UTF_8), + EbicsStandardOrderParams() + ) +} + +fun getEbicsConnectionDetails(conn: NexusBankConnectionEntity): Any { + val ebicsSubscriber = transaction { getEbicsSubscriberDetails(conn.id.value) } + val mapper = ObjectMapper() + val details = mapper.createObjectNode() + details.put("ebicsUrl", ebicsSubscriber.ebicsUrl) + details.put("ebicsHostId", ebicsSubscriber.hostId) + details.put("partnerId", ebicsSubscriber.partnerId) + details.put("userId", ebicsSubscriber.userId) + val node = mapper.createObjectNode() + node.put("type", conn.type) + node.put("owner", conn.owner.id.value) + node.set<JsonNode>("details", details) + return node +} + +suspend fun connectEbics(client: HttpClient, connId: String) { + val subscriber = transaction { getEbicsSubscriberDetails(connId) } + if (subscriber.bankAuthPub != null && subscriber.bankEncPub != null) { + return + } + val iniDone = when (subscriber.ebicsIniState) { + EbicsInitState.NOT_SENT, EbicsInitState.UNKNOWN -> { + val iniResp = doEbicsIniRequest(client, subscriber) + iniResp.bankReturnCode == EbicsReturnCode.EBICS_OK && iniResp.technicalReturnCode == EbicsReturnCode.EBICS_OK + } + else -> { + false + } + } + val hiaDone = when (subscriber.ebicsHiaState) { + EbicsInitState.NOT_SENT, EbicsInitState.UNKNOWN -> { + val hiaResp = doEbicsHiaRequest(client, subscriber) + hiaResp.bankReturnCode == EbicsReturnCode.EBICS_OK && hiaResp.technicalReturnCode == EbicsReturnCode.EBICS_OK + } + else -> { + false + } + } + val hpbData = try { + doEbicsHpbRequest(client, subscriber) + } catch (e: EbicsProtocolError) { + logger.warn("failed hpb request", e) + null + } + transaction { + val conn = NexusBankConnectionEntity.findById(connId) + if (conn == null) { + throw NexusError(HttpStatusCode.NotFound, "bank connection '$connId' not found") + } + val subscriberEntity = + EbicsSubscriberEntity.find { EbicsSubscribersTable.nexusBankConnection eq conn.id }.first() + if (iniDone) { + subscriberEntity.ebicsIniState = EbicsInitState.SENT + } + if (hiaDone) { + subscriberEntity.ebicsHiaState = EbicsInitState.SENT + } + if (hpbData != null) { + subscriberEntity.bankAuthenticationPublicKey = + ExposedBlob((hpbData.authenticationPubKey.encoded)) + subscriberEntity.bankEncryptionPublicKey = ExposedBlob((hpbData.encryptionPubKey.encoded)) + } + } +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/taler.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/taler.kt @@ -38,6 +38,7 @@ import org.jetbrains.exposed.dao.Entity import org.jetbrains.exposed.dao.id.IdTable import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.transactions.transaction +import tech.libeufin.nexus.ebics.doEbicsUploadTransaction import tech.libeufin.util.* import kotlin.math.abs import kotlin.math.min @@ -378,6 +379,7 @@ private suspend fun talerAddIncoming(call: ApplicationCall): Unit { } // submits ALL the prepared payments from ALL the Taler facades. +// FIXME(dold): This should not be done here. suspend fun submitPreparedPaymentsViaEbics() { data class EbicsSubmission( val subscriberDetails: EbicsClientSubscriberDetails, diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt @@ -828,6 +828,7 @@ private fun handleEbicsDownloadTransactionInitialization(requestContext: Request "HKD" -> handleEbicsHkd(requestContext) /* Temporarily handling C52/C53 with same logic */ "C53" -> handleEbicsC53(requestContext) + "C52" -> handleEbicsC53(requestContext) "TSD" -> handleEbicsTSD(requestContext) "PTK" -> handleEbicsPTK(requestContext) else -> throw EbicsInvalidXmlError()