diff options
-rwxr-xr-x | integration-tests/test-ebics-highlevel.py | 2 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/Helpers.kt | 127 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/JSON.kt | 2 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 383 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsClient.kt (renamed from nexus/src/main/kotlin/tech/libeufin/nexus/EbicsClient.kt) | 3 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt | 482 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/taler.kt | 2 | ||||
-rw-r--r-- | sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt | 1 |
8 files changed, 543 insertions, 459 deletions
diff --git a/integration-tests/test-ebics-highlevel.py b/integration-tests/test-ebics-highlevel.py index f78f09ff..bbdfe53f 100755 --- 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/Helpers.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Helpers.kt index b57dc26a..04942252 100644 --- 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 index 0ec844a6..c5d7c3a3 100644 --- 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 index e270e284..8ca4b9f6 100644 --- 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/EbicsClient.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsClient.kt index f23ad843..1cdabc9a 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsClient.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsClient.kt @@ -20,11 +20,12 @@ /** * High-level interface for the EBICS protocol. */ -package tech.libeufin.nexus +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.* diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt new file mode 100644 index 00000000..5fc83f24 --- /dev/null +++ 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 index 33a71530..85e9cc81 100644 --- 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 index c5583094..f827ce74 100644 --- 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() |