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 }