libeufin

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

Subject.kt (7179B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2024-2025 Taler Systems S.A.
      4 
      5  * LibEuFin is free software; you can redistribute it and/or modify
      6  * it under the terms of the GNU Affero General Public License as
      7  * published by the Free Software Foundation; either version 3, or
      8  * (at your option) any later version.
      9 
     10  * LibEuFin is distributed in the hope that it will be useful, but
     11  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12  * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13  * Public License for more details.
     14 
     15  * You should have received a copy of the GNU Affero General Public
     16  * License along with LibEuFin; see the file COPYING.  If not, see
     17  * <http://www.gnu.org/licenses/>
     18  */
     19 
     20 package tech.libeufin.common
     21 
     22 import org.bouncycastle.math.ec.rfc8032.Ed25519
     23 
     24 sealed interface IncomingSubject {
     25     data class Reserve(val reserve_pub: EddsaPublicKey): IncomingSubject
     26     data class Kyc(val account_pub: EddsaPublicKey): IncomingSubject
     27     data object AdminBalanceAdjust: IncomingSubject
     28 
     29     val type: IncomingType get() = when (this) {
     30         is Reserve -> IncomingType.reserve
     31         is Kyc -> IncomingType.kyc
     32         AdminBalanceAdjust -> throw IllegalStateException("Admin balance adjust")
     33     }
     34     val key: EddsaPublicKey get() = when (this) {
     35         is Reserve -> this.reserve_pub
     36         is Kyc -> this.account_pub
     37         AdminBalanceAdjust -> throw IllegalStateException("Admin balance adjust")
     38     }
     39 }
     40 
     41 /** Base32 quality by proximity to spec and error probability */
     42 private enum class Base32Quality {
     43     /// Both mixed casing and mixed characters, that's weird
     44     Mixed,
     45     /// Standard but use lowercase, maybe the client shown lowercase in the UI
     46     Standard,
     47     /// Uppercase but mixed characters, its common when making typos
     48     Upper,
     49     /// Both uppercase and use the standard alphabet as it should
     50     UpperStandard;
     51 
     52     companion object {
     53         fun measure(s: String): Base32Quality {
     54             var uppercase = true;
     55             var standard = true;
     56             for (char in s) {
     57                 uppercase = uppercase && char.isUpperCase()
     58                 standard = standard && Base32Crockford.ALPHABET.contains(char)
     59             }
     60             return if (uppercase && standard) {
     61                 Base32Quality.UpperStandard
     62             } else if (uppercase && !standard) {
     63                 Base32Quality.Upper
     64             } else if (!uppercase && standard) {
     65                 Base32Quality.Standard
     66             } else {
     67                 Base32Quality.Mixed
     68             }
     69         }
     70     }
     71 }
     72 
     73 private data class Candidate(val subject: IncomingSubject, val quality: Base32Quality)
     74 
     75 private const val ADMIN_BALANCE_ADJUST = "ADMINBALANCEADJUST"
     76 private const val KEY_SIZE = 52;
     77 private const val KYC_SIZE = KEY_SIZE + 3;
     78 private val ALPHA_NUMBERIC_PATTERN = Regex("[0-9a-zA-Z]*")
     79 
     80 /**
     81  * Extract the public key from an unstructured incoming transfer subject.
     82  *
     83  * When a user enters the transfer object in an unstructured way, for ex in
     84  * their banking UI, they may mistakenly enter separators such as ' \n-+' and
     85  * make typos.
     86  * To parse them while ignoring user errors, we reconstruct valid keys from key
     87  * parts, resolving ambiguities where possible.
     88  **/
     89 fun parseIncomingSubject(subject: String?): IncomingSubject {
     90     if (subject == null || subject.isEmpty()) {
     91         throw Exception("missing subject")
     92     }
     93 
     94     /** Parse an incoming subject */
     95     fun parseSingle(str: String): Candidate? {
     96         // Check key type
     97         val (isKyc, raw) = when (str.length) {
     98             ADMIN_BALANCE_ADJUST.length -> if (str.equals(ADMIN_BALANCE_ADJUST, ignoreCase = true)) {
     99                 return Candidate(IncomingSubject.AdminBalanceAdjust, Base32Quality.UpperStandard)
    100             } else {
    101                 return null
    102             }
    103             KEY_SIZE -> Pair(false, str)
    104             KYC_SIZE -> if (str.startsWith("KYC")) {
    105                 Pair(true, str.substring(3))
    106             } else {
    107                 return null
    108             }
    109             else -> return null
    110         }
    111 
    112         // Check key validity
    113         val key = try {
    114             EddsaPublicKey(raw)
    115         } catch (e: Exception) {
    116             return null
    117         }
    118         if (!Ed25519.validatePublicKeyFull(key.raw, 0)) {
    119             return null
    120         }
    121 
    122         val quality = Base32Quality.measure(raw);
    123 
    124         val subject = if (isKyc) IncomingSubject.Kyc(key) else IncomingSubject.Reserve(key)
    125         return Candidate(subject, quality)
    126     }
    127 
    128     // Find and concatenate valid parts of a keys
    129     val parts = mutableListOf(0)
    130     val concatenated = StringBuilder()
    131     for (match in ALPHA_NUMBERIC_PATTERN.findAll(subject.replace("%20", " "))) {
    132         concatenated.append(match.value);
    133         parts.add(concatenated.length);
    134     }
    135 
    136     // Find best candidates
    137     var best: Candidate? = null
    138     // For each part as a starting point
    139     for ((i, start) in parts.withIndex()) {
    140         // Use progressively longer concatenation
    141         for (end in parts.subList(i, parts.size)) {
    142             val range = start until end
    143             // Until they are to long to be a key
    144             if (range.count() > KYC_SIZE) {
    145                 break;
    146             }
    147             // Parse the concatenated parts
    148             val slice = concatenated.substring(range)
    149             parseSingle(slice)?.let { other ->
    150                 if (best != null) {
    151                     if (best.subject is IncomingSubject.AdminBalanceAdjust) {
    152                         if (other.subject !is IncomingSubject.AdminBalanceAdjust) {
    153                             throw Exception("found multiple subject kind")
    154                         }
    155                     } else if (other.quality > best.quality // We prefer high quality keys
    156                         || ( // We prefer prefixed keys over reserve keys
    157                             best.subject.type == IncomingType.reserve &&
    158                             (other.subject.type == IncomingType.kyc || other.subject.type == IncomingType.wad)
    159                         ))
    160                     {
    161                         best = other
    162                     } else if (best.subject.key != other.subject.key // If keys are different
    163                         && best.quality == other.quality // Of same quality
    164                         && !( // And prefixing is different
    165                             (best.subject.type == IncomingType.kyc || best.subject.type == IncomingType.wad) &&
    166                             other.subject.type == IncomingType.reserve
    167                         ))
    168                     {
    169                         throw Exception("found multiple reserve public key")
    170                     }
    171                 } else {
    172                     best = other
    173                 }
    174             }
    175         }
    176     }
    177 
    178     return best?.subject ?: throw Exception("missing reserve public key")
    179 }
    180 
    181 /** Extract the reserve public key from an incoming Taler transaction subject */
    182 fun parseOutgoingSubject(subject: String): Pair<ShortHashCode, BaseURL>  {
    183     val (wtid, baseUrl) = subject.splitOnce(" ") ?: throw Exception("malformed outgoing subject")
    184     return Pair(EddsaPublicKey(wtid), BaseURL.parse(baseUrl))
    185 }