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 }