libeufin

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

Subject.kt (9892B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2024, 2025, 2026 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 import java.math.BigInteger
     24 import java.security.MessageDigest
     25 
     26 sealed interface IncomingSubject {
     27     data class Reserve(val reserve_pub: EddsaPublicKey): IncomingSubject
     28     data class Kyc(val account_pub: EddsaPublicKey): IncomingSubject
     29     data class Map(val auth_pub: EddsaPublicKey): IncomingSubject
     30     data object AdminBalanceAdjust: IncomingSubject
     31 
     32     val type: IncomingType get() = when (this) {
     33         is Reserve -> IncomingType.reserve
     34         is Kyc -> IncomingType.kyc
     35         is Map -> IncomingType.map
     36         AdminBalanceAdjust -> throw IllegalStateException("Admin balance adjust")
     37     }
     38     val key: EddsaPublicKey get() = when (this) {
     39         is Reserve -> this.reserve_pub
     40         is Kyc -> this.account_pub
     41         is Map -> this.auth_pub
     42         AdminBalanceAdjust -> throw IllegalStateException("Admin balance adjust")
     43     }
     44 }
     45 
     46 /** Base32 quality by proximity to spec and error probability */
     47 private enum class Base32Quality {
     48     /// Both mixed casing and mixed characters, that's weird
     49     Mixed,
     50     /// Standard but use lowercase, maybe the client shown lowercase in the UI
     51     Standard,
     52     /// Uppercase but mixed characters, its common when making typos
     53     Upper,
     54     /// Both uppercase and use the standard alphabet as it should
     55     UpperStandard;
     56 
     57     companion object {
     58         fun measure(s: String): Base32Quality {
     59             var uppercase = true;
     60             var standard = true;
     61             for (char in s) {
     62                 uppercase = uppercase && char.isUpperCase()
     63                 standard = standard && Base32Crockford.ALPHABET.contains(char)
     64             }
     65             return if (uppercase && standard) {
     66                 Base32Quality.UpperStandard
     67             } else if (uppercase && !standard) {
     68                 Base32Quality.Upper
     69             } else if (!uppercase && standard) {
     70                 Base32Quality.Standard
     71             } else {
     72                 Base32Quality.Mixed
     73             }
     74         }
     75     }
     76 }
     77 
     78 private data class Candidate(val subject: IncomingSubject, val quality: Base32Quality)
     79 
     80 private const val ADMIN_BALANCE_ADJUST = "ADMINBALANCEADJUST"
     81 private const val KEY_SIZE = 52;
     82 private const val PREFIX_SIZE = KEY_SIZE + 3;
     83 private val ALPHA_NUMBERIC_PATTERN = Regex("[0-9a-zA-Z]*")
     84 
     85 /**
     86  * Extract the public key from an unstructured incoming transfer subject.
     87  *
     88  * When a user enters the transfer object in an unstructured way, for ex in
     89  * their banking UI, they may mistakenly enter separators such as ' \n-+' and
     90  * make typos.
     91  * To parse them while ignoring user errors, we reconstruct valid keys from key
     92  * parts, resolving ambiguities where possible.
     93  **/
     94 fun parseIncomingSubject(subject: String?): IncomingSubject {
     95     if (subject == null || subject.isEmpty()) {
     96         throw Exception("missing subject")
     97     }
     98 
     99     /** Parse an incoming subject */
    100     fun parseSingle(str: String): Candidate? {
    101         // Check key type
    102         val (type, raw) = when (str.length) {
    103             ADMIN_BALANCE_ADJUST.length -> if (str.equals(ADMIN_BALANCE_ADJUST, ignoreCase = true)) {
    104                 return Candidate(IncomingSubject.AdminBalanceAdjust, Base32Quality.UpperStandard)
    105             } else {
    106                 return null
    107             }
    108             KEY_SIZE -> Pair(IncomingType.reserve, str)
    109             PREFIX_SIZE -> if (str.startsWith("KYC")) {
    110                 Pair(IncomingType.kyc, str.substring(3))
    111             } else if (str.startsWith("MAP")) {
    112                 Pair(IncomingType.map, str.substring(3))
    113             } else {
    114                 return null
    115             }
    116             else -> return null
    117         }
    118 
    119         // Check key validity
    120         val key = try {
    121             EddsaPublicKey(raw)
    122         } catch (e: Exception) {
    123             return null
    124         }
    125         if (!Ed25519.validatePublicKeyFull(key.raw, 0)) {
    126             return null
    127         }
    128 
    129         val quality = Base32Quality.measure(raw);
    130 
    131         val subject = when (type) {
    132             IncomingType.map -> IncomingSubject.Map(key)
    133             IncomingType.kyc -> IncomingSubject.Kyc(key)
    134             IncomingType.reserve -> IncomingSubject.Reserve(key)
    135         }
    136         return Candidate(subject, quality)
    137     }
    138 
    139     // Find and concatenate valid parts of a keys
    140     val parts = mutableListOf(0)
    141     val concatenated = StringBuilder()
    142     for (match in ALPHA_NUMBERIC_PATTERN.findAll(subject.replace("%20", " "))) {
    143         concatenated.append(match.value);
    144         parts.add(concatenated.length);
    145     }
    146 
    147     // Find best candidates
    148     var best: Candidate? = null
    149     // For each part as a starting point
    150     for ((i, start) in parts.withIndex()) {
    151         // Use progressively longer concatenation
    152         for (end in parts.subList(i, parts.size)) {
    153             val range = start until end
    154             // Until they are to long to be a key
    155             if (range.count() > PREFIX_SIZE) {
    156                 break;
    157             }
    158             // Parse the concatenated parts
    159             val slice = concatenated.substring(range)
    160             parseSingle(slice)?.let { other ->
    161                 if (best != null) {
    162                     if (best.subject is IncomingSubject.AdminBalanceAdjust) {
    163                         if (other.subject !is IncomingSubject.AdminBalanceAdjust) {
    164                             throw Exception("found multiple subject kind")
    165                         }
    166                     } else if (other.quality > best.quality // We prefer high quality keys
    167                         || ( // We prefer prefixed keys over reserve keys
    168                             best.subject.type == IncomingType.reserve &&
    169                             (other.subject.type == IncomingType.kyc || other.subject.type == IncomingType.map)
    170                         ))
    171                     {
    172                         best = other
    173                     } else if (best.subject.key != other.subject.key // If keys are different
    174                         && best.quality == other.quality // Of same quality
    175                         && !( // And prefixing is different
    176                             (best.subject.type == IncomingType.kyc || best.subject.type == IncomingType.map) &&
    177                             other.subject.type == IncomingType.reserve
    178                         ))
    179                     {
    180                         throw Exception("found multiple reserve public key")
    181                     }
    182                 } else {
    183                     best = other
    184                 }
    185             }
    186         }
    187     }
    188 
    189     return best?.subject ?: throw Exception("missing reserve public key")
    190 }
    191 
    192 /** Extract the reserve public key from an incoming Taler transaction subject */
    193 fun parseOutgoingSubject(subject: String): Triple<ShortHashCode, BaseURL, String?>  {
    194     var iterator = subject.splitToSequence(' ').iterator();
    195     val first = iterator.next()
    196     if (!iterator.hasNext()) throw Exception("malformed outgoing subject")
    197     val second = iterator.next()
    198     if (iterator.hasNext()) {
    199         val third = iterator.next()
    200         return Triple(EddsaPublicKey(second), BaseURL.parse(third), first)
    201     } else {
    202         return Triple(EddsaPublicKey(first), BaseURL.parse(second), null)
    203     }
    204 }
    205 
    206 /** Format an outgoing subject */
    207 fun fmtOutgoingSubject(wtid: ShortHashCode, url: BaseURL, metadata: String? = null): String = buildString {
    208     if (metadata != null) {
    209         append(metadata)
    210         append(" ")
    211     }
    212     append(wtid)
    213     append(" ")
    214     append(url)
    215 }
    216 
    217 /** Format an incoming subject */
    218 fun fmtIncomingSubject(type: IncomingType, key: EddsaPublicKey): String = buildString {
    219     append("Taler ")
    220     when (type) {
    221         IncomingType.kyc -> append("KYC:")
    222         IncomingType.map -> append("MAP:")
    223         IncomingType.reserve -> Unit
    224     }
    225     append(key)
    226 }
    227 
    228 /** Encode a public key as a QR-Bill reference */
    229 fun subjectFmtQrBill(key: EddsaPublicKey): String {
    230     // High-Entropy Hash (SHA-256) to ensure even distribution
    231     val digest = MessageDigest.getInstance("SHA-256")
    232     val hashInt = BigInteger(1, digest.digest(key.raw))
    233     
    234     // Modulo 10^26 to fit the Swiss QR data field
    235     val divisor = BigInteger.TEN.pow(26)
    236     val referenceBase = hashInt.remainder(divisor).toString().padStart(26, '0')
    237 
    238     // Modulo 10 Recursive calculation
    239     val lookupTable = intArrayOf(0, 9, 4, 6, 8, 2, 7, 1, 3, 5)
    240     var carry = 0
    241     
    242     for (char in referenceBase) {
    243         val digit = char.digitToInt()
    244         carry = lookupTable[(carry + digit) % 10]
    245     }
    246     
    247     val checksum = (10 - carry) % 10
    248     
    249     return referenceBase + checksum
    250 }
    251 
    252 /** Check if a subject is a valid QR-Bill reference */
    253 fun subjectIsQrBill(reference: String): Boolean {
    254     // Quick length and numeric check
    255     if (reference.length != 27 || !reference.all { it.isDigit() }) {
    256         return false
    257     }
    258 
    259     // Modulo 10 Recursive check
    260     val lookupTable = intArrayOf(0, 9, 4, 6, 8, 2, 7, 1, 3, 5)
    261     var carry = 0
    262     
    263     for (char in reference) {
    264         val digit = char.digitToInt()
    265         carry = lookupTable[(carry + digit) % 10]
    266     }
    267     
    268     // If the check digit was correct, the final carry will be 0
    269     return carry == 0
    270 }