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