libeufin

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

commit 8383013aa8defa2205c20ccf76bf5d46edd59ab9
parent 3fcbe27ff2dba6778607e19d10367adaff095a65
Author: Antoine A <>
Date:   Mon,  6 Jan 2025 13:26:29 +0100

common: better subject parsing logic

Diffstat:
Mcommon/src/main/kotlin/Encoding.kt | 4++--
Mcommon/src/main/kotlin/TxMedatada.kt | 176++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcommon/src/test/kotlin/TxMedataTest.kt | 140+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
3 files changed, 205 insertions(+), 115 deletions(-)

diff --git a/common/src/main/kotlin/Encoding.kt b/common/src/main/kotlin/Encoding.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 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 @@ -22,7 +22,7 @@ package tech.libeufin.common /** Crockford's Base32 implementation */ object Base32Crockford { /** Crockford's Base32 alphabet */ - private const val ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + const val ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" /** Base32 mark to extract 5 bits chunks */ private const val MASK = 0b11111 /** Crockford's Base32 inversed alphabet */ diff --git a/common/src/main/kotlin/TxMedatada.kt b/common/src/main/kotlin/TxMedatada.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 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 @@ -21,77 +21,141 @@ package tech.libeufin.common import org.bouncycastle.math.ec.rfc8032.Ed25519 -private val PART_PATTERN = Regex("[:a-z0-9A-Z]*") -private val BASE32_32B_PATTERN = Regex("(KYC:)?([a-z0-9A-Z]{52})") - data class TalerIncomingMetadata(val type: TalerIncomingType, val key: EddsaPublicKey) -/** - * Extract the reserve public key from an incoming Taler transaction subject - * - * We first try to find a whole key. If none are found we try to reconstruct - * one from parts. - **/ -fun parseIncomingTxMetadata(subject: String): TalerIncomingMetadata { - /** Check one or no reserve public key is found **/ - fun check(matches: Sequence<MatchResult>): TalerIncomingMetadata? { - val iterator = matches.iterator() - - // Check none - if (!iterator.hasNext()) { - return null +/** Base32 quality by proximity to spec and error probability */ +private enum class Base32Quality { + /// Both mixed casing and mixed characters, thats weird + Mixed, + /// Standard but use lowercase, maybe the client shown lowercase in the UI + Standard, + /// Uppercase but mixed characters, its common when making typos + Upper, + /// Both uppercase and use the standard alphabet as it should + UpperStandard; + + companion object { + fun measure(s: String): Base32Quality { + var uppercase = true; + var standard = true; + for (char in s) { + uppercase = uppercase && char.isUpperCase() + standard = standard && Base32Crockford.ALPHABET.contains(char) + } + return if (uppercase && standard) { + Base32Quality.UpperStandard + } else if (uppercase && !standard) { + Base32Quality.Upper + } else if (!uppercase && standard) { + Base32Quality.Standard + } else { + Base32Quality.Mixed + } } + } +} - val (prefix, base32) = iterator.next().destructured +private data class Candidate(val subject: TalerIncomingMetadata, val quality: Base32Quality) - // Check many - if (iterator.hasNext()) { - throw Exception("Found multiple reserve public key") +/** + * Extract the public key from an unstructured incoming transfer subject. + * + * When a user enters the transfer object in an unstructured way, for ex in + * their banking UI, they may mistakenly enter separators such as ' \n-+' and + * make typos. + * To parse them while ignoring user errors, we reconstruct valid keys from key + * parts, resolving ambiguities where possible. + **/ +fun parseIncomingTxMetadata(subject: String): TalerIncomingMetadata { + /** Parse an incoming subject */ + fun parseSingle(str: String): Candidate? { + // Check key type + val (isKyc, raw) = if (str.startsWith("KYC:")) { + Pair(true, str.substring(4)) + } else { + Pair(false, str) } // Check key validity - val key = EddsaPublicKey(base32) + val key = try { + EddsaPublicKey(raw) + } catch (e: Exception) { + return null + } if (!Ed25519.validatePublicKeyFull(key.raw, 0)) { return null } - // Check key type - val type = if (prefix == "KYC:") TalerIncomingType.kyc else TalerIncomingType.reserve - return TalerIncomingMetadata(type, key) + val quality = Base32Quality.measure(raw); + val type = if (isKyc) TalerIncomingType.kyc else TalerIncomingType.reserve + return Candidate(TalerIncomingMetadata(type, key), quality) } - // Wire transfer subjects are generally small in size, and not - // being able to find the encoded reserve public key poses a huge - // usability problem. So we're ready to work hard to find it. - - // Bank interfaces doesn't always allow a complete encoded key to - // be entered on a single line, so users have to split them, which - // may lead them to add an erroneous space or + or - separator. - // If we can't find a key, we try to eliminate these common errors - // before trying again. - - // Since any sequence of 52 upper and lowercase characters can be a - // valid encoded key, deleting spaces and separators can create false - // positives, so we always start by searching for a valid whole key - // then we try to reconstruct a key from valid parts. - - // Whole key match - val key = check(BASE32_32B_PATTERN.findAll(subject)) - if (key != null) return key - - // Key reconstruction from parts - val parts = PART_PATTERN.findAll(subject).map { it.value }.toList() - for (windowSize in 2..parts.size) { - val matches = parts.windowed(windowSize).asSequence().map { window -> - val joined = window.joinToString("") - BASE32_32B_PATTERN.matchEntire(joined) - }.filterNotNull() - val key = check(matches) - if (key != null) return key + // Find and concatenate valid parts of a keys + val parts = mutableListOf<IntRange>() + val concatenated = StringBuilder() + var part: Int? = null + for ((i, c) in subject.withIndex()) { + if (c.isLetterOrDigit() || c == ':') { + part = part ?: i + } else if (part != null) { + val start = concatenated.length + concatenated.append(subject.substring(part until i)); + val end = concatenated.length + parts.add(start until end); + part = null + } + } + if (part != null) { + val start = concatenated.length + concatenated.append(subject.substring(part until subject.length)); + val end = concatenated.length + parts.add(start until end); + } + + // Find best candidates + var best: Candidate? = null + // For each part as a starting point + for ((i, start) in parts.withIndex()) { + // Use progressively longer concatenation + for (end in parts.subList(i, parts.size)) { + val range = start.start..end.endInclusive + val len = range.count() + // Until they are to long to be a key + if (len > 56) { + break; + } + // If the slice is the right size for a key (56B with prefix else 54B) + if (len == 52 || len == 56) { + // Parse the concatenated parts + val slice = concatenated.substring(range) + parseSingle(slice)?.let { other -> + if (best != null) { + if (other.quality > best.quality // We prefer high quality keys + || ( // We prefer prefixed keys over reserve keys + best.subject.type == TalerIncomingType.reserve && + (other.subject.type == TalerIncomingType.kyc || other.subject.type == TalerIncomingType.wad) + )) + { + best = other + } else if (best.subject.key != other.subject.key // If keys are different + && best.quality == other.quality // Of same quality + && !( // And prefixing is diferent + (best.subject.type == TalerIncomingType.kyc || best.subject.type == TalerIncomingType.wad) && + other.subject.type == TalerIncomingType.reserve + )) + { + throw Exception("Found multiple reserve public key") + } + } else { + best = other + } + } + } + } } - // No key where found - throw Exception("Missing reserve public key") + return best?.subject ?: throw Exception("Missing reserve public key") } /** Extract the reserve public key from an incoming Taler transaction subject */ diff --git a/common/src/test/kotlin/TxMedataTest.kt b/common/src/test/kotlin/TxMedataTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 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 @@ -28,70 +28,96 @@ class TxMetadataTest { @Test fun parse() { - val upper = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0" - val (upperL, upperR) = upper.chunked(26) - val mixed = "4mzt6RS3rvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0" - val (mixedL, mixedR) = mixed.chunked(26) - val key = TalerIncomingMetadata(TalerIncomingType.reserve, EddsaPublicKey(upper)) - - // Check succeed if upper or mixed - for (case in sequenceOf(upper, mixed)) { - for (test in sequenceOf( - "noise $case noise", - "$case noise to the right", - "noise to the left $case", - " $case ", - "noise\n$case\nnoise", - "Test+$case" + val key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"; + val other = "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG"; + + for (ty in sequenceOf(TalerIncomingType.reserve, TalerIncomingType.kyc)) { + val prefix = if (ty == TalerIncomingType.kyc) "KYC:" else ""; + val standard = "$prefix$key" + val (standardL, standardR) = standard.chunked(standard.length / 2) + val mixed = "${prefix}4mzt6RS3rvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0" + val (mixedL, mixedR) = mixed.chunked(mixed.length / 2) + val other_standard = "$prefix$other" + val other_mixed = "${prefix}TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60" + val key = TalerIncomingMetadata(ty, EddsaPublicKey(key)) + + // Check succeed if standard or mixed + for (case in sequenceOf(standard, mixed)) { + for (test in sequenceOf( + "noise $case noise", + "$case noise to the right", + "noise to the left $case", + " $case ", + "noise\n$case\nnoise", + "Test+$case" + )) { + assertEquals(key, parseIncomingTxMetadata(test)) + } + } + + // Check succeed if standard or mixed and split + for ((L, R) in sequenceOf(standardL to standardR, mixedL to mixedR)) { + for (case in sequenceOf( + "left $L$R right", + "left $L $R right", + "left $L-$R right", + "left $L+$R right", + "left $L\n$R right", + "left $L-+\n$R right", + "left $L - $R right", + "left $L + $R right", + "left $L \n $R right", + "left $L - + \n $R right", + )) { + assertEquals(key, parseIncomingTxMetadata(case)) + } + } + + // Check concat parts + for (chunkSize in 1 until standard.length) { + val chunked = standard.chunked(chunkSize).joinToString(" ") + for (case in sequenceOf(chunked, "left ${chunked} right")) { + assertEquals(key, parseIncomingTxMetadata(case)) + } + } + + // Check failed when multiple key + for (case in sequenceOf( + "$standard $other_standard", + "$mixed $other_mixed", )) { - assertEquals(key, parseIncomingTxMetadata(test)) + assertFailsMsg("Found multiple reserve public key") { + parseIncomingTxMetadata(case) + } } - } - // Check succeed if upper or mixed and split - for ((L, R) in sequenceOf(upperL to upperR, mixedL to mixedR)) { + // Check accept redundant key for (case in sequenceOf( - "left $L$R right", - "left $L $R right", - "left $L-$R right", - "left $L+$R right", - "left $L\n$R right", - "left $L-+\n$R right", - "left $L - $R right", - "left $L + $R right", - "left $L \n $R right", - "left $L - + \n $R right", + "$standard $standard $mixed $mixed", // Accept redundant key + "$mixedL-$mixedR $standardL-$standardR", + "$standard $other_mixed", // Prefer high quality )) { assertEquals(key, parseIncomingTxMetadata(case)) } - } - - // Check parts - for (case in sequenceOf( - upper.chunked(12).joinToString(" "), - "left ${upper.chunked(1).joinToString(" ")} right", - )) - - // Check failure when multiple keys match - for (case in sequenceOf( - "$upper $upper", - "$mixed $mixed", - "$mixed $upper", - "$mixedL-$mixedR $upperL-$upperR" - )) { - assertFailsMsg("Found multiple reserve public key") { - parseIncomingTxMetadata(case) + + // Check failure if malformed or missing + for (case in sequenceOf( + "does not contain any reserve", // Check fail if none + standard.substring(1), // Check fail if missing char + "2MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0" // Check fail if not a valid key + )) { + assertFailsMsg("Missing reserve public key") { + parseIncomingTxMetadata(case) + } } - } - - // Check failure if malformed or missing - for (case in sequenceOf( - "does not contain any reserve", // Check fail if none - upper.substring(0, upper.length-1), // Check fail if missing char - "2MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0" // Check fail if not a valid key - )) { - assertFailsMsg("Missing reserve public key") { - parseIncomingTxMetadata(case) + + if (ty == TalerIncomingType.kyc) { + // Prefer prefixed over unprefixed + for (case in sequenceOf( + "$other $standard", "$other $mixed" + )) { + assertEquals(key, parseIncomingTxMetadata(case)) + } } } }