commit 8383013aa8defa2205c20ccf76bf5d46edd59ab9
parent 3fcbe27ff2dba6778607e19d10367adaff095a65
Author: Antoine A <>
Date: Mon, 6 Jan 2025 13:26:29 +0100
common: better subject parsing logic
Diffstat:
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))
+ }
}
}
}