subject.rs (13841B)
1 /* 2 This file is part of TALER 3 Copyright (C) 2024-2025 Taler Systems SA 4 5 TALER is free software; you can redistribute it and/or modify it under the 6 terms of the GNU Affero General Public License as published by the Free Software 7 Foundation; either version 3, or (at your option) any later version. 8 9 TALER is distributed in the hope that it will be useful, but WITHOUT ANY 10 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. 12 13 You should have received a copy of the GNU Affero General Public License along with 14 TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 use std::{fmt::Debug, ops::Deref, str::FromStr}; 18 19 use taler_common::{ 20 api_common::{EddsaPublicKey, ShortHashCode}, 21 types::base32::{Base32Error, CROCKFORD_ALPHABET}, 22 }; 23 use url::Url; 24 25 use crate::db::IncomingType; 26 27 #[derive(Debug, Clone, PartialEq, Eq)] 28 pub enum IncomingSubject { 29 Reserve(EddsaPublicKey), 30 Kyc(EddsaPublicKey), 31 } 32 33 impl IncomingSubject { 34 pub fn ty(&self) -> IncomingType { 35 match self { 36 IncomingSubject::Reserve(_) => IncomingType::reserve, 37 IncomingSubject::Kyc(_) => IncomingType::kyc, 38 } 39 } 40 41 pub fn key(&self) -> &[u8] { 42 match self { 43 IncomingSubject::Kyc(key) | IncomingSubject::Reserve(key) => key.deref(), 44 } 45 } 46 } 47 48 #[derive(Debug, PartialEq, Eq)] 49 pub struct OutgoingSubject(pub ShortHashCode, pub Url); 50 51 /** Base32 quality by proximity to spec and error probability */ 52 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 53 enum Base32Quality { 54 /// Both mixed casing and mixed characters, that's weird 55 Mixed, 56 /// Standard but use lowercase, maybe the client shown lowercase in the UI 57 Standard, 58 /// Uppercase but mixed characters, its common when making typos 59 Upper, 60 /// Both uppercase and use the standard alphabet as it should 61 UpperStandard, 62 } 63 64 impl Base32Quality { 65 pub fn measure(s: &str) -> Self { 66 let mut uppercase = true; 67 let mut standard = true; 68 for b in s.bytes() { 69 uppercase &= b.is_ascii_uppercase(); 70 standard &= CROCKFORD_ALPHABET.contains(&b) 71 } 72 match (uppercase, standard) { 73 (true, true) => Base32Quality::UpperStandard, 74 (true, false) => Base32Quality::Upper, 75 (false, true) => Base32Quality::Standard, 76 (false, false) => Base32Quality::Mixed, 77 } 78 } 79 } 80 81 #[derive(Debug)] 82 pub struct Candidate { 83 subject: IncomingSubject, 84 quality: Base32Quality, 85 } 86 87 #[derive(Debug, PartialEq, Eq)] 88 pub enum IncomingSubjectResult { 89 Success(IncomingSubject), 90 Ambiguous, 91 } 92 93 #[derive(Debug, PartialEq, Eq, thiserror::Error)] 94 pub enum IncomingSubjectErr { 95 #[error("found multiple public keys")] 96 Ambiguous, 97 } 98 99 #[derive(Debug, thiserror::Error)] 100 pub enum OutgoingSubjectErr { 101 #[error("missing parts")] 102 MissingParts, 103 #[error("malformed wtid: {0}")] 104 Wtid(#[from] Base32Error<32>), 105 #[error("malformed exchange url: {0}")] 106 Url(#[from] url::ParseError), 107 } 108 109 /** 110 * Extract the wtid and exchange url from an outgoing transfer subject. 111 */ 112 pub fn parse_outgoing(subject: &str) -> Result<OutgoingSubject, OutgoingSubjectErr> { 113 let (wtid, base_url) = subject 114 .split_once(" ") 115 .ok_or(OutgoingSubjectErr::MissingParts)?; 116 let wtid = wtid.parse()?; 117 let base_url = base_url.parse()?; 118 Ok(OutgoingSubject(wtid, base_url)) 119 } 120 121 /** 122 * Extract the public key from an unstructured incoming transfer subject. 123 * 124 * When a user enters the transfer object in an unstructured way, for ex in 125 * their banking UI, they may mistakenly enter separators such as ' \n-+' and 126 * make typos. 127 * To parse them while ignoring user errors, we reconstruct valid keys from key 128 * parts, resolving ambiguities where possible. 129 **/ 130 pub fn parse_incoming_unstructured( 131 subject: &str, 132 ) -> Result<Option<IncomingSubject>, IncomingSubjectErr> { 133 // We expect subject to be less than 65KB 134 assert!(subject.len() <= u16::MAX as usize); 135 136 const KEY_SIZE: usize = 52; 137 const KYC_SIZE: usize = KEY_SIZE + 3; 138 139 /** Parse an incoming subject */ 140 #[inline] 141 fn parse_single(str: &str) -> Option<Candidate> { 142 // Check key type 143 let (is_kyc, raw) = match str.len() { 144 KEY_SIZE => (false, str), 145 KYC_SIZE => { 146 if let Some(key) = str.strip_prefix("KYC") { 147 (true, key) 148 } else { 149 return None; 150 } 151 } 152 _ => unreachable!(), 153 }; 154 155 // Check key validity 156 let key = EddsaPublicKey::from_str(raw).ok()?; 157 if ed25519_dalek::VerifyingKey::from_bytes(&key).is_err() { 158 return None; 159 } 160 161 let quality = Base32Quality::measure(raw); 162 Some(Candidate { 163 subject: if is_kyc { 164 IncomingSubject::Kyc(key) 165 } else { 166 IncomingSubject::Reserve(key) 167 }, 168 quality, 169 }) 170 } 171 172 // Find and concatenate valid parts of a keys 173 let (parts, concatenated) = { 174 let mut parts = Vec::with_capacity(4); 175 let mut concatenated = String::with_capacity(subject.len().min(KYC_SIZE + 10)); 176 parts.push(0u16); 177 for part in subject.as_bytes().split(|b| !b.is_ascii_alphanumeric()) { 178 // SAFETY: part are all valid ASCII alphanumeric 179 concatenated.push_str(unsafe { std::str::from_utf8_unchecked(part) }); 180 parts.push(concatenated.len() as u16); 181 } 182 (parts, concatenated) 183 }; 184 185 // Find best candidates 186 let mut best: Option<Candidate> = None; 187 // For each part as a starting point 188 for (i, &start) in parts.iter().enumerate() { 189 // Use progressively longer concatenation 190 for &end in parts[i..].iter().skip(1) { 191 let len = (end - start) as usize; 192 // Until they are to long to be a key 193 if len > KYC_SIZE { 194 break; 195 } else if len != KEY_SIZE && len != KYC_SIZE { 196 continue; 197 } 198 199 // Parse the concatenated parts 200 // SAFETY: we now end.end <= concatenated.len 201 let slice = unsafe { &concatenated.get_unchecked(start as usize..end as usize) }; 202 if let Some(other) = parse_single(slice) { 203 // On success update best candidate 204 match &mut best { 205 Some(best) => { 206 if other.quality > best.quality // We prefer high quality keys 207 || matches!( // We prefer prefixed keys over reserve keys 208 (&best.subject.ty(), &other.subject.ty()), 209 (IncomingType::reserve, IncomingType::kyc | IncomingType::wad) 210 ) 211 { 212 *best = other 213 } else if best.subject.key() != other.subject.key() // If keys are different 214 && best.quality == other.quality // Of same quality 215 && !matches!( // And prefixing is different 216 (&best.subject.ty(), &other.subject.ty()), 217 (IncomingType::kyc | IncomingType::wad, IncomingType::reserve) 218 ) 219 { 220 return Err(IncomingSubjectErr::Ambiguous); 221 } 222 } 223 None => best = Some(other), 224 } 225 } 226 } 227 } 228 229 Ok(best.map(|it| it.subject)) 230 } 231 232 #[test] 233 /** Test parsing logic */ 234 fn parse() { 235 let key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"; 236 let other = "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG"; 237 238 // Common checks 239 for ty in [IncomingType::reserve, IncomingType::kyc] { 240 let prefix = match ty { 241 IncomingType::reserve => "", 242 IncomingType::kyc => "KYC", 243 IncomingType::wad => unreachable!(), 244 }; 245 let standard = &format!("{prefix}{key}"); 246 let (standard_l, standard_r) = standard.split_at(standard.len() / 2); 247 let mixed = &format!("{prefix}4mzt6RS3rvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0"); 248 let (mixed_l, mixed_r) = mixed.split_at(mixed.len() / 2); 249 let other_standard = &format!("{prefix}{other}"); 250 let other_mixed = &format!("{prefix}TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60"); 251 252 let result = Ok(Some(match ty { 253 IncomingType::reserve => { 254 IncomingSubject::Reserve(EddsaPublicKey::from_str(key).unwrap()) 255 } 256 IncomingType::kyc => IncomingSubject::Kyc(EddsaPublicKey::from_str(key).unwrap()), 257 IncomingType::wad => unreachable!(), 258 })); 259 260 // Check succeed if standard or mixed 261 for case in [standard, mixed] { 262 for test in [ 263 format!("noise {case} noise"), 264 format!("{case} noise to the right"), 265 format!("noise to the left {case}"), 266 format!(" {case} "), 267 format!("noise\n{case}\nnoise"), 268 format!("Test+{case}"), 269 ] { 270 assert_eq!(parse_incoming_unstructured(&test), result); 271 } 272 } 273 274 // Check succeed if standard or mixed and split 275 for (l, r) in [(standard_l, standard_r), (mixed_l, mixed_r)] { 276 for case in [ 277 format!("left {l}{r} right"), 278 format!("left {l} {r} right"), 279 format!("left {l}-{r} right"), 280 format!("left {l}+{r} right"), 281 format!("left {l}\n{r} right"), 282 format!("left {l}-+\n{r} right"), 283 format!("left {l} - {r} right"), 284 format!("left {l} + {r} right"), 285 format!("left {l} \n {r} right"), 286 format!("left {l} - + \n {r} right"), 287 ] { 288 assert_eq!(parse_incoming_unstructured(&case), result); 289 } 290 } 291 292 // Check concat parts 293 for chunk_size in 1..standard.len() { 294 let chunked: String = standard 295 .as_bytes() 296 .chunks(chunk_size) 297 .flat_map(|c| [std::str::from_utf8(c).unwrap(), " "]) 298 .collect(); 299 for case in [chunked.clone(), format!("left {chunked} right")] { 300 assert_eq!(parse_incoming_unstructured(&case), result); 301 } 302 } 303 304 // Check failed when multiple key 305 for case in [ 306 format!("{standard} {other_standard}"), 307 format!("{mixed} {other_mixed}"), 308 ] { 309 assert_eq!( 310 parse_incoming_unstructured(&case), 311 Err(IncomingSubjectErr::Ambiguous) 312 ); 313 } 314 315 // Check accept redundant key 316 for case in [ 317 format!("{standard} {standard} {mixed} {mixed}"), // Accept redundant key 318 format!("{standard} {other_mixed}"), // Prefer high quality 319 ] { 320 assert_eq!(parse_incoming_unstructured(&case), result); 321 } 322 323 // Check prefer prefixed over simple 324 for case in [format!("{mixed_l}-{mixed_r} {standard_l}-{standard_r}")] { 325 let res = parse_incoming_unstructured(&case); 326 if ty == IncomingType::reserve { 327 assert_eq!(res, Err(IncomingSubjectErr::Ambiguous)); 328 } else { 329 assert_eq!(res, result); 330 } 331 } 332 333 // Check failure if malformed or missing 334 for case in [ 335 "does not contain any reserve", // Check fail if none 336 &standard[1..], // Check fail if missing char 337 "2MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0", // Check fail if not a valid key 338 ] { 339 assert_eq!(parse_incoming_unstructured(&case), Ok(None)); 340 } 341 342 if ty == IncomingType::kyc { 343 // Prefer prefixed over unprefixed 344 for case in [format!("{other} {standard}"), format!("{other} {mixed}")] { 345 assert_eq!(parse_incoming_unstructured(&case), result); 346 } 347 } 348 } 349 } 350 351 #[test] 352 /** Test parsing logic using real cases */ 353 fn real() { 354 // Good reserve case 355 for (subject, key) in [ 356 ( 357 "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60", 358 "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60", 359 ), 360 ( 361 "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG", 362 "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG", 363 ), 364 ( 365 "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0", 366 "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0", 367 ), 368 ( 369 "KYCVEEXTBXBEMCS5R64C24GFNQVWBN5R2F9QSQ7PN8QXAP1NG4NG", 370 "KYCVEEXTBXBEMCS5R64C24GFNQVWBN5R2F9QSQ7PN8QXAP1NG4NG", 371 ), 372 ] { 373 assert_eq!( 374 Ok(Some(IncomingSubject::Reserve( 375 EddsaPublicKey::from_str(key).unwrap(), 376 ))), 377 parse_incoming_unstructured(subject) 378 ) 379 } 380 // Good kyc case 381 for (subject, key) in [( 382 "KYC JW398X85FWPKKMS0EYB6TQ1799RMY5DDXTZ FPW4YC3WJ2DWSJT70", 383 "JW398X85FWPKKMS0EYB6TQ1799RMY5DDXTZFPW4YC3WJ2DWSJT70", 384 )] { 385 assert_eq!( 386 Ok(Some(IncomingSubject::Kyc( 387 EddsaPublicKey::from_str(key).unwrap(), 388 ))), 389 parse_incoming_unstructured(subject) 390 ) 391 } 392 }