libeufin

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

commit 464f489cec0b57d9da38ee2197e3625deb4d01af
parent 45880ad633e9e7fc954233982f22eeed84b02556
Author: Antoine A <>
Date:   Tue,  9 Jul 2024 16:15:41 +0200

common: clean more code and documentation

Diffstat:
Mcommon/src/main/kotlin/helpers.kt | 14++++++++++++--
Mcommon/src/main/kotlin/time.kt | 27---------------------------
Dcommon/src/test/kotlin/TimeTest.kt | 49-------------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt | 189+++++++++++++++++++++++++++++++------------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt | 40+++++-----------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt | 26+++++++-------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt | 19+------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 15+--------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt | 8++++----
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt | 76+++++++++++++++++++++++++---------------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/helpers.kt | 13+++++++++++--
Mnexus/src/test/kotlin/Keys.kt | 5+++--
12 files changed, 143 insertions(+), 338 deletions(-)

diff --git a/common/src/main/kotlin/helpers.kt b/common/src/main/kotlin/helpers.kt @@ -36,7 +36,9 @@ import java.time.format.DateTimeFormatter /* ----- String ----- */ +/** Decode a base64 encoded string */ fun String.decodeBase64(): ByteArray = Base64.getDecoder().decode(this) +/** Decode a hexadecimal uppercase encoded string */ fun String.decodeUpHex(): ByteArray = HexFormat.of().withUpperCase().parseHex(this) fun String.splitOnce(pat: String): Pair<String, String>? { @@ -46,6 +48,14 @@ fun String.splitOnce(pat: String): Pair<String, String>? { return Pair(first, split.next()) } +/** Format a string with a space every two characters */ +fun String.fmtChunkByTwo() = buildString { + this@fmtChunkByTwo.forEachIndexed { pos, c -> + if (pos != 0 && pos % 2 == 0) append(' ') + append(c) + } +} + /* ----- Date ----- */ /** Converting YYYY-MM-DD to Instant */ @@ -88,11 +98,11 @@ inline fun InputStream.unzipEach(lambda: (String, InputStream) -> Unit) { } } -/** Decode a base64 an input stream */ +/** Decode a base64 encoded input stream */ fun InputStream.decodeBase64(): InputStream = Base64.getDecoder().wrap(this) -/** Decode a base64 an input stream */ +/** Encode an input stream as base64 */ fun InputStream.encodeBase64(): String { val w = ByteArrayOutputStream() val encoded = Base64.getEncoder().wrap(w) diff --git a/common/src/main/kotlin/time.kt b/common/src/main/kotlin/time.kt @@ -56,31 +56,4 @@ fun Long.asInstant(): Instant { } catch (e: ArithmeticException ) { throw Exception("$this is too big to be converted to Instant", e) } -} - -/** - * Returns the minimum instant between two. - * - * @param a input [Instant] - * @param b input [Instant] - * @return the minimum [Instant] or null if even one is null. - */ -fun minTimestamp(a: Instant?, b: Instant?): Instant? { - if (a == null || b == null) return null - if (a.isBefore(b)) return a - return b // includes the case where a == b. -} - -/** - * Returns the max instant between two. - * - * @param a input [Instant] - * @param b input [Instant] - * @return the max [Instant] or null if both are null - */ -fun maxTimestamp(a: Instant?, b: Instant?): Instant? { - if (a == null) return b - if (b == null) return a - if (a.isAfter(b)) return a - return b // includes the case where a == b } \ No newline at end of file diff --git a/common/src/test/kotlin/TimeTest.kt b/common/src/test/kotlin/TimeTest.kt @@ -1,48 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. - - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - -import org.junit.Test -import tech.libeufin.common.maxTimestamp -import tech.libeufin.common.minTimestamp -import java.time.Instant -import java.time.temporal.ChronoUnit -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class TimeTest { - @Test - fun cmp() { - val now = Instant.now() - val inOneMinute = now.plus(1, ChronoUnit.MINUTES) - - // testing the "min" function - assertNull(minTimestamp(null, null)) - assertEquals(now, minTimestamp(now, inOneMinute)) - assertNull(minTimestamp(now, null)) - assertNull(minTimestamp(null, now)) - assertEquals(inOneMinute, minTimestamp(inOneMinute, inOneMinute)) - - // testing the "max" function - assertNull(maxTimestamp(null, null)) - assertEquals(inOneMinute, maxTimestamp(now, inOneMinute)) - assertEquals(now, maxTimestamp(now, null)) - assertEquals(now, maxTimestamp(null, now)) - assertEquals(now, minTimestamp(now, now)) - } -} -\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -40,29 +40,7 @@ import kotlin.io.* import kotlin.io.path.* import kotlin.time.toKotlinDuration -/** - * Converts the 2-digits fraction value as given by the bank - * (postfinance dialect), to the Taler 8-digit value (db representation). - * - * @param bankFrac fractional value - * @return the Taler fractional value with at most 8 digits. - */ -private fun makeTalerFrac(bankFrac: String): Int { - if (bankFrac.length > 2) throw Exception("Fractional value has more than 2 digits") - var buf = bankFrac.toIntOrNull() ?: throw Exception("Fractional value not an Int: $bankFrac") - repeat(8 - bankFrac.length) { - buf *= 10 - } - return buf -} - -/** - * Ingests an outgoing payment. It links it to the initiated - * outgoing transaction that originated it. - * - * @param db database handle. - * @param payment payment to (maybe) ingest. - */ +/** Ingests an outgoing [payment] into [db] */ suspend fun ingestOutgoingPayment( db: Database, payment: OutgoingPayment @@ -81,12 +59,9 @@ suspend fun ingestOutgoingPayment( } } -/** - * Ingests an incoming payment. Stores the payment into valid talerable ones - * or bounces it, according to the subject. - * - * @param db database handle. - * @param payment payment to (maybe) ingest. +/** + * Ingest an incoming [payment] into [db] + * Stores the payment into valid talerable ones or bounces it, according to [accountType] . */ suspend fun ingestIncomingPayment( db: Database, @@ -138,118 +113,102 @@ suspend fun ingestIncomingPayment( ) } -private suspend fun ingestDocument( +/** Ingest an EBICS [payload] of [document] into [db] */ +private suspend fun ingestPayload( db: Database, cfg: NexusConfig, - xml: InputStream, - whichDocument: SupportedDocument + payload: InputStream, + document: SupportedDocument ) { - when (whichDocument) { - SupportedDocument.CAMT_052, SupportedDocument.CAMT_053, SupportedDocument.CAMT_054 -> { - try { - parseTx(xml, cfg.currency, cfg.dialect).forEach { - if (cfg.fetch.ignoreBefore != null && it.executionTime < cfg.fetch.ignoreBefore) { - logger.debug("IGNORE {}", it) - } else { - when (it) { - is IncomingPayment -> ingestIncomingPayment(db, it, cfg.accountType) - is OutgoingPayment -> ingestOutgoingPayment(db, it) - is TxNotification.Reversal -> { - logger.error("BOUNCE '${it.msgId}': ${it.reason}") - db.initiated.reversal(it.msgId, "Payment bounced: ${it.reason}") + /** Ingest a single EBICS [xml] [document] into [db] */ + suspend fun ingest(xml: InputStream) { + when (document) { + SupportedDocument.CAMT_052, SupportedDocument.CAMT_053, SupportedDocument.CAMT_054 -> { + try { + parseTx(xml, cfg.currency, cfg.dialect).forEach { + if (cfg.fetch.ignoreBefore != null && it.executionTime < cfg.fetch.ignoreBefore) { + logger.debug("IGNORE {}", it) + } else { + when (it) { + is IncomingPayment -> ingestIncomingPayment(db, it, cfg.accountType) + is OutgoingPayment -> ingestOutgoingPayment(db, it) + is TxNotification.Reversal -> { + logger.error("BOUNCE '${it.msgId}': ${it.reason}") + db.initiated.reversal(it.msgId, "Payment bounced: ${it.reason}") + } } } } + } catch (e: Exception) { + throw Exception("Ingesting notifications failed", e) } - } catch (e: Exception) { - throw Exception("Ingesting notifications failed", e) } - } - SupportedDocument.PAIN_002_LOGS -> { - val acks = parseCustomerAck(xml) - for (ack in acks) { - when (ack.actionType) { - HacAction.ORDER_HAC_FINAL_POS -> { - logger.debug("{}", ack) - db.initiated.logSuccess(ack.orderId!!)?.let { requestUID -> - logger.info("Payment '$requestUID' accepted at ${ack.timestamp.fmtDateTime()}") + SupportedDocument.PAIN_002_LOGS -> { + val acks = parseCustomerAck(xml) + for (ack in acks) { + when (ack.actionType) { + HacAction.ORDER_HAC_FINAL_POS -> { + logger.debug("{}", ack) + db.initiated.logSuccess(ack.orderId!!)?.let { requestUID -> + logger.info("Payment '$requestUID' accepted at ${ack.timestamp.fmtDateTime()}") + } } - } - HacAction.ORDER_HAC_FINAL_NEG -> { - logger.debug("{}", ack) - db.initiated.logFailure(ack.orderId!!)?.let { (requestUID, msg) -> - logger.error("Payment '$requestUID' refused at ${ack.timestamp.fmtDateTime()}${if (msg != null) ": $msg" else ""}") + HacAction.ORDER_HAC_FINAL_NEG -> { + logger.debug("{}", ack) + db.initiated.logFailure(ack.orderId!!)?.let { (requestUID, msg) -> + logger.error("Payment '$requestUID' refused at ${ack.timestamp.fmtDateTime()}${if (msg != null) ": $msg" else ""}") + } } - } - else -> { - logger.debug("{}", ack) - if (ack.orderId != null) { - db.initiated.logMessage(ack.orderId, ack.msg()) + else -> { + logger.debug("{}", ack) + if (ack.orderId != null) { + db.initiated.logMessage(ack.orderId, ack.msg()) + } } } } } - } - SupportedDocument.PAIN_002 -> { - val status = parseCustomerPaymentStatusReport(xml) - val msg = status.msg() - logger.debug("{}", status) - if (status.paymentCode == ExternalPaymentGroupStatusCode.RJCT) { - db.initiated.bankFailure(status.msgId, msg) - logger.error("Transaction '${status.msgId}' was rejected : $msg") - } else { - db.initiated.bankMessage(status.msgId, msg) + SupportedDocument.PAIN_002 -> { + val status = parseCustomerPaymentStatusReport(xml) + val msg = status.msg() + logger.debug("{}", status) + if (status.paymentCode == ExternalPaymentGroupStatusCode.RJCT) { + db.initiated.bankFailure(status.msgId, msg) + logger.error("Transaction '${status.msgId}' was rejected : $msg") + } else { + db.initiated.bankMessage(status.msgId, msg) + } } } } -} - -private suspend fun ingestDocuments( - db: Database, - cfg: NexusConfig, - content: InputStream, - whichDocument: SupportedDocument -) { - when (whichDocument) { + + // Unzip payload if nescessary + when (document) { SupportedDocument.PAIN_002, SupportedDocument.CAMT_052, SupportedDocument.CAMT_053, SupportedDocument.CAMT_054 -> { try { - content.unzipEach { fileName, xmlContent -> + payload.unzipEach { fileName, xml -> logger.trace("parse $fileName") - ingestDocument(db, cfg, xmlContent, whichDocument) + ingest(xml) } } catch (e: IOException) { throw Exception("Could not open any ZIP archive", e) } } - SupportedDocument.PAIN_002_LOGS -> ingestDocument(db, cfg, content, whichDocument) + SupportedDocument.PAIN_002_LOGS -> ingest(payload) } } -/** - * Fetches the banking records via EBICS notifications requests. - * - * It first checks the last execution_time (db column) among the - * incoming transactions. If that's not found, it asks the bank - * about 'unseen notifications' (= does not specify any date range - * in the request). If that's found, it crafts a notification - * request with such execution_time as the start date and now as - * the end date. - * - * What this function does NOT do (now): linking documents between - * different camt.05x formats and/or pain.002 acknowledgements. - * - * @param db database connection - * @param ctx [FetchContext] - * @param pinnedStart explicit start date for the downloaded documents. - * This parameter makes the last incoming transaction timestamp in - * the database IGNORED. Only useful when running in --transient - * mode to download past documents / debug. - * TODO update doc +/** + * Fetch and ingest banking records of type [docs] using EBICS [client] starting from [pinnedStart] + * + * If [pinnedStart] is null fetch new records. + * + * Return true if successful */ -private suspend fun fetchDocuments( +private suspend fun fetchEbicsDocuments( client: EbicsClient, docs: List<EbicsDocument>, pinnedStart: Instant?, @@ -269,12 +228,12 @@ private suspend fun fetchDocuments( order, lastExecutionTime, null - ) { stream -> - val loggedStream = client.fileLogger.logFetch( - stream, + ) { payload -> + val loggedPayload = client.fileLogger.logFetch( + payload, doc == SupportedDocument.PAIN_002_LOGS ) - ingestDocuments(client.db, client.cfg, loggedStream, doc) + ingestPayload(client.db, client.cfg, loggedPayload, doc) } true } catch (e: Exception) { @@ -368,7 +327,7 @@ class EbicsFetch: CliktCommand("Fetches EBICS files") { logger.debug("Pinning start date to: $pinnedStartVal") dateToInstant(pinnedStartVal) } else null - if (!fetchDocuments(client, docs, pinnedStartArg)) { + if (!fetchEbicsDocuments(client, docs, pinnedStartArg)) { throw Exception("Failed to fetch documents") } } else { @@ -377,7 +336,7 @@ class EbicsFetch: CliktCommand("Fetches EBICS files") { logger.warn("Long-polling not implemented, running therefore in transient mode") } do { - fetchDocuments(client, docs, null) + fetchEbicsDocuments(client, docs, null) delay(cfg.fetch.frequency.toKotlinDuration()) } while (cfg.fetch.frequency != Duration.ZERO) } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt @@ -31,14 +31,7 @@ import java.nio.file.* import java.time.Instant import kotlin.io.path.* -/** - * Obtains the client private keys, regardless of them being - * created for the first time, or read from an existing file - * on disk. - * - * @param path path to the file that contains the keys. - * @return current or new client keys - */ +/** Load client private keys at [path] or create new ones if missing */ private fun loadOrGenerateClientKeys(path: Path): ClientPrivateKeysFile { // If exists load from disk val current = loadClientKeys(path) @@ -51,20 +44,6 @@ private fun loadOrGenerateClientKeys(path: Path): ClientPrivateKeysFile { } /** - * @return the "this" string with a space every two characters. - */ -fun String.spaceEachTwo() = - buildString { - this@spaceEachTwo.forEachIndexed { pos, c -> - when { - (pos == 0) -> this.append(c) - (pos % 2 == 0) -> this.append(" $c") - else -> this.append(c) - } - } - } - -/** * Asks the user to accept the bank public keys. * * @param bankKeys bank public keys, in format stored on disk. @@ -74,25 +53,16 @@ private fun askUserToAcceptKeys(bankKeys: BankPublicKeysFile): Boolean { val encHash = CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_encryption_public_key).encodeUpHex() val authHash = CryptoUtil.getEbicsPublicKeyHash(bankKeys.bank_authentication_public_key).encodeUpHex() println("The bank has the following keys:") - println("Encryption key: ${encHash.spaceEachTwo()}") - println("Authentication key: ${authHash.spaceEachTwo()}") + println("Encryption key: ${encHash.fmtChunkByTwo()}") + println("Authentication key: ${authHash.fmtChunkByTwo()}") print("type 'yes, accept' to accept them: ") val userResponse: String? = readlnOrNull() return userResponse == "yes, accept" } /** - * Collects all the steps from generating the message, to - * sending it to the bank, and finally updating the state - * on disk according to the response. - * - * @param cfg handle to the configuration. - * @param privs bundle of all the private keys of the client. - * @param client the http client that requests to the bank. - * @param orderType INI or HIA. - * @param autoAcceptBankKeys only given in case of HPB. Expresses - * the --auto-accept-key CLI flag. - * TODO update doc + * Perform an EBICS key management [order] using [client] and update on disk + * keys */ suspend fun doKeysRequestAndUpdateState( cfg: NexusConfig, diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt @@ -31,14 +31,12 @@ import java.time.* import java.util.* import kotlin.time.toKotlinDuration -/** - * Takes the initiated payment data as it was returned from the - * database, sanity-checks it, gets the pain.001 from the helper - * function and finally submits it via EBICS to the bank. - * - * @param ctx [SubmissionContext] - * @return true on success, false otherwise. - * TODO update doc +/** + * Submit an initiated [payment] using [client]. + * + * Parse creditor IBAN account metadata then perform an EBICS direct credit. + * + * Returns the orderID */ private suspend fun submitInitiatedPayment( client: EbicsClient, @@ -71,17 +69,7 @@ private suspend fun submitInitiatedPayment( ) } -/** - * Searches the database for payments to submit and calls - * the submitter helper. - * - * @param cfg configuration handle. - * @param db database connection. - * @param httpClient HTTP connection handle. - * @param clientKeys subscriber private keys. - * @param bankKeys bank public keys. - * TODO update doc - */ +/** Submit all pending initiated payments using [client] */ private suspend fun submitBatch(client: EbicsClient) { client.db.initiated.submittable(client.cfg.currency).forEach { logger.debug("Submitting payment '${it.requestUid}'") diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -46,24 +46,7 @@ fun getAmountNoCurrency(amount: TalerAmount): String { } } -/** - * Create a pain.001 document. It requires the debtor BIC. - * - * @param requestUid UID of this request, helps to make this request idempotent. - * @param initiationTimestamp timestamp when the payment was initiated in the database. - * Although this is NOT the pain.001 creation timestamp, it - * will help making idempotent requests where one MsgId is - * always associated with one, and only one creation timestamp. - * @param debitAccount [IbanPayto] bank account information of the EBICS subscriber that - * sends this request. It's expected to contain IBAN, BIC, and NAME. - * @param amount amount to pay. The caller is responsible for sanity-checking this - * value to match the bank expectation. For example, that the decimal - * part formats always to at most two digits. - * @param wireTransferSubject wire transfer subject. - * @param creditAccount payment receiver in [IbanPayto]. It should contain IBAN and NAME. - * @return raw pain.001 XML, or throws if the debtor BIC is not found. - * TODO update doc - */ +/** Create a pain.001 XML document valid for [dialect] */ fun createPain001( requestUid: String, initiationTimestamp: Instant, diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -50,29 +50,16 @@ import tech.libeufin.nexus.ebics.EbicsOrder import tech.libeufin.nexus.ebics.EbicsClient import java.nio.file.Path import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.time.format.DateTimeFormatter - internal val logger: Logger = LoggerFactory.getLogger("libeufin-nexus") -/** - * Triple identifying one IBAN bank account. - */ +/** Triple identifying one IBAN bank account */ data class IbanAccountMetadata( val iban: String, val bic: String?, val name: String ) -fun Instant.fmtDate(): String = - DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneId.of("UTC")).format(this) - -fun Instant.fmtDateTime(): String = - DateTimeFormatter.ISO_LOCAL_DATE_TIME.withZone(ZoneId.of("UTC")).format(this) - - fun Application.nexusApi(db: Database, cfg: NexusConfig) = talerApi(logger) { wireGatewayApi(db, cfg) revenueApi(db, cfg) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt @@ -297,7 +297,7 @@ class EbicsBTS( lateinit var bankCode: EbicsReturnCode var orderID: String? = null var segmentNumber: Int? = null - var payloadChunk: ByteArray? = null + var segment: ByteArray? = null var dataEncryptionInfo: DataEncryptionInfo? = null one("header") { one("static") { @@ -312,7 +312,7 @@ class EbicsBTS( } one("body") { opt("DataTransfer") { - payloadChunk = one("OrderData").text().decodeBase64() + segment = one("OrderData").text().decodeBase64() dataEncryptionInfo = opt("DataEncryptionInfo") { DataEncryptionInfo( one("TransactionKey").text().decodeBase64(), @@ -328,7 +328,7 @@ class EbicsBTS( content = BTSResponse( transactionID = transactionID, orderID = orderID, - payloadChunk = payloadChunk, + segment = segment, dataEncryptionInfo = dataEncryptionInfo, numSegments = numSegments, segmentNumber = segmentNumber @@ -343,7 +343,7 @@ class BTSResponse( val transactionID: String?, val orderID: String?, val dataEncryptionInfo: DataEncryptionInfo?, - val payloadChunk: ByteArray?, + val segment: ByteArray?, val segmentNumber: Int?, val numSegments: Int? ) \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt @@ -17,19 +17,6 @@ * <http://www.gnu.org/licenses/> */ -/** - * This file collects the EBICS helpers in the most version-independent way. - * It tries therefore to make the helpers reusable across the EBICS versions 2.x - * and 3.x. - */ - -/** - * NOTE: it has been observed that even with a EBICS 3 server, it - * is still possible to exchange the keys via the EBICS 2.5 protocol. - * That is how this file does, but future versions should implement the - * EBICS 3 keying. - */ - package tech.libeufin.nexus.ebics import io.ktor.client.* @@ -52,9 +39,7 @@ import java.security.interfaces.RSAPrivateCrtKey import java.time.Instant import java.util.* -/** - * Which documents can be downloaded via EBICS. - */ +/** Supported documents that can be downloaded via EBICS */ enum class SupportedDocument { PAIN_002, PAIN_002_LOGS, @@ -63,33 +48,7 @@ enum class SupportedDocument { CAMT_054 } -/** - * Decrypts and decompresses the business payload that was - * transported within an EBICS message from the bank - * - * @param clientEncryptionKey client private encryption key, used to decrypt - * the transaction key. The transaction key is the - * one actually used to encrypt the payload. - * @param encryptionInfo details related to the encrypted payload. - * @param chunks the several chunks that constitute the whole encrypted payload. - * @return the plain payload. - */ -fun decryptAndDecompressPayload( - clientEncryptionKey: RSAPrivateCrtKey, - encryptionInfo: DataEncryptionInfo, - chunks: List<ByteArray> -): InputStream { - val transactionKey = CryptoUtil.decryptEbicsE002Key(clientEncryptionKey, encryptionInfo.transactionKey) - return SequenceInputStream(Collections.enumeration(chunks.map { it.inputStream() })) // Aggregate - .run { - CryptoUtil.decryptEbicsE002( - transactionKey, - this - ) - }.inflate() -} - - +/** EBICS related errors */ sealed class EbicsError(msg: String, cause: Throwable? = null): Exception(msg, cause) { /** Http and network errors */ class Transport(msg: String, cause: Throwable? = null): EbicsError(msg, cause) @@ -198,7 +157,7 @@ class EbicsClient( val howManySegments = requireNotNull(initContent.numSegments) { "Download init phase: missing num segments" } - val firstDataChunk = requireNotNull(initContent.payloadChunk) { + val firstSegment = requireNotNull(initContent.segment) { "Download init phase: missing OrderData" } val dataEncryptionInfo = requireNotNull(initContent.dataEncryptionInfo) { @@ -208,14 +167,14 @@ class EbicsClient( logger.debug("Download init phase for transaction '$tId'") // Transfer phase - val ebicsChunks = mutableListOf(firstDataChunk) + val segments = mutableListOf(firstSegment) for (x in 2 .. howManySegments) { val transReq = impl.downloadTransfer(x, howManySegments, tId) val transResp = impl.postBTS(client, transReq, "Download transfer phase").okOrFail("Download transfer phase") - val chunk = requireNotNull(transResp.payloadChunk) { - "Download transfer phase: missing encrypted chunk" + val segment = requireNotNull(transResp.segment) { + "Download transfer phase: missing encrypted segment" } - ebicsChunks.add(chunk) + segments.add(segment) } // Decompress encrypted chunks @@ -223,7 +182,7 @@ class EbicsClient( decryptAndDecompressPayload( clientKeys.encryption_private_key, dataEncryptionInfo, - ebicsChunks + segments ) } catch (e: Exception) { throw EbicsError.Protocol("invalid chunks", e) @@ -292,7 +251,7 @@ class PreparedUploadData( val segments: List<String> ) -/** Signs, encrypts and format data to send via EBICS */ +/** Signs, encrypts and format EBICS BTS payload */ fun prepareUploadPayload( cfg: NexusConfig, clientKeys: ClientPrivateKeysFile, @@ -335,8 +294,23 @@ fun prepareUploadPayload( ) } -private val SECURE_RNG = SecureRandom() +/** Decrypts and decompresses EBICS BTS payload */ +fun decryptAndDecompressPayload( + clientEncryptionKey: RSAPrivateCrtKey, + encryptionInfo: DataEncryptionInfo, + segments: List<ByteArray> +): InputStream { + val transactionKey = CryptoUtil.decryptEbicsE002Key(clientEncryptionKey, encryptionInfo.transactionKey) + return SequenceInputStream(Collections.enumeration(segments.map { it.inputStream() })) // Aggregate + .run { + CryptoUtil.decryptEbicsE002( + transactionKey, + this + ) + }.inflate() +} +private val SECURE_RNG = SecureRandom() /** Generate a secure random nonce of [size] bytes */ fun getNonce(size: Int): ByteArray { diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/helpers.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/helpers.kt @@ -21,6 +21,10 @@ package tech.libeufin.nexus import io.ktor.client.* import io.ktor.client.plugins.* +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter /** Create an HTTP client for EBICS requests */ fun httpClient(): HttpClient = HttpClient { @@ -28,4 +32,10 @@ fun httpClient(): HttpClient = HttpClient { // It can take a lot of time for the bank to generate documents socketTimeoutMillis = 5 * 60 * 1000 } -} -\ No newline at end of file +} + +fun Instant.fmtDate(): String = + DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneId.of("UTC")).format(this) + +fun Instant.fmtDateTime(): String = + DateTimeFormatter.ISO_LOCAL_DATE_TIME.withZone(ZoneId.of("UTC")).format(this) diff --git a/nexus/src/test/kotlin/Keys.kt b/nexus/src/test/kotlin/Keys.kt @@ -19,6 +19,7 @@ import org.junit.Test import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.common.* import tech.libeufin.nexus.* import kotlin.io.path.Path import kotlin.io.path.deleteIfExists @@ -33,8 +34,8 @@ class PublicKeys { // Tests intermittent spaces in public keys fingerprint. @Test fun splitTest() { - assertEquals("0099887766".spaceEachTwo(), "00 99 88 77 66") // even - assertEquals("ZZYYXXWWVVU".spaceEachTwo(), "ZZ YY XX WW VV U") // odd + assertEquals("0099887766".fmtChunkByTwo(), "00 99 88 77 66") // even + assertEquals("ZZYYXXWWVVU".fmtChunkByTwo(), "ZZ YY XX WW VV U") // odd } // Tests loading the bank public keys from disk.