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