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 }