libeufin

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

EbicsCommon.kt (15294B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2024, 2025, 2026 Taler Systems S.A.
      4 
      5  * LibEuFin is free software; you can redistribute it and/or modify
      6  * it under the terms of the GNU Affero General Public License as
      7  * published by the Free Software Foundation; either version 3, or
      8  * (at your option) any later version.
      9 
     10  * LibEuFin is distributed in the hope that it will be useful, but
     11  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12  * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13  * Public License for more details.
     14 
     15  * You should have received a copy of the GNU Affero General Public
     16  * License along with LibEuFin; see the file COPYING.  If not, see
     17  * <http://www.gnu.org/licenses/>
     18  */
     19 
     20 package tech.libeufin.ebics
     21 
     22 import io.ktor.client.*
     23 import io.ktor.client.request.*
     24 import io.ktor.client.statement.*
     25 import io.ktor.http.*
     26 import io.ktor.utils.io.jvm.javaio.*
     27 import org.slf4j.Logger
     28 import org.slf4j.LoggerFactory
     29 import kotlinx.coroutines.NonCancellable
     30 import kotlinx.coroutines.withContext
     31 import org.w3c.dom.Document
     32 import org.xml.sax.SAXException
     33 import tech.libeufin.common.*
     34 import tech.libeufin.common.crypto.CryptoUtil
     35 import java.io.InputStream
     36 import java.io.SequenceInputStream
     37 import java.security.interfaces.RSAPrivateCrtKey
     38 import java.time.Instant
     39 import java.util.*
     40 
     41 internal val logger: Logger = LoggerFactory.getLogger("libeufin-ebics")
     42 
     43 /** Supported documents that can be downloaded via EBICS  */
     44 enum class SupportedDocument {
     45     PAIN_002,
     46     PAIN_002_LOGS,
     47     CAMT_053,
     48     CAMT_052,
     49     CAMT_054
     50 }
     51 
     52 /** EBICS related errors */
     53 sealed class EbicsError(msg: String, cause: Throwable? = null): Exception(msg, cause) {
     54     /** Network errors */
     55     class Network(msg: String, cause: Throwable): EbicsError(msg, cause)
     56     /** Http errors */
     57     class HTTP(msg: String, val status: HttpStatusCode): EbicsError(msg)
     58     /** EBICS protocol & XML format error */
     59     class Protocol(msg: String, cause: Throwable? = null): EbicsError(msg, cause)
     60     /** EBICS protocol & XML format error */
     61     class Code(msg: String, val technicalCode: EbicsReturnCode, val bankCode: EbicsReturnCode): EbicsError(msg)
     62 }
     63 
     64 /** POST an EBICS request [msg] to [bankUrl] returning a parsed XML response */
     65 suspend fun HttpClient.postToBank(
     66     bankUrl: String, 
     67     msg: ByteArray,
     68     phase: String,
     69     stepLogger: StepLogger
     70 ): Document {
     71     stepLogger.logRequest(msg)
     72     val res = try {
     73         post(urlString = bankUrl) {
     74             contentType(ContentType.Text.Xml)
     75             setBody(msg)
     76         }
     77     } catch (e: Exception) {
     78         throw EbicsError.Network("$phase: failed to contact bank", e)
     79     }
     80     
     81     if (res.status != HttpStatusCode.OK) {
     82         stepLogger.logFailure(res)
     83         throw EbicsError.HTTP("$phase: bank HTTP error: ${res.status}", res.status)
     84     }
     85     try {
     86         val bodyStream = res.bodyAsChannel().toInputStream()
     87         val loggedStream = stepLogger.logResponse(bodyStream)
     88         return XMLUtil.parseIntoDom(loggedStream)
     89     } catch (e: SAXException) {
     90         throw EbicsError.Protocol("$phase: invalid XML bank response", e)
     91     } catch (e: Exception) {
     92         throw EbicsError.Network("$phase: failed read bank response", e)
     93     }
     94 }
     95 
     96 /** POST an EBICS BTS request [xmlReq] using [client] returning a validated and parsed XML response */
     97 suspend fun EbicsBTS.postBTS(
     98     client: HttpClient,
     99     xmlReq: ByteArray,
    100     phase: String,
    101     stepLogger: StepLogger
    102 ): BTSResponse {
    103     val doc = client.postToBank(cfg.baseUrl, xmlReq, phase, stepLogger)
    104     try {
    105         XMLUtil.verifyEbicsDocument(
    106             doc,
    107             bankKeys.bank_authentication_public_key
    108         )
    109     } catch (e: Exception) {
    110         throw EbicsError.Protocol("$phase ${order.description()}: invalid signature", e)
    111     }
    112     val response = try {
    113         EbicsBTS.parseResponse(doc)
    114     } catch (e: Exception) {
    115         throw EbicsError.Protocol("$phase ${order.description()}: invalid ebics response", e)
    116     }
    117     logger.debug {
    118         buildString {
    119             append(phase)
    120             response.content.transactionID?.let {
    121                 append(" for ")
    122                 append(it)
    123             }
    124             append(": ")
    125             append(response.technicalCode)
    126             append(" & ")
    127             append(response.bankCode)
    128         }
    129     }
    130     return response.okOrFail(phase)
    131 }
    132 
    133 /** High level EBICS client */
    134 class EbicsClient(
    135     val cfg: EbicsHostConfig,
    136     val client: HttpClient,
    137     val dao: EbicsDAO,
    138     val ebicsLogger: EbicsLogger,
    139     val clientKeys: ClientPrivateKeysFile,
    140     val bankKeys: BankPublicKeysFile
    141 ) {
    142     /** 
    143      * Performs an EBICS download transaction of [order] between [startDate] and [endDate].
    144      * Download content is passed to [processing]
    145      * 
    146      * It conducts init -> transfer -> processing -> receipt phases.
    147      * 
    148      * Cancellations and failures are handled.
    149      */
    150     suspend fun <T> download(
    151         order: EbicsOrder,
    152         startDate: Instant? = null,
    153         endDate: Instant? = null,
    154         peek: Boolean = false,
    155         processing: suspend (InputStream) -> T,
    156     ): T {
    157         val description = order.description()
    158         logger.debug {
    159             buildString {
    160                 append("Download order ")
    161                 append(description)
    162                 if (startDate != null) {
    163                     append(" from ")
    164                     append(startDate)
    165                     if (endDate != null) {
    166                         append(" to ")
    167                         append(endDate)
    168                     }
    169                 }
    170             }
    171         }
    172         val impl = EbicsBTS(cfg, bankKeys, clientKeys, order)
    173 
    174         // Close interrupted
    175         val interruptedLog = ebicsLogger.tx("INTD")
    176         while (true) {
    177             val tId = dao.first()
    178             if (tId == null) break
    179             val xml = impl.downloadReceipt(tId, false)
    180             try {
    181                 impl.postBTS(client, xml, "Closing interrupted transaction ${tId}", interruptedLog.step(tId))
    182             } catch (e: Exception) {
    183                 when (e) {
    184                     // Transaction already closed or expired - EBICS protocol error
    185                     is EbicsError.Code if e.technicalCode == EbicsReturnCode.EBICS_TX_UNKNOWN_TXID -> {}
    186                     // Transaction already closed or expired - HTTP protocol error for non compliant banks
    187                     is EbicsError.HTTP if e.status == HttpStatusCode.BadRequest -> {}
    188                     // Unexpected error
    189                     else -> throw e
    190                 }
    191                 logger.debug("${e.fmt()}")
    192             }
    193             dao.remove(tId)
    194         }
    195 
    196         val txLog = ebicsLogger.tx(order)
    197 
    198         // We need to run the logic in a non-cancelable context because we need to send 
    199         // a receipt for each open download transaction, otherwise we'll be stuck in an 
    200         // error loop until the pending transaction timeout.
    201         val (tId, initContent) = withContext(NonCancellable) {
    202             // Init phase
    203             val initReq = impl.downloadInitialization(startDate, endDate)
    204             val initContent = impl.postBTS(client, initReq, "Download init $description", txLog.step("init"))
    205             val tId = requireNotNull(initContent.transactionID) {
    206                 "Download init $description: missing transaction ID"
    207             }
    208             dao.register(tId)
    209             Pair(tId, initContent)
    210         }
    211         val howManySegments = requireNotNull(initContent.numSegments) {
    212             "Download init $description: missing num segments"
    213         }
    214         val firstSegment = requireNotNull(initContent.segment) {
    215             "Download init $description: missing OrderData"
    216         }
    217         val dataEncryptionInfo = requireNotNull(initContent.dataEncryptionInfo) {
    218             "Download init $description: missing EncryptionInfo"
    219         }
    220 
    221         // Transfer phase
    222         val segments = mutableListOf(firstSegment)
    223         for (x in 2 .. howManySegments) {
    224             val transReq = impl.downloadTransfer(x, howManySegments, tId)
    225             val transResp = impl.postBTS(client, transReq, "Download transfer $description", txLog.step("transfer$x"))
    226             val segment = requireNotNull(transResp.segment) {
    227                 "Download transfer: missing encrypted segment"
    228             }
    229             segments.add(segment)
    230         }
    231 
    232 
    233         // Decompress encrypted chunks
    234         val payloadStream = try {
    235             decryptAndDecompressPayload(
    236                 clientKeys.encryption_private_key,
    237                 dataEncryptionInfo,
    238                 segments
    239             )
    240         } catch (e: Exception) {
    241             throw EbicsError.Protocol("invalid chunks", e)
    242         }
    243 
    244         val container = when (order) {
    245             is EbicsOrder.V2_5 -> "rax" // TODO infer ?
    246             is EbicsOrder.V3 -> order.container ?: "xml"
    247         }
    248         val loggedStream = txLog.payload(payloadStream, container)
    249 
    250         // Run business logic
    251         val res = runCatching {
    252             processing(loggedStream)
    253         }
    254 
    255         // First send a proper EBICS transaction receipt
    256         val xml = impl.downloadReceipt(tId, res.isSuccess && !peek)
    257         impl.postBTS(client, xml, "Download receipt $description", txLog.step("receipt"))
    258         runCatching { dao.remove(tId) }
    259         // Then throw business logic exception if any
    260         return res.getOrThrow()
    261     }
    262 
    263     /** 
    264      * Performs an EBICS upload transaction of [order] using [payload].
    265      * 
    266      * It conducts init -> upload phases.
    267      * 
    268      * Returns upload orderID
    269      */
    270     suspend fun upload(
    271         order: EbicsOrder,
    272         payload: ByteArray,
    273     ): String {
    274         val description = order.description();
    275         logger.debug { "Upload order $description" }
    276         val txLog = ebicsLogger.tx(order)
    277         val impl = EbicsBTS(cfg, bankKeys, clientKeys, order)
    278         val preparedPayload = prepareUploadPayload(cfg, clientKeys, bankKeys, payload)
    279         
    280         txLog.payload(payload, "xml")
    281 
    282         // Init phase
    283         val initXml = impl.uploadInitialization(preparedPayload)
    284         val initResp = impl.postBTS(client, initXml, "Upload init $description", txLog.step("init"))
    285         val tId = requireNotNull(initResp.transactionID) {
    286             "Upload init $description: missing transaction ID"
    287         }
    288         val orderId = requireNotNull(initResp.orderID) {
    289             "Upload init $description: missing order ID"
    290         }
    291 
    292         // Transfer phase
    293         for (i in 1..preparedPayload.segments.size) {
    294             val transferXml = impl.uploadTransfer(tId, preparedPayload, i)
    295             impl.postBTS(client, transferXml, "Upload transfer $description", txLog.step("transfer$i"))
    296         }
    297         return orderId
    298     } 
    299 }
    300 
    301 suspend fun HEV(
    302     client: HttpClient,
    303     cfg: EbicsHostConfig,
    304     ebicsLogger: EbicsLogger
    305 ): List<VersionNumber> {
    306     logger.info("Doing administrative request HEV")
    307     val txLog = ebicsLogger.tx("HEV")
    308     val req = EbicsAdministrative.HEV(cfg)
    309     val xml = client.postToBank(cfg.baseUrl, req, "HEV", txLog.step())
    310     return EbicsAdministrative.parseHEV(xml).okOrFail("HEV")
    311 }
    312 
    313 suspend fun keyManagement(
    314     cfg: EbicsHostConfig,
    315     privs: ClientPrivateKeysFile,
    316     client: HttpClient,
    317     ebicsLogger: EbicsLogger,
    318     order: EbicsKeyMng.Order,
    319     ebics3: Boolean
    320 ): EbicsResponse<InputStream?> {
    321     logger.info("Doing key request $order")
    322     val txLog = ebicsLogger.tx(order.name)
    323     // TODO is this still necessary ?
    324     val req = EbicsKeyMng(cfg, privs, ebics3).request(order)
    325     val xml = client.postToBank(cfg.baseUrl, req, order.name, txLog.step())
    326     return EbicsKeyMng.parseResponse(xml, privs.encryption_private_key)
    327 }
    328 
    329 class PreparedUploadData(
    330     val transactionKey: ByteArray,
    331     val userSignatureDataEncrypted: String,
    332     val dataDigest: ByteArray,
    333     val segments: List<String>
    334 )
    335 
    336 /** Signs, encrypts and format EBICS BTS payload */
    337 fun prepareUploadPayload(
    338     cfg: EbicsHostConfig,
    339     clientKeys: ClientPrivateKeysFile,
    340     bankKeys: BankPublicKeysFile,
    341     payload: ByteArray,
    342 ): PreparedUploadData {
    343     val payloadDigest = CryptoUtil.digestEbicsOrderA006(payload)
    344     val innerSignedEbicsXml = XmlBuilder.toBytes("UserSignatureData") {
    345         attr("xmlns", "http://www.ebics.org/S002")
    346         el("OrderSignatureData") {
    347             el("SignatureVersion", "A006")
    348             el("SignatureValue", CryptoUtil.signEbicsA006(
    349                 payloadDigest,
    350                 clientKeys.signature_private_key,
    351             ).encodeBase64())
    352             el("PartnerID", cfg.partnerId)
    353             el("UserID", cfg.userId)
    354         }
    355     }
    356     // Generate ephemeral transaction key
    357     val (transactionKey, encryptedTransactionKey) = CryptoUtil.genEbicsE002Key(bankKeys.bank_encryption_public_key)
    358     // Compress and encrypt order signature
    359     val orderSignature = CryptoUtil.encryptEbicsE002(
    360         transactionKey,
    361         innerSignedEbicsXml.inputStream().deflate()
    362     ).encodeBase64()
    363     // Compress and encrypt payload
    364     val encrypted = CryptoUtil.encryptEbicsE002(
    365         transactionKey,
    366         payload.inputStream().deflate()
    367     )
    368     // Chunks of 1MB and encode segments
    369     val segments = encrypted.encodeBase64().chunked(1000000)
    370 
    371     return PreparedUploadData(
    372         encryptedTransactionKey,
    373         orderSignature,
    374         payloadDigest,
    375         segments
    376     )
    377 }
    378 
    379 /** Decrypts and decompresses EBICS BTS payload */
    380 fun decryptAndDecompressPayload(
    381     clientEncryptionKey: RSAPrivateCrtKey,
    382     encryptionInfo: DataEncryptionInfo,
    383     segments: List<ByteArray>
    384 ): InputStream {
    385     val transactionKey = CryptoUtil.decryptEbicsE002Key(clientEncryptionKey, encryptionInfo.transactionKey)
    386     return SequenceInputStream(Collections.enumeration(segments.map { it.inputStream() })) // Aggregate
    387         .run {
    388             CryptoUtil.decryptEbicsE002(
    389                 transactionKey,
    390                 this
    391             )
    392         }.inflate()
    393 }
    394 
    395 /** Generate a secure random nonce of [size] bytes */
    396 fun getNonce(size: Int): ByteArray {
    397     return ByteArray(size / 8).secureRand()
    398 }
    399 
    400 private val EBICS_ID_ALPHABET = ('A'..'Z') + ('0'..'9')
    401 
    402 fun randEbicsId(): String {
    403     return List(34) { EBICS_ID_ALPHABET.random() }.joinToString("")
    404 }
    405 
    406 class DataEncryptionInfo(
    407     val transactionKey: ByteArray,
    408     val bankPubDigest: ByteArray
    409 )
    410 
    411 class EbicsResponse<T>(
    412     val technicalCode: EbicsReturnCode,
    413     val bankCode: EbicsReturnCode,
    414     internal val content: T
    415 ) {
    416     /** Checks that return codes are both EBICS_OK */
    417     fun ok(): T? {
    418         return if (technicalCode.kind() != EbicsReturnCode.Kind.Error &&
    419             bankCode.kind() != EbicsReturnCode.Kind.Error) {
    420                 content
    421         } else {
    422             null
    423         }
    424     }
    425 
    426     /** Checks that return codes are both EBICS_OK or throw an exception */
    427     fun okOrFail(phase: String): T {
    428         if (technicalCode.kind() == EbicsReturnCode.Kind.Error) {
    429             throw EbicsError.Code("$phase has technical error: $technicalCode", technicalCode, bankCode)
    430         } else if (bankCode.kind() == EbicsReturnCode.Kind.Error) {
    431             throw EbicsError.Code("$phase has bank error: $bankCode", technicalCode, bankCode)
    432         } else {
    433             return content
    434         }
    435     }
    436 }