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:
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