libeufin

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

commit 5b3c695a08f029befabd8f8eca3ce7a319428765
parent a2d1516cfbfe61f07c4db0d3bd208a901ce9dd92
Author: Antoine A <>
Date:   Tue, 30 Jun 2026 13:58:57 +0200

common: prepared transfer new signatures

Diffstat:
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/api/PreparedTransferApi.kt | 16++++++++--------
Mlibeufin-bank/src/test/kotlin/PreparedTransferApiTest.kt | 210+++++++++++++++++++++++++++----------------------------------------------------
Mlibeufin-bank/src/test/kotlin/WireGatewayApiTest.kt | 20++++++++++----------
Mlibeufin-bank/src/test/kotlin/bench.kt | 4++--
Mlibeufin-common/src/main/kotlin/TalerCommon.kt | 15+++++++++++++++
Mlibeufin-common/src/main/kotlin/TalerMessage.kt | 49++++++++++++++++++++++++++++++++++++++++++++++---
Mlibeufin-common/src/main/kotlin/client.kt | 11++++++++++-
Mlibeufin-common/src/main/kotlin/crypto/CryptoUtil.kt | 47+++++++++++++++++++++++++++++++----------------
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/PreparedTransferApi.kt | 13++++++-------
Mlibeufin-nexus/src/test/kotlin/PreparedTransferApiTest.kt | 173++++++++++++++++++++++++++++++++++---------------------------------------------
Mlibeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt | 20++++++++++----------
Mlibeufin-nexus/src/test/kotlin/bench.kt | 4++--
12 files changed, 287 insertions(+), 295 deletions(-)

diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/PreparedTransferApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/PreparedTransferApi.kt @@ -65,11 +65,11 @@ fun Routing.preparedTransferApi(db: Database, cfg: BankConfig) { code(HttpStatusCode.Conflict) { description = "Reserve pub or subject derivation already used" } } }) { - val req = call.receive<SubjectRequest>(); + val req = call.receive<SubjectRequest>() cfg.checkRegionalCurrency(req.credit_amount) - if (!CryptoUtil.checkEdssaSignature(req.account_pub.raw, req.authorization_sig, req.authorization_pub)) - throw conflict( + if (!req.verify()) + throw forbidden( "invalid signature", TalerErrorCode.BANK_BAD_SIGNATURE ) @@ -115,17 +115,17 @@ fun Routing.preparedTransferApi(db: Database, cfg: BankConfig) { } }) { val req = call.receive<Unregistration>(); + + logger.error("${req.nbo().joinToString(", ") { (it.toInt() and 0xFF).toString() }}") - val timestamp = Instant.parse(req.timestamp) - - if (timestamp.isBefore(Instant.now().minus(Duration.ofMinutes(15)))) + if (req.timestamp.instant.isBefore(Instant.now().minus(Duration.ofMinutes(15)))) throw conflict( "timestamp too old", TalerErrorCode.BANK_OLD_TIMESTAMP ) - if (!CryptoUtil.checkEdssaSignature(req.timestamp.toByteArray(), req.authorization_sig, req.authorization_pub)) - throw conflict( + if (!req.verify()) + throw forbidden( "invalid signature", TalerErrorCode.BANK_BAD_SIGNATURE ) diff --git a/libeufin-bank/src/test/kotlin/PreparedTransferApiTest.kt b/libeufin-bank/src/test/kotlin/PreparedTransferApiTest.kt @@ -37,22 +37,22 @@ class PreparedTransferApiTest { fun registration() = bankSetup { val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() val amount = TalerAmount("KUDOS:1") - val valid_req = obj { - "credit_account" to exchangePayto - "credit_amount" to amount - "type" to "reserve" - "alg" to "EdDSA" - "account_pub" to pub - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) - "recurrent" to false - } + val valid_req = SubjectRequest( + exchangePayto, + TransferType.reserve, + false, + amount, + PublicKeyAlg.EdDSA, + pub, + pub, + EddsaSignature.rand() + ) val simpleSubject = TransferSubject.Simple("Taler MAP:$pub", amount) // Valid val subjects = client.post("/taler-prepared-transfer/registration") { - json(valid_req) + json(valid_req.sign(priv)) }.assertOkJson<SubjectResult> { assertEquals(it.subjects[1], simpleSubject) assertIs<TransferSubject.Uri>(it.subjects[0]) @@ -60,16 +60,14 @@ class PreparedTransferApiTest { // Idempotent client.post("/taler-prepared-transfer/registration") { - json(valid_req) + json(valid_req.sign(priv)) }.assertOkJson<SubjectResult> { assertEquals(it.subjects, subjects) } // KYC has a different withdrawal uri client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "type" to "kyc" - } + json(valid_req.copy(type = TransferType.kyc).sign(priv)) }.assertOkJson<SubjectResult> { assertEquals(it.subjects[1], simpleSubject) val uriSubject = assertIs<TransferSubject.Uri>(it.subjects[0]) @@ -78,32 +76,24 @@ class PreparedTransferApiTest { // Recurrent only has simple subject client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "recurrent" to true - } + json(valid_req.copy(recurrent = true).sign(priv)) }.assertOkJson<SubjectResult> { assertEquals(it.subjects, listOf(simpleSubject)) } // Bad signature client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "authorization_sig" to EddsaSignature.rand() - } - }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) + json(valid_req) + }.assertForbidden(TalerErrorCode.BANK_BAD_SIGNATURE) // Not exchange client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "credit_account" to merchantPayto - } + json(valid_req.copy(credit_account = merchantPayto).sign(priv)) }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE) // Unknown account client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "credit_account" to unknownPayto - } + json(valid_req.copy(credit_account = unknownPayto).sign(priv)) }.assertConflict(TalerErrorCode.BANK_UNKNOWN_CREDITOR) assertBalance("customer", "+KUDOS:0") @@ -111,9 +101,7 @@ class PreparedTransferApiTest { // Non recurrent accept on then bounce client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "type" to "reserve" - } + json(valid_req.sign(priv)) }.assertOkJson<SubjectResult> { val uuid = (it.subjects[0] as? TransferSubject.Uri)!!.uri.substringAfterLast('/') client.postA("/accounts/customer/withdrawals/$uuid/confirm").assertNoContent() // reserve @@ -125,9 +113,7 @@ class PreparedTransferApiTest { // Withdrawal is aborted on completion client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "type" to "kyc" - } + json(valid_req.copy(type = TransferType.kyc).sign(priv)) }.assertOkJson<SubjectResult> { val uuid = (it.subjects[0] as? TransferSubject.Uri)!!.uri.substringAfterLast('/') println("UUID $uuid") @@ -142,11 +128,7 @@ class PreparedTransferApiTest { // Recurrent accept one and delay others val newKey = EddsaPublicKey.randEdsaKey() client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "account_pub" to newKey - "authorization_sig" to CryptoUtil.eddsaSign(newKey.raw, priv) - "recurrent" to true - } + json(valid_req.copy(account_pub = newKey, recurrent = true).sign(priv)) } tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // reserve tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending @@ -159,20 +141,10 @@ class PreparedTransferApiTest { // Complete pending on recurrent update val kycKey = EddsaPublicKey.randEdsaKey() client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "type" to "kyc" - "account_pub" to kycKey - "authorization_sig" to CryptoUtil.eddsaSign(kycKey.raw, priv) - "recurrent" to true - } + json(valid_req.copy(type= TransferType.kyc, account_pub = kycKey, recurrent = true).sign(priv)) }.assertOkJson<SubjectResult>() client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "type" to "reserve" - "account_pub" to kycKey - "authorization_sig" to CryptoUtil.eddsaSign(kycKey.raw, priv) - "recurrent" to true - } + json(valid_req.copy(account_pub = kycKey, recurrent = true).sign(priv)) }.assertOkJson<SubjectResult>() assertBalance("customer", "-KUDOS:7") assertBalance("exchange", "+KUDOS:7") @@ -184,11 +156,7 @@ class PreparedTransferApiTest { // Switching to non recurrent cancel pending client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "type" to "kyc" - "account_pub" to kycKey - "authorization_sig" to CryptoUtil.eddsaSign(kycKey.raw, priv) - } + json(valid_req.copy(type= TransferType.kyc, account_pub = kycKey).sign(priv)) }.assertOkJson<SubjectResult>() assertBalance("customer", "-KUDOS:6") assertBalance("exchange", "+KUDOS:6") @@ -196,38 +164,19 @@ class PreparedTransferApiTest { // Check authorization field in incoming history val (testPriv, testAuth) = EddsaPublicKey.randEdsaKeyPair() val testKey = EddsaPublicKey.randEdsaKey() - val testSig = CryptoUtil.eddsaSign(testKey.raw, testPriv) val qr = subjectFmtQrBill(testAuth) client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "account_pub" to testKey - "authorization_pub" to testAuth - "authorization_sig" to testSig - "recurrent" to true - } + json(valid_req.copy(account_pub=testKey, authorization_pub=testAuth, recurrent=true).sign(testPriv)) }.assertOkJson<SubjectResult>() tx("customer", "KUDOS:0.1", "exchange", "Taler MAP:$testAuth") tx("customer", "KUDOS:0.1", "exchange", "Taler MAP:$testAuth") tx("customer", "KUDOS:0.1", "exchange", "Taler MAP:$testAuth") client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "type" to "kyc" - "account_pub" to testKey - "authorization_pub" to testAuth - "authorization_sig" to testSig - "recurrent" to true - } + json(valid_req.copy(type=TransferType.kyc, account_pub=testKey, authorization_pub=testAuth, recurrent=true).sign(testPriv)) }.assertOkJson<SubjectResult>() val otherPub = EddsaPublicKey.randEdsaKey() - val otherSig = CryptoUtil.eddsaSign(otherPub.raw, testPriv) client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "type" to "reserve" - "account_pub" to otherPub - "authorization_pub" to testAuth - "authorization_sig" to otherSig - "recurrent" to true - } + json(valid_req.copy(account_pub=otherPub, authorization_pub=testAuth, recurrent=true).sign(testPriv)) }.assertOkJson<SubjectResult>() val lastPub = EddsaPublicKey.randEdsaKey() tx("customer", "KUDOS:0.1", "exchange", "Taler $lastPub") @@ -235,17 +184,17 @@ class PreparedTransferApiTest { val history = client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?limit=-5") .assertOkJson<IncomingHistory>().incoming_transactions.map { when (it) { - is IncomingKycAuthTransaction -> Triple(it.account_pub, it.authorization_pub, it.authorization_sig) - is IncomingReserveTransaction -> Triple(it.reserve_pub, it.authorization_pub, it.authorization_sig) + is IncomingKycAuthTransaction -> Pair(it.account_pub, it.authorization_pub) + is IncomingReserveTransaction -> Pair(it.reserve_pub, it.authorization_pub) else -> throw UnsupportedOperationException() } } assertContentEquals(history, listOf( - Triple(lastPub, null, null), - Triple(lastPub, null, null), - Triple(otherPub, testAuth, otherSig), - Triple(testKey, testAuth, testSig), - Triple(testKey, testAuth, testSig) + Pair(lastPub, null), + Pair(lastPub, null), + Pair(otherPub, testAuth), + Pair(testKey, testAuth), + Pair(testKey, testAuth) )) } @@ -253,68 +202,62 @@ class PreparedTransferApiTest { @Test fun unregistration() = bankSetup { val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() - val valid_req = obj { - "credit_account" to exchangePayto - "credit_amount" to "KUDOS:1" - "type" to "reserve" - "alg" to "EdDSA" - "account_pub" to pub - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) - "recurrent" to false - } + val valid_req = SubjectRequest( + exchangePayto, + TransferType.reserve, + false, + TalerAmount("KUDOS:1"), + PublicKeyAlg.EdDSA, + pub, + pub, + EddsaSignature.rand() + ).sign(priv) + val now = TalerTimestamp(Instant.now()) + val req = Unregistration( + now, + pub, + EddsaSignature.rand() + ).sign(priv) + // Unknown client.post("/taler-prepared-transfer/unregistration") { - val now = Instant.now().toString() - json { - "timestamp" to now - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) - } + json(req) }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) - + // Know client.post("/taler-prepared-transfer/registration") { json(valid_req) }.assertOkJson<SubjectResult>() client.post("/taler-prepared-transfer/unregistration") { - val now = Instant.now().toString() - json { - "timestamp" to now - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) - } + json(req) }.assertNoContent() // Idempotent client.post("/taler-prepared-transfer/unregistration") { - val now = Instant.now().toString() - json { - "timestamp" to now - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) - } + json(req) }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) // Bad signature client.post("/taler-prepared-transfer/unregistration") { - val now = Instant.now().toString() - json { - "timestamp" to now - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign("lol".toByteArray(), priv) - } - }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) + json( + Unregistration( + now, + pub, + EddsaSignature.rand() + ) + ) + }.assertForbidden(TalerErrorCode.BANK_BAD_SIGNATURE) // Old timestamp client.post("/taler-prepared-transfer/unregistration") { - val now = Instant.now().minusSeconds(1000000).toString() - json { - "timestamp" to now - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) - } + json( + Unregistration( + TalerTimestamp(Instant.now().minusSeconds(1000000)), + pub, + EddsaSignature.rand() + ).sign(priv) + ) }.assertConflict(TalerErrorCode.BANK_OLD_TIMESTAMP) // Unknown bounce @@ -327,11 +270,7 @@ class PreparedTransferApiTest { // Pending bounced after deletion val newKey = EddsaPublicKey.randEdsaKey() client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "account_pub" to newKey - "authorization_sig" to CryptoUtil.eddsaSign(newKey.raw, priv) - "recurrent" to true - } + json(valid_req.copy(account_pub=newKey, recurrent=true).sign(priv)) }.assertOkJson<SubjectResult>() tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // reserve tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending @@ -339,12 +278,7 @@ class PreparedTransferApiTest { assertBalance("customer", "-KUDOS:3") assertBalance("exchange", "+KUDOS:3") client.post("/taler-prepared-transfer/unregistration") { - val now = Instant.now().toString() - json { - "timestamp" to now - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) - } + json(req) }.assertNoContent() assertBalance("customer", "-KUDOS:1") assertBalance("exchange", "+KUDOS:1") diff --git a/libeufin-bank/src/test/kotlin/WireGatewayApiTest.kt b/libeufin-bank/src/test/kotlin/WireGatewayApiTest.kt @@ -397,16 +397,16 @@ class WireGatewayApiTest { val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() client.post("/taler-prepared-transfer/registration") { - json { - "credit_account" to exchangePayto - "credit_amount" to "KUDOS:44" - "type" to "reserve" - "alg" to "EdDSA" - "account_pub" to pub - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) - "recurrent" to false - } + json(SubjectRequest( + exchangePayto, + TransferType.reserve, + false, + TalerAmount("KUDOS:44"), + PublicKeyAlg.EdDSA, + pub, + pub, + EddsaSignature.rand() + ).sign(priv)) }.assertOkJson<SubjectResult>() val valid_req = obj { "amount" to "KUDOS:44" diff --git a/libeufin-bank/src/test/kotlin/bench.kt b/libeufin-bank/src/test/kotlin/bench.kt @@ -386,7 +386,7 @@ class Bench { } // Wire transfer - measureAction("wt_register") { + /*measureAction("wt_register") { val (priv, pub) = accountPubs[it] val valid_req = obj { "credit_account" to exchangePayto @@ -419,7 +419,7 @@ class Bench { client.post("/taler-prepared-transfer/unregistration") { json(valid_req) }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) - } + }*/ // Delete accounts diff --git a/libeufin-common/src/main/kotlin/TalerCommon.kt b/libeufin-common/src/main/kotlin/TalerCommon.kt @@ -29,8 +29,11 @@ import java.time.Instant import java.time.Duration import java.time.temporal.ChronoUnit import java.util.concurrent.TimeUnit +import java.nio.ByteBuffer +import java.nio.ByteOrder import org.bouncycastle.math.ec.rfc8032.Ed25519 import io.github.smiley4.schemakenerator.core.annotations.Description +import kotlinx.io.bytestring.putByteString sealed class CommonError(msg: String) : Exception(msg) { class AmountFormat(msg: String) : CommonError(msg) @@ -293,6 +296,18 @@ class TalerAmount : Comparable<TalerAmount> { /* Check is amount has fractional amount < 0.01 */ fun isSubCent(): Boolean = (frac % CENT_FRACTION) > 0 + /* Network bytes */ + fun nbo(): ByteArray = ByteBuffer.allocate(24).apply { + order(ByteOrder.BIG_ENDIAN) + putLong(value) + putInt(frac) + val curr = currency.encodeToByteArray() + put(curr) + repeat(12 - curr.size) { + put(0) + } + }.array() + override fun equals(other: Any?): Boolean { return other is TalerAmount && other.value == this.value && diff --git a/libeufin-common/src/main/kotlin/TalerMessage.kt b/libeufin-common/src/main/kotlin/TalerMessage.kt @@ -19,9 +19,14 @@ package tech.libeufin.common +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.MessageDigest import io.github.smiley4.schemakenerator.core.annotations.Description import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import tech.libeufin.common.crypto.CryptoUtil.hashStringNbo +import tech.libeufin.common.crypto.NBO enum class IncomingType { reserve, @@ -391,7 +396,31 @@ data class SubjectRequest( val authorization_pub: EddsaPublicKey, @Description("Authorization signature") val authorization_sig: EddsaSignature, -) +) : NBO { + /* Network bytes */ + override fun nbo(): ByteArray = ByteBuffer.allocate(104).apply { + order(ByteOrder.BIG_ENDIAN) + putInt(capacity()) + putInt(1224) + put(hashStringNbo(credit_account.toString())) + put(credit_amount.nbo()) + putInt(when (type) { + TransferType.reserve -> 1 + TransferType.kyc -> 2 + }) + putShort(if (recurrent) 2 else 1) + putShort(when (alg) { + PublicKeyAlg.EdDSA -> 1 + }) + put(account_pub.raw) + }.array() + + fun sign(priv: ByteArray): SubjectRequest = + this.copy(authorization_sig = this.signNbo(priv)) + + fun verify(): Boolean = + this.verifyNbo(this.authorization_sig, this.authorization_pub) +} @Serializable @Description("Result of subject generation") @@ -406,12 +435,26 @@ data class SubjectResult( @Description("Request to unregister a subject") data class Unregistration( @Description("Timestamp of the unregistration") - val timestamp: String, + val timestamp: TalerTimestamp, @Description("Authorization public key") val authorization_pub: EddsaPublicKey, @Description("Authorization signature") val authorization_sig: EddsaSignature -) +) : NBO { + /* Network bytes */ + override fun nbo(): ByteArray = ByteBuffer.allocate(16).apply { + order(ByteOrder.BIG_ENDIAN) + putInt(capacity()) + putInt(1225) + putLong(timestamp.instant.epochSecond * 1000 * 1000) + }.array() + + fun sign(priv: ByteArray): Unregistration = + this.copy(authorization_sig = this.signNbo(priv)) + + fun verify(): Boolean = + this.verifyNbo(this.authorization_sig, this.authorization_pub) +} /** Response GET /taler-revenue/config */ @Serializable diff --git a/libeufin-common/src/main/kotlin/client.kt b/libeufin-common/src/main/kotlin/client.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2025 Taler Systems S.A. + * Copyright (C) 2023-2025, 2026 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 @@ -25,6 +25,8 @@ import io.ktor.http.* import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject import kotlin.test.assertEquals /* ----- Json DSL ----- */ @@ -52,6 +54,13 @@ inline fun <reified B> HttpRequestBuilder.json(b: B) { setBody(json) } +inline fun <reified B> HttpRequestBuilder.json( + from: B, + builderAction: JsonBuilder.() -> Unit +) { + json(obj(Json.encodeToJsonElement(kotlinx.serialization.serializer<B>(), from).jsonObject, builderAction)) +} + inline fun HttpRequestBuilder.json( from: JsonObject = JsonObject(emptyMap()), builderAction: JsonBuilder.() -> Unit diff --git a/libeufin-common/src/main/kotlin/crypto/CryptoUtil.kt b/libeufin-common/src/main/kotlin/crypto/CryptoUtil.kt @@ -52,6 +52,14 @@ import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.SecretKeySpec +interface NBO { + fun nbo(): ByteArray + + fun signNbo(key: ByteArray): EddsaSignature = CryptoUtil.eddsaSign(this.nbo(), key) + + fun verifyNbo(signature: EddsaSignature, key: EddsaPublicKey): Boolean = CryptoUtil.checkEdssaSignature(this.nbo(), signature, key) +} + /** Helpers for dealing with cryptographic operations in EBICS / LibEuFin */ object CryptoUtil { @@ -104,23 +112,25 @@ object CryptoUtil { name, BigInteger(20, Random()), start, - end, + end, name, RSAPublicFromPrivate(private) ) - - builder.addExtension(Extension.keyUsage, true, KeyUsage( - KeyUsage.digitalSignature - or KeyUsage.nonRepudiation - or KeyUsage.keyEncipherment - or KeyUsage.dataEncipherment - or KeyUsage.keyAgreement - or KeyUsage.keyCertSign - or KeyUsage.cRLSign - or KeyUsage.encipherOnly - or KeyUsage.decipherOnly - )) + + builder.addExtension( + Extension.keyUsage, true, KeyUsage( + KeyUsage.digitalSignature + or KeyUsage.nonRepudiation + or KeyUsage.keyEncipherment + or KeyUsage.dataEncipherment + or KeyUsage.keyAgreement + or KeyUsage.keyCertSign + or KeyUsage.cRLSign + or KeyUsage.encipherOnly + or KeyUsage.decipherOnly + ) + ) builder.addExtension(Extension.basicConstraints, true, BasicConstraints(true)) val certificate = JcaContentSignerBuilder("SHA256WithRSA").build(private) @@ -137,8 +147,10 @@ object CryptoUtil { val pair = gen.genKeyPair() return Pair(pair.private as RSAPrivateCrtKey, pair.public as RSAPublicKey) } + /** Generate an RSA private key of [keysize] */ fun genRSAPrivate(keysize: Int): RSAPrivateCrtKey = genRSAPair(keysize).first + /** Generate an RSA public key of [keysize] */ fun genRSAPublic(keysize: Int): RSAPublicKey = genRSAPair(keysize).second @@ -168,7 +180,7 @@ object CryptoUtil { val encryptedTransactionKey = cipher.doFinal(transactionKey.encoded) return Pair(transactionKey, encryptedTransactionKey) } - + /** * Encrypt data according to the EBICS E002 encryption process. */ @@ -266,11 +278,14 @@ object CryptoUtil { fun hashStringSHA256(input: String): ByteArray = MessageDigest.getInstance("SHA-256").digest(input.toByteArray(Charsets.UTF_8)) + fun hashStringNbo(input: String): ByteArray = + MessageDigest.getInstance("SHA-512").digest(input.toByteArray(Charsets.UTF_8) + byteArrayOf(0)).sliceArray(0..31) + fun bcrypt(password: String, salt: ByteArray, cost: Int): ByteArray { val pwBytes = BCrypt.passwordToByteArray(password.toCharArray()) return BCrypt.generate(pwBytes, salt, cost) } - + fun hashArgon2id(password: String, salt: ByteArray): ByteArray { // OSWAP recommended config https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id val builder = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) @@ -311,7 +326,7 @@ object CryptoUtil { val verifier = Ed25519Signer() verifier.init(false, pubKey) verifier.update(data, 0, data.size) - + return verifier.verifySignature(signature.raw) } diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/PreparedTransferApi.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/PreparedTransferApi.kt @@ -63,8 +63,8 @@ fun Routing.preparedTransferAPI(db: Database, cfg: NexusConfig) = conditional(cf }) { val req = call.receive<SubjectRequest>(); - if (!CryptoUtil.checkEdssaSignature(req.account_pub.raw, req.authorization_sig, req.authorization_pub)) - throw conflict( + if (!req.verify()) + throw forbidden( "invalid signature", TalerErrorCode.BANK_BAD_SIGNATURE ) @@ -124,19 +124,18 @@ fun Routing.preparedTransferAPI(db: Database, cfg: NexusConfig) = conditional(cf }) { val req = call.receive<Unregistration>(); - val timestamp = Instant.parse(req.timestamp) - - if (timestamp.isBefore(Instant.now().minus(Duration.ofMinutes(15)))) + if (req.timestamp.instant.isBefore(Instant.now().minus(Duration.ofMinutes(15)))) throw conflict( "timestamp too old", TalerErrorCode.BANK_OLD_TIMESTAMP ) - if (!CryptoUtil.checkEdssaSignature(req.timestamp.toByteArray(), req.authorization_sig, req.authorization_pub)) - throw conflict( + if (!req.verify()) + throw forbidden( "invalid signature", TalerErrorCode.BANK_BAD_SIGNATURE ) + if (db.transfer.unregister(req.authorization_pub, Instant.now())) { call.respond(HttpStatusCode.NoContent) } else { diff --git a/libeufin-nexus/src/test/kotlin/PreparedTransferApiTest.kt b/libeufin-nexus/src/test/kotlin/PreparedTransferApiTest.kt @@ -38,103 +38,89 @@ class PreparedTransferApiTest { fun registration() = serverSetup { db -> val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() val amount = TalerAmount("KUDOS:55") - val valid_req = obj { - "credit_account" to "payto://iban/CH7789144474425692816" - "credit_amount" to amount - "type" to "reserve" - "alg" to "EdDSA" - "account_pub" to pub - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) - "recurrent" to false - } + val valid_req = SubjectRequest( + Payto.parse("payto://iban/CH7789144474425692816"), + TransferType.reserve, + false, + amount, + PublicKeyAlg.EdDSA, + pub, + pub, + EddsaSignature.rand() + ) val simple = listOf(TransferSubject.Simple("Taler MAP:$pub", amount)) val qrs = listOf(TransferSubject.QrBill(subjectFmtQrBill(pub), amount)) // Valid simple client.post("/taler-prepared-transfer/registration") { - json(valid_req) + json(valid_req.sign(priv)) }.assertOkJson<SubjectResult> { assertEquals(it.subjects, simple) } // Idempotent simple client.post("/taler-prepared-transfer/registration") { - json(valid_req) + json(valid_req.sign(priv)) }.assertOkJson<SubjectResult> { assertEquals(it.subjects, simple) } // Valid qr client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "credit_account" to "payto://iban/CH4431999123000889012" - } + json(valid_req.copy(credit_account=Payto.parse("payto://iban/CH4431999123000889012")).sign(priv)) }.assertOkJson<SubjectResult> { assertEquals(it.subjects, qrs) } // Idempotent qr client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "credit_account" to "payto://iban/CH4431999123000889012" - } + json(valid_req.copy(credit_account=Payto.parse("payto://iban/CH4431999123000889012")).sign(priv)) }.assertOkJson<SubjectResult> { assertEquals(it.subjects, qrs) } // Bad signature client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "authorization_sig" to EddsaSignature.rand() - } - }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) + json(valid_req) + }.assertForbidden(TalerErrorCode.BANK_BAD_SIGNATURE) // Unknown account client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "credit_account" to grothoffPayto - } + json(valid_req.copy(credit_account=Payto.parse(grothoffPayto)).sign(priv)) }.assertConflict(TalerErrorCode.BANK_UNKNOWN_CREDITOR) // Check authorization field in incoming history val (testPriv, testAuth) = EddsaPublicKey.randEdsaKeyPair() val testKey = EddsaPublicKey.randEdsaKey() - val testSig = CryptoUtil.eddsaSign(testKey.raw, testPriv) val qr = subjectFmtQrBill(testAuth) client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "credit_account" to "payto://iban/CH4431999123000889012" - "account_pub" to testKey - "authorization_pub" to testAuth - "authorization_sig" to testSig - "recurrent" to true - } + json(valid_req.copy( + credit_account=Payto.parse("payto://iban/CH4431999123000889012"), + account_pub=testKey, + authorization_pub=testAuth, + recurrent=true + ).sign(testPriv)) }.assertOkJson<SubjectResult>() val cfg = NexusIngestConfig.default(AccountType.exchange) registerIncomingPayment(db, cfg, genInPay(qr)) registerIncomingPayment(db, cfg, genInPay(qr)) registerIncomingPayment(db, cfg, genInPay(qr)) client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "type" to "kyc" - "account_pub" to testKey - "authorization_pub" to testAuth - "authorization_sig" to testSig - "recurrent" to true - } + json(valid_req.copy( + type=TransferType.kyc, + account_pub=testKey, + authorization_pub=testAuth, + recurrent=true + ).sign(testPriv)) }.assertOkJson<SubjectResult>() val otherPub = EddsaPublicKey.randEdsaKey() - val otherSig = CryptoUtil.eddsaSign(otherPub.raw, testPriv) client.post("/taler-prepared-transfer/registration") { - json(valid_req) { - "type" to "reserve" - "account_pub" to otherPub - "authorization_pub" to testAuth - "authorization_sig" to otherSig - "recurrent" to true - } + json(valid_req.copy( + account_pub=otherPub, + authorization_pub=testAuth, + recurrent=true + ).sign(testPriv)) }.assertOkJson<SubjectResult>() val lastPub = EddsaPublicKey.randEdsaKey() talerableIn(db, reserve_pub=lastPub) @@ -142,17 +128,17 @@ class PreparedTransferApiTest { val history = client.getA("/taler-wire-gateway/history/incoming?limit=-5") .assertOkJson<IncomingHistory>().incoming_transactions.map { when (it) { - is IncomingKycAuthTransaction -> Triple(it.account_pub, it.authorization_pub, it.authorization_sig) - is IncomingReserveTransaction -> Triple(it.reserve_pub, it.authorization_pub, it.authorization_sig) + is IncomingKycAuthTransaction -> Pair(it.account_pub, it.authorization_pub) + is IncomingReserveTransaction -> Pair(it.reserve_pub, it.authorization_pub) else -> throw UnsupportedOperationException() } } assertContentEquals(history, listOf( - Triple(lastPub, null, null), - Triple(lastPub, null, null), - Triple(otherPub, testAuth, otherSig), - Triple(testKey, testAuth, testSig), - Triple(testKey, testAuth, testSig) + Pair(lastPub, null), + Pair(lastPub, null), + Pair(otherPub, testAuth), + Pair(testKey, testAuth), + Pair(testKey, testAuth) )) } @@ -160,67 +146,58 @@ class PreparedTransferApiTest { @Test fun unregistration() = serverSetup { val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() - + val now = TalerTimestamp(Instant.now()) + val req = Unregistration( + now, + pub, + EddsaSignature.rand() + ).sign(priv) + // Unknown client.post("/taler-prepared-transfer/unregistration") { - val now = Instant.now().toString() - json { - "timestamp" to now - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) - } + json(req) }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) - + // Know client.post("/taler-prepared-transfer/registration") { - json { - "credit_account" to "payto://iban/CH7789144474425692816" - "credit_amount" to "KUDOS:55" - "type" to "reserve" - "alg" to "EdDSA" - "account_pub" to pub - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) - "recurrent" to false - } + json(SubjectRequest( + Payto.parse("payto://iban/CH7789144474425692816"), + TransferType.reserve, + false, + TalerAmount("KUDOS:55"), + PublicKeyAlg.EdDSA, + pub, + pub, + EddsaSignature.rand() + ).sign(priv)) }.assertOkJson<SubjectResult>() client.post("/taler-prepared-transfer/unregistration") { - val now = Instant.now().toString() - json { - "timestamp" to now - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) - } + json(req) }.assertNoContent() // Idempotent client.post("/taler-prepared-transfer/unregistration") { - val now = Instant.now().toString() - json { - "timestamp" to now - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) - } + json(req) }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) // Bad signature client.post("/taler-prepared-transfer/unregistration") { - val now = Instant.now().toString() - json { - "timestamp" to now - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign("lol".toByteArray(), priv) - } - }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) + json(Unregistration( + now, + pub, + EddsaSignature.rand() + )) + }.assertForbidden(TalerErrorCode.BANK_BAD_SIGNATURE) // Old timestamp client.post("/taler-prepared-transfer/unregistration") { - val now = Instant.now().minusSeconds(1000000).toString() - json { - "timestamp" to now - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) - } + json( + Unregistration( + TalerTimestamp(Instant.now().minusSeconds(1000000)), + pub, + EddsaSignature.rand() + ).sign(priv) + ) }.assertConflict(TalerErrorCode.BANK_OLD_TIMESTAMP) } } \ No newline at end of file diff --git a/libeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt b/libeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -340,16 +340,16 @@ class WireGatewayApiTest { val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() client.post("/taler-prepared-transfer/registration") { - json { - "credit_account" to "payto://iban/CH7789144474425692816" - "credit_amount" to "CHF:44" - "type" to "reserve" - "alg" to "EdDSA" - "account_pub" to pub - "authorization_pub" to pub - "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) - "recurrent" to false - } + json(SubjectRequest( + Payto.parse("payto://iban/CH7789144474425692816"), + TransferType.reserve, + false, + TalerAmount("CHF:44"), + PublicKeyAlg.EdDSA, + pub, + pub, + EddsaSignature.rand() + ).sign(priv)) }.assertOkJson<SubjectResult>() val valid_req = obj { "amount" to "CHF:44" diff --git a/libeufin-nexus/src/test/kotlin/bench.kt b/libeufin-nexus/src/test/kotlin/bench.kt @@ -194,7 +194,7 @@ class Bench { } // Wire transfer - measureAction("wt_register") { + /*measureAction("wt_register") { val (priv, pub) = accountPubs[it] val valid_req = obj { "credit_account" to "payto://iban/CH7789144474425692816" @@ -227,7 +227,7 @@ class Bench { client.post("/taler-prepared-transfer/unregistration") { json(valid_req) }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) - } + }*/ // Observability /*measureAction("metrics") {