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