subject.rs (20777B)
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 aws_lc_rs::digest::{SHA256, digest}; 23 use compact_str::CompactString; 24 use taler_common::{ 25 api_common::{EddsaPublicKey, ShortHashCode}, 26 db::IncomingType, 27 types::{ 28 base32::{Base32Error, CROCKFORD_ALPHABET}, 29 url, 30 }, 31 }; 32 use url::Url; 33 34 #[derive(Debug, Clone, PartialEq, Eq)] 35 pub enum IncomingSubject { 36 Reserve(EddsaPublicKey), 37 Kyc(EddsaPublicKey), 38 Map(EddsaPublicKey), 39 AdminBalanceAdjust, 40 } 41 42 impl IncomingSubject { 43 pub fn ty(&self) -> IncomingType { 44 match self { 45 IncomingSubject::Reserve(_) => IncomingType::reserve, 46 IncomingSubject::Kyc(_) => IncomingType::kyc, 47 IncomingSubject::Map(_) => IncomingType::map, 48 IncomingSubject::AdminBalanceAdjust => panic!("Admin balance adjust"), 49 } 50 } 51 52 pub fn key(&self) -> &[u8] { 53 match self { 54 IncomingSubject::Kyc(key) 55 | IncomingSubject::Reserve(key) 56 | IncomingSubject::Map(key) => key.as_ref(), 57 IncomingSubject::AdminBalanceAdjust => panic!("Admin balance adjust"), 58 } 59 } 60 } 61 62 #[derive(Debug, PartialEq, Eq)] 63 pub struct OutgoingSubject { 64 pub wtid: ShortHashCode, 65 pub exchange_base_url: Url, 66 pub metadata: Option<CompactString>, 67 } 68 69 impl OutgoingSubject { 70 /// Generate a random outgoing subject for https://exchange.test.com 71 pub fn rand() -> Self { 72 Self { 73 wtid: ShortHashCode::rand(), 74 exchange_base_url: url("https://exchange.test.com"), 75 metadata: None, 76 } 77 } 78 } 79 80 /** Base32 quality by proximity to spec and error probability */ 81 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 82 enum Base32Quality { 83 /// Both mixed casing and mixed characters, that's weird 84 Mixed, 85 /// Standard but use lowercase, maybe the client shown lowercase in the UI 86 Standard, 87 /// Uppercase but mixed characters, its common when making typos 88 Upper, 89 /// Both uppercase and use the standard alphabet as it should 90 UpperStandard, 91 } 92 93 impl Base32Quality { 94 pub fn measure(s: &str) -> Self { 95 let mut uppercase = true; 96 let mut standard = true; 97 for b in s.bytes() { 98 uppercase &= b.is_ascii_uppercase(); 99 standard &= CROCKFORD_ALPHABET.contains(&b) 100 } 101 match (uppercase, standard) { 102 (true, true) => Base32Quality::UpperStandard, 103 (true, false) => Base32Quality::Upper, 104 (false, true) => Base32Quality::Standard, 105 (false, false) => Base32Quality::Mixed, 106 } 107 } 108 } 109 110 #[derive(Debug)] 111 pub struct Candidate { 112 subject: IncomingSubject, 113 quality: Base32Quality, 114 } 115 116 #[derive(Debug, PartialEq, Eq)] 117 pub enum IncomingSubjectResult { 118 Success(IncomingSubject), 119 Ambiguous, 120 } 121 122 #[derive(Debug, PartialEq, Eq, thiserror::Error)] 123 pub enum IncomingSubjectErr { 124 #[error("found multiple public keys")] 125 Ambiguous, 126 } 127 128 #[derive(Debug, thiserror::Error)] 129 pub enum OutgoingSubjectErr { 130 #[error("missing parts")] 131 MissingParts, 132 #[error("malformed wtid: {0}")] 133 Wtid(#[from] Base32Error<32>), 134 #[error("malformed exchange url: {0}")] 135 Url(#[from] url::ParseError), 136 } 137 138 /// Parse a talerable outgoing transfer subject 139 pub fn parse_outgoing(subject: &str) -> Result<OutgoingSubject, OutgoingSubjectErr> { 140 let mut parts = subject.split(' '); 141 let first = parts.next().ok_or(OutgoingSubjectErr::MissingParts)?; 142 let second = parts.next().ok_or(OutgoingSubjectErr::MissingParts)?; 143 Ok(if let Some(third) = parts.next() { 144 OutgoingSubject { 145 wtid: second.parse()?, 146 exchange_base_url: third.parse()?, 147 metadata: Some(first.into()), 148 } 149 } else { 150 OutgoingSubject { 151 wtid: first.parse()?, 152 exchange_base_url: second.parse()?, 153 metadata: None, 154 } 155 }) 156 } 157 158 /// Format an outgoing subject 159 pub fn fmt_out_subject(wtid: &ShortHashCode, url: &Url, metadata: Option<&str>) -> String { 160 let mut buf = String::new(); 161 if let Some(metadata) = metadata { 162 buf.push_str(metadata); 163 buf.push(' '); 164 } 165 write!(&mut buf, "{wtid} {url}").unwrap(); 166 buf 167 } 168 169 /// Format an incoming subject 170 pub fn fmt_in_subject(ty: IncomingType, key: &EddsaPublicKey) -> String { 171 match ty { 172 IncomingType::reserve => format!("{key}"), 173 IncomingType::kyc => format!("KYC:{key}"), 174 IncomingType::map => format!("MAP:{key}"), 175 } 176 } 177 178 /** 179 * Extract the public key from an unstructured incoming transfer subject. 180 * 181 * When a user enters the transfer object in an unstructured way, for ex in 182 * their banking UI, they may mistakenly enter separators such as ' \n-+' and 183 * make typos. 184 * To parse them while ignoring user errors, we reconstruct valid keys from key 185 * parts, resolving ambiguities where possible. 186 **/ 187 pub fn parse_incoming_unstructured( 188 subject: &str, 189 ) -> Result<Option<IncomingSubject>, IncomingSubjectErr> { 190 // We expect subject to be less than 65KB 191 assert!(subject.len() <= u16::MAX as usize); 192 193 const KEY_SIZE: usize = 52; 194 const PREFIXED_SIZE: usize = KEY_SIZE + 3; 195 const ADMIN_BALANCE_ADJUST: &str = "ADMINBALANCEADJUST"; 196 197 /** Parse an incoming subject */ 198 #[inline] 199 fn parse_single(str: &str) -> Option<Candidate> { 200 if str == ADMIN_BALANCE_ADJUST { 201 return Some(Candidate { 202 subject: IncomingSubject::AdminBalanceAdjust, 203 quality: Base32Quality::UpperStandard, 204 }); 205 } 206 // Check key type 207 let (ty, raw) = match str.len() { 208 KEY_SIZE => (IncomingType::reserve, str), 209 PREFIXED_SIZE => { 210 if let Some(key) = str.strip_prefix("KYC") { 211 (IncomingType::kyc, key) 212 } else if let Some(key) = str.strip_prefix("MAP") { 213 (IncomingType::map, key) 214 } else { 215 return None; 216 } 217 } 218 _ => return None, 219 }; 220 221 // Check key validity 222 let key = EddsaPublicKey::from_str(raw).ok()?; 223 224 let quality = Base32Quality::measure(raw); 225 Some(Candidate { 226 subject: match ty { 227 IncomingType::reserve => IncomingSubject::Reserve(key), 228 IncomingType::kyc => IncomingSubject::Kyc(key), 229 IncomingType::map => IncomingSubject::Map(key), 230 }, 231 quality, 232 }) 233 } 234 235 // Find and concatenate valid parts of a keys 236 let (parts, concatenated) = { 237 let mut parts = Vec::with_capacity(4); 238 let mut concatenated = String::with_capacity(subject.len().min(PREFIXED_SIZE + 10)); 239 parts.push(0u16); 240 for part in subject.as_bytes().split(|b| !b.is_ascii_alphanumeric()) { 241 // SAFETY: part are all valid ASCII alphanumeric 242 concatenated.push_str(unsafe { std::str::from_utf8_unchecked(part) }); 243 parts.push(concatenated.len() as u16); 244 } 245 (parts, concatenated) 246 }; 247 248 // Find best candidates 249 let mut best: Option<Candidate> = None; 250 // For each part as a starting point 251 for (i, &start) in parts.iter().enumerate() { 252 // Use progressively longer concatenation 253 for &end in parts[i..].iter().skip(1) { 254 let len = (end - start) as usize; 255 // Until they are to long to be a key 256 if len > PREFIXED_SIZE { 257 break; 258 } else if len != KEY_SIZE && len != PREFIXED_SIZE && len != ADMIN_BALANCE_ADJUST.len() { 259 continue; 260 } 261 262 // Parse the concatenated parts 263 // SAFETY: we now end.end <= concatenated.len 264 let slice = unsafe { &concatenated.get_unchecked(start as usize..end as usize) }; 265 if let Some(other) = parse_single(slice) { 266 // On success update best candidate 267 match &mut best { 268 Some(best) => { 269 if other.quality > best.quality // We prefer high quality keys 270 || matches!( // We prefer prefixed keys over reserve keys 271 (&best.subject.ty(), &other.subject.ty()), 272 (IncomingType::reserve, IncomingType::kyc | IncomingType::map) 273 ) 274 { 275 *best = other 276 } else if best.subject.key() != other.subject.key() // If keys are different 277 && best.quality == other.quality // Of same quality 278 && !matches!( // And prefixing is different 279 (&best.subject.ty(), &other.subject.ty()), 280 (IncomingType::kyc | IncomingType::map, IncomingType::reserve) 281 ) 282 { 283 return Err(IncomingSubjectErr::Ambiguous); 284 } 285 } 286 None => best = Some(other), 287 } 288 } 289 } 290 } 291 292 Ok(best.map(|it| it.subject)) 293 } 294 295 // Modulo 10 Recursive 296 fn mod10_recursive(bytes: &[u8]) -> u8 { 297 const LOOKUP_TABLE: [u8; 10] = [0, 9, 4, 6, 8, 2, 7, 1, 3, 5]; 298 // Modulo 10 Recursive calculation 299 let mut carry = 0u8; 300 for &b in bytes { 301 // ASCII '0'-'9' is 0x30-0x39. Subtracting b'0' (48) gives the integer. 302 let digit = b - b'0'; 303 carry = LOOKUP_TABLE[((carry + digit) % 10) as usize]; 304 } 305 carry 306 } 307 308 /// Encode a public key as a QR-Bill reference 309 pub fn subject_fmt_qr_bill(key_bytes: &[u8]) -> String { 310 // High-Entropy Hash (SHA-256) to ensure even distribution 311 let hash = digest(&SHA256, key_bytes); 312 313 // Compute hash % 10^26 314 let hash_mod = hash.as_ref().chunks(3).fold(0u128, |rem, chunk| { 315 chunk.iter().fold(rem, |r, &b| r * 256 + b as u128) % 10u128.pow(26) 316 }); 317 318 // Format to 26 digits with leading zeros 319 let reference_base = format!("{:0>26}", hash_mod); 320 321 // Modulo 10 Recursive calculation 322 let carry = mod10_recursive(reference_base.as_bytes()); 323 let checksum = (10 - carry) % 10; 324 325 // Combine base (26) + checksum (1) = 27 characters 326 format!("{}{}", reference_base, checksum) 327 } 328 329 /// Check if a string is a valid QR-Bill reference 330 pub fn subject_is_qr_bill(reference: &str) -> bool { 331 // Quick length and numeric check 332 if reference.len() != 27 || !reference.chars().all(|c| c.is_ascii_digit()) { 333 return false; 334 } 335 336 // If the check digit is correct, the final carry will be 0 337 mod10_recursive(reference.as_bytes()) == 0 338 } 339 340 #[cfg(test)] 341 mod test { 342 use std::str::FromStr as _; 343 344 use taler_common::{ 345 api_common::{EddsaPublicKey, ShortHashCode}, 346 db::IncomingType, 347 types::url, 348 }; 349 350 use crate::subject::{ 351 IncomingSubject, IncomingSubjectErr, OutgoingSubject, fmt_out_subject, mod10_recursive, 352 parse_incoming_unstructured, parse_outgoing, subject_fmt_qr_bill, subject_is_qr_bill, 353 }; 354 355 #[test] 356 fn qrbill() { 357 let reference = "210000000003139471430009017"; 358 359 let input = "21000000000313947143000901"; 360 let carry = mod10_recursive(input.as_bytes()); 361 let checksum = (10 - carry) % 10; 362 assert_eq!(checksum, 7); 363 364 assert_eq!(mod10_recursive(reference.as_bytes()), 0); 365 assert!(subject_is_qr_bill(reference)); 366 assert!(!subject_is_qr_bill(input)); 367 assert!(!subject_is_qr_bill("")); 368 assert!(!subject_is_qr_bill("210000000003139471430009019")); 369 assert!(!subject_is_qr_bill("21000000000313947143000901A")); 370 371 let key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"; 372 let key = EddsaPublicKey::from_str(key).unwrap(); 373 assert_eq!( 374 subject_fmt_qr_bill(key.as_ref()), 375 "442862674560948379842733643" 376 ); 377 } 378 379 #[test] 380 /** Test parsing logic */ 381 fn incoming_parse() { 382 let key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"; 383 let other = "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG"; 384 385 // Common checks 386 for ty in [IncomingType::reserve, IncomingType::kyc, IncomingType::map] { 387 let prefix = match ty { 388 IncomingType::reserve => "", 389 IncomingType::kyc => "KYC", 390 IncomingType::map => "MAP", 391 }; 392 let standard = &format!("{prefix}{key}"); 393 let (standard_l, standard_r) = standard.split_at(standard.len() / 2); 394 let mixed = &format!("{prefix}4mzt6RS3rvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0"); 395 let (mixed_l, mixed_r) = mixed.split_at(mixed.len() / 2); 396 let other_standard = &format!("{prefix}{other}"); 397 let other_mixed = 398 &format!("{prefix}TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60"); 399 let key = EddsaPublicKey::from_str(key).unwrap(); 400 let result = Ok(Some(match ty { 401 IncomingType::reserve => IncomingSubject::Reserve(key), 402 IncomingType::kyc => IncomingSubject::Kyc(key), 403 IncomingType::map => IncomingSubject::Map(key), 404 })); 405 406 // Check succeed if standard or mixed 407 for case in [standard, mixed] { 408 for test in [ 409 format!("noise {case} noise"), 410 format!("{case} noise to the right"), 411 format!("noise to the left {case}"), 412 format!(" {case} "), 413 format!("noise\n{case}\nnoise"), 414 format!("Test+{case}"), 415 ] { 416 assert_eq!(parse_incoming_unstructured(&test), result); 417 } 418 } 419 420 // Check succeed if standard or mixed and split 421 for (l, r) in [(standard_l, standard_r), (mixed_l, mixed_r)] { 422 for case in [ 423 format!("left {l}{r} right"), 424 format!("left {l} {r} right"), 425 format!("left {l}-{r} right"), 426 format!("left {l}+{r} right"), 427 format!("left {l}\n{r} right"), 428 format!("left {l}-+\n{r} right"), 429 format!("left {l} - {r} right"), 430 format!("left {l} + {r} right"), 431 format!("left {l} \n {r} right"), 432 format!("left {l} - + \n {r} right"), 433 ] { 434 assert_eq!(parse_incoming_unstructured(&case), result); 435 } 436 } 437 438 // Check concat parts 439 for chunk_size in 1..standard.len() { 440 let chunked: String = standard 441 .as_bytes() 442 .chunks(chunk_size) 443 .flat_map(|c| [std::str::from_utf8(c).unwrap(), " "]) 444 .collect(); 445 for case in [chunked.clone(), format!("left {chunked} right")] { 446 assert_eq!(parse_incoming_unstructured(&case), result); 447 } 448 } 449 450 // Check failed when multiple key 451 for case in [ 452 format!("{standard} {other_standard}"), 453 format!("{mixed} {other_mixed}"), 454 ] { 455 assert_eq!( 456 parse_incoming_unstructured(&case), 457 Err(IncomingSubjectErr::Ambiguous) 458 ); 459 } 460 461 // Check accept redundant key 462 for case in [ 463 format!("{standard} {standard} {mixed} {mixed}"), // Accept redundant key 464 format!("{standard} {other_mixed}"), // Prefer high quality 465 ] { 466 assert_eq!(parse_incoming_unstructured(&case), result); 467 } 468 469 // Check prefer prefixed over simple 470 for case in [format!("{mixed_l}-{mixed_r} {standard_l}-{standard_r}")] { 471 let res = parse_incoming_unstructured(&case); 472 if ty == IncomingType::reserve { 473 assert_eq!(res, Err(IncomingSubjectErr::Ambiguous)); 474 } else { 475 assert_eq!(res, result); 476 } 477 } 478 479 // Check failure if malformed or missing 480 for case in [ 481 "does not contain any reserve", // Check fail if none 482 &standard[1..], // Check fail if missing char 483 //"2MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0", // Check fail if not a valid key 484 ] { 485 assert_eq!(parse_incoming_unstructured(case), Ok(None)); 486 } 487 488 if ty == IncomingType::kyc || ty == IncomingType::map { 489 // Prefer prefixed over unprefixed 490 for case in [format!("{other} {standard}"), format!("{other} {mixed}")] { 491 assert_eq!(parse_incoming_unstructured(&case), result); 492 } 493 } 494 } 495 } 496 497 #[test] 498 /** Test parsing logic using real cases */ 499 fn real() { 500 // Good reserve case 501 for (subject, key) in [ 502 ( 503 "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60", 504 "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60", 505 ), 506 ( 507 "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG", 508 "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG", 509 ), 510 ( 511 "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0", 512 "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0", 513 ), 514 ( 515 "KYCVEEXTBXBEMCS5R64C24GFNQVWBN5R2F9QSQ7PN8QXAP1NG4NG", 516 "KYCVEEXTBXBEMCS5R64C24GFNQVWBN5R2F9QSQ7PN8QXAP1NG4NG", 517 ), 518 ] { 519 assert_eq!( 520 Ok(Some(IncomingSubject::Reserve( 521 EddsaPublicKey::from_str(key).unwrap(), 522 ))), 523 parse_incoming_unstructured(subject) 524 ) 525 } 526 // Good kyc case 527 for (subject, key) in [( 528 "KYC JW398X85FWPKKMS0EYB6TQ1799RMY5DDXTZ FPW4YC3WJ2DWSJT70", 529 "JW398X85FWPKKMS0EYB6TQ1799RMY5DDXTZFPW4YC3WJ2DWSJT70", 530 )] { 531 assert_eq!( 532 Ok(Some(IncomingSubject::Kyc( 533 EddsaPublicKey::from_str(key).unwrap(), 534 ))), 535 parse_incoming_unstructured(subject) 536 ) 537 } 538 } 539 540 #[test] 541 fn outgoing() { 542 let key = ShortHashCode::rand(); 543 544 // Without metadata 545 let subject = format!("{key} http://exchange.example.com/"); 546 let parsed = parse_outgoing(&subject).unwrap(); 547 assert_eq!( 548 parsed, 549 OutgoingSubject { 550 wtid: key.clone(), 551 exchange_base_url: url("http://exchange.example.com/"), 552 metadata: None 553 } 554 ); 555 assert_eq!( 556 subject, 557 fmt_out_subject( 558 &parsed.wtid, 559 &parsed.exchange_base_url, 560 parsed.metadata.as_deref() 561 ) 562 ); 563 564 // With metadata 565 let subject = format!("Accounting:id.4 {key} http://exchange.example.com/"); 566 let parsed = parse_outgoing(&subject).unwrap(); 567 assert_eq!( 568 parsed, 569 OutgoingSubject { 570 wtid: key.clone(), 571 exchange_base_url: url("http://exchange.example.com/"), 572 metadata: Some("Accounting:id.4".into()) 573 } 574 ); 575 assert_eq!( 576 subject, 577 fmt_out_subject( 578 &parsed.wtid, 579 &parsed.exchange_base_url, 580 parsed.metadata.as_deref() 581 ) 582 ); 583 } 584 }