libeufin

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

commit 05971692600e125018c1450be3558ff11562fbda
parent d920c59dcdeb1754b5e60c26061d3733e42cf953
Author: Antoine A <>
Date:   Wed,  4 Oct 2023 10:41:54 +0000

Add Crockford32 validation to /taler-wire-gateway/transfer

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt | 50++++++++++++++++++++++++++++++++++++++++++++++++--
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 4++--
Mbank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt | 4++--
Mbank/src/test/kotlin/DatabaseTest.kt | 4++--
Mbank/src/test/kotlin/TalerApiTest.kt | 108+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Abank/src/test/kotlin/helpers.kt | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 191 insertions(+), 52 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt @@ -22,12 +22,58 @@ package tech.libeufin.bank import io.ktor.http.* import io.ktor.server.application.* import kotlinx.serialization.Serializable +import net.taler.wallet.crypto.Base32Crockford +import net.taler.wallet.crypto.EncodingException import java.time.Duration import java.time.Instant import java.time.temporal.ChronoUnit import java.util.* /** + * 32-byte hash code. + */ +@Serializable() +@JvmInline +value class ShortHashCode(val encoded: String) { + init { + val decoded = try { + Base32Crockford.decode(encoded) + } catch (e: EncodingException) { + null + } + + require(decoded != null) { + "Data should be encoded using Crockford's Base32" + } + require(decoded.size == 32) { + "Encoded data should be 32 bytes long" + } + } +} + +/** + * 64-byte hash code. + */ +@Serializable() +@JvmInline +value class HashCode(val encoded: String) { + init { + val decoded = try { + Base32Crockford.decode(encoded) + } catch (e: EncodingException) { + null + } + + require(decoded != null) { + "Data should be encoded using Crockford's Base32" + } + require(decoded.size == 64) { + "Encoded data should be 64 bytes long" + } + } +} + +/** * Allowed lengths for fractional digits in amounts. */ enum class FracDigits { @@ -634,10 +680,10 @@ data class IncomingReserveTransaction( */ @Serializable data class TransferRequest( - val request_uid: String, + val request_uid: HashCode, val amount: TalerAmount, val exchange_base_url: String, - val wtid: String, + val wtid: ShortHashCode, val credit_account: String ) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -1293,8 +1293,8 @@ class Database(private val dbConfig: String, private val bankCurrency: String) { ); """) - stmt.setString(1, req.request_uid) - stmt.setString(2, req.wtid) + stmt.setString(1, req.request_uid.encoded) + stmt.setString(2, req.wtid.encoded) stmt.setLong(3, req.amount.value) stmt.setInt(4, req.amount.frac) stmt.setString(5, req.exchange_base_url) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt @@ -46,14 +46,14 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) if (!call.getResourceName("USERNAME").canI(c, withAdmin = false)) throw forbidden() val req = call.receive<TransferRequest>() // Checking for idempotency. - val maybeDoneAlready = db.talerTransferGetFromUid(req.request_uid) + val maybeDoneAlready = db.talerTransferGetFromUid(req.request_uid.encoded) val creditAccount = stripIbanPayto(req.credit_account) if (maybeDoneAlready != null) { val isIdempotent = maybeDoneAlready.amount == req.amount && maybeDoneAlready.creditAccount == creditAccount && maybeDoneAlready.exchangeBaseUrl == req.exchange_base_url - && maybeDoneAlready.wtid == req.wtid + && maybeDoneAlready.wtid == req.wtid.encoded if (isIdempotent) { call.respond( TransferResponse( diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -113,8 +113,8 @@ class DatabaseTest { amount = TalerAmount(9, 0, "KUDOS"), credit_account = "payto://iban/BAR-IBAN-ABC".lowercase(), // foo pays bar exchange_base_url = "example.com/exchange", - request_uid = "entropic 0", - wtid = "entropic 1" + request_uid = randHashCode(), + wtid = randShortHashCode() ) val db = initDb() val fooId = db.customerCreate(customerFoo) diff --git a/bank/src/test/kotlin/TalerApiTest.kt b/bank/src/test/kotlin/TalerApiTest.kt @@ -4,6 +4,7 @@ import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.testing.* import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive import net.taler.wallet.crypto.Base32Crockford import org.junit.Test import tech.libeufin.bank.* @@ -12,28 +13,7 @@ import tech.libeufin.util.stripIbanPayto import java.util.* import kotlin.test.assertEquals import kotlin.test.assertNotNull - -private fun HttpResponse.assertStatus(status: HttpStatusCode): HttpResponse { - assertEquals(status, this.status); - return this -} - -private fun HttpResponse.assertOk(): HttpResponse = assertStatus(HttpStatusCode.OK) - -private inline fun <reified B> HttpRequestBuilder.jsonBody(b: B, deflate: Boolean = false) { - val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), b); - contentType(ContentType.Application.Json) - if (deflate) { - headers.set("Content-Encoding", "deflate") - setBody(deflater(json)) - } else { - setBody(json) - } -} - -private fun BankTransactionResult.assertSuccess() { - assertEquals(BankTransactionResult.SUCCESS, this) -} +import randHashCode class TalerApiTest { private val customerFoo = Customer( @@ -86,36 +66,36 @@ class TalerApiTest { corebankWebApp(db, ctx) } - val req2 = TransferRequest( - request_uid = "entropic 0", - amount = TalerAmount(55, 0, "KUDOS"), - exchange_base_url = "http://exchange.example.com/", - wtid = "entropic 0", - credit_account = "${stripIbanPayto(bankAccountBar.internalPaytoUri)}" - ); + val valid_req = json { + put("request_uid", randHashCode()) + put("amount", "KUDOS:55") + put("exchange_base_url", "http://exchange.example.com/") + put("wtid", randShortHashCode()) + put("credit_account", "${stripIbanPayto(bankAccountBar.internalPaytoUri)}") + }; // Unkown account client.post("/accounts/foo/taler-wire-gateway/transfer") { basicAuth("unknown", "password") - jsonBody(req2) + jsonBody(valid_req) }.assertStatus(HttpStatusCode.Unauthorized) // Wrong password client.post("/accounts/foo/taler-wire-gateway/transfer") { basicAuth("foo", "password") - jsonBody(req2) + jsonBody(valid_req) }.assertStatus(HttpStatusCode.Unauthorized) // Wrong account client.post("/accounts/foo/taler-wire-gateway/transfer") { basicAuth("bar", "secret") - jsonBody(req2) + jsonBody(valid_req) }.assertStatus(HttpStatusCode.Forbidden) // Checking exchange debt constraint. client.post("/accounts/foo/taler-wire-gateway/transfer") { basicAuth("foo", "pw") - jsonBody(req2) + jsonBody(valid_req) }.assertStatus(HttpStatusCode.Conflict) // Giving debt allowance and checking the OK case. @@ -125,23 +105,23 @@ class TalerApiTest { )) client.post("/accounts/foo/taler-wire-gateway/transfer") { basicAuth("foo", "pw") - jsonBody(req2) + jsonBody(valid_req) }.assertOk() // check idempotency client.post("/accounts/foo/taler-wire-gateway/transfer") { basicAuth("foo", "pw") - jsonBody(req2) + jsonBody(valid_req) }.assertOk() // Trigger conflict due to reused request_uid client.post("/accounts/foo/taler-wire-gateway/transfer") { basicAuth("foo", "pw") jsonBody( - req2.copy( - wtid = "entropic 1", - exchange_base_url = "http://different-exchange.example.com/", - ) + json(valid_req) { + put("wtid", randShortHashCode()) + put("exchange_base_url", "http://different-exchange.example.com/") + } ) }.assertStatus(HttpStatusCode.Conflict) @@ -149,11 +129,51 @@ class TalerApiTest { client.post("/accounts/foo/taler-wire-gateway/transfer") { basicAuth("foo", "pw") jsonBody( - req2.copy( - request_uid = "entropic 4", - wtid = "entropic 5", - amount = TalerAmount(value = 33, frac = 0, currency = "EUR") - ) + json(valid_req) { + put("request_uid", randHashCode()) + put("wtid", randShortHashCode()) + put("amount", "EUR:33") + } + ) + }.assertStatus(HttpStatusCode.BadRequest) + + // Bad BASE32 wtid + client.post("/accounts/foo/taler-wire-gateway/transfer") { + basicAuth("foo", "pw") + jsonBody( + json(valid_req) { + put("wtid", "I love chocolate") + } + ) + }.assertStatus(HttpStatusCode.BadRequest) + + // Bad BASE32 len wtid + client.post("/accounts/foo/taler-wire-gateway/transfer") { + basicAuth("foo", "pw") + jsonBody( + json(valid_req) { + put("wtid", randBase32Crockford(31)) + } + ) + }.assertStatus(HttpStatusCode.BadRequest) + + // Bad BASE32 request_uid + client.post("/accounts/foo/taler-wire-gateway/transfer") { + basicAuth("foo", "pw") + jsonBody( + json(valid_req) { + put("request_uid", "I love chocolate") + } + ) + }.assertStatus(HttpStatusCode.BadRequest) + + // Bad BASE32 len wtid + client.post("/accounts/foo/taler-wire-gateway/transfer") { + basicAuth("foo", "pw") + jsonBody( + json(valid_req) { + put("request_uid", randBase32Crockford(65)) + } ) }.assertStatus(HttpStatusCode.BadRequest) } diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -0,0 +1,72 @@ +import io.ktor.http.* +import io.ktor.client.statement.* +import io.ktor.client.request.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import net.taler.wallet.crypto.Base32Crockford +import kotlin.test.assertEquals +import tech.libeufin.bank.* + +/* ----- Assert ----- */ + +fun HttpResponse.assertStatus(status: HttpStatusCode): HttpResponse { + assertEquals(status, this.status); + return this +} + +fun HttpResponse.assertOk(): HttpResponse = assertStatus(HttpStatusCode.OK) + + +fun BankTransactionResult.assertSuccess() { + assertEquals(BankTransactionResult.SUCCESS, this) +} + +/* ----- Body helper ----- */ + +inline fun <reified B> HttpRequestBuilder.jsonBody(b: B, deflate: Boolean = false) { + val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), b); + contentType(ContentType.Application.Json) + if (deflate) { + headers.set("Content-Encoding", "deflate") + setBody(deflater(json)) + } else { + setBody(json) + } +} + +/* ----- Json DSL ----- */ + +inline fun json(from: JsonObject = JsonObject(emptyMap()), builderAction: JsonBuilder2.() -> Unit): JsonObject { + val builder = JsonBuilder2(from) + builder.apply(builderAction) + println(builder.content) + return JsonObject(builder.content) +} + +class JsonBuilder2(from: JsonObject) { + val content: MutableMap<String, JsonElement> = from.toMutableMap() + + inline fun <reified B> put(name: String, b: B) { + val json = Json.encodeToJsonElement(kotlinx.serialization.serializer<B>(), b); + content.put(name, json) + } +} + +/* ----- Random data generation ----- */ + +fun randBase32Crockford(lenght: Int): String { + val bytes = ByteArray(lenght) + kotlin.random.Random.nextBytes(bytes) + return Base32Crockford.encode(bytes) +} + +fun randHashCode(): HashCode { + return HashCode(randBase32Crockford(64)) +} + +fun randShortHashCode(): ShortHashCode { + return ShortHashCode(randBase32Crockford(32)) +} +\ No newline at end of file