subject.rs (21765B)
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 65KB 201 assert!(subject.len() <= u16::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(0u16); 246 for part in subject.as_bytes().split(|b| !b.is_ascii_alphanumeric()) { 247 // SAFETY: part are all valid ASCII alphanumeric 248 concatenated.push_str(unsafe { std::str::from_utf8_unchecked(part) }); 249 parts.push(concatenated.len() as u16); 250 } 251 (parts, concatenated) 252 }; 253 254 // Find best candidates 255 let mut best: Option<(IncomingType, EddsaPublicKey, Base32Quality)> = None; 256 // For each part as a starting point 257 for (i, &start) in parts.iter().enumerate() { 258 // Use progressively longer concatenation 259 for &end in parts[i..].iter().skip(1) { 260 let len = (end - start) as usize; 261 // Until they are to long to be a key 262 if len > PREFIXED_SIZE { 263 break; 264 } else if len != KEY_SIZE && len != PREFIXED_SIZE && len != ADMIN_BALANCE_ADJUST.len() { 265 continue; 266 } 267 268 // Parse the concatenated parts 269 // SAFETY: we now end.end <= concatenated.len 270 let slice = unsafe { &concatenated.get_unchecked(start as usize..end as usize) }; 271 if let Some(new) = parse_single(slice) { 272 let (nty, nkey) = match new.subject { 273 IncomingSubject::AdminBalanceAdjust => { 274 return Ok(IncomingSubject::AdminBalanceAdjust); 275 } 276 IncomingSubject::Key(IncomingKey { ty, key }) => (ty, key), 277 }; 278 // On success update best candidate 279 match best { 280 Some((bty, bkey, bquality)) => { 281 if new.quality > bquality // We prefer high quality keys 282 || matches!( // We prefer prefixed keys over reserve keys 283 (bty, nty), 284 (IncomingType::reserve, IncomingType::kyc | IncomingType::map) 285 ) 286 { 287 best = Some((nty, nkey, new.quality)) 288 } else if bkey != nkey // If keys are different 289 && bquality == new.quality // Of same quality 290 && !matches!( // And prefixing is different 291 (bty, nty), 292 (IncomingType::kyc | IncomingType::map, IncomingType::reserve) 293 ) 294 { 295 return Err(IncomingSubjectErr::Ambiguous); 296 } 297 } 298 None => best = Some((nty, nkey, new.quality)), 299 } 300 } 301 } 302 } 303 304 if let Some((ty, key, _)) = best { 305 Ok(IncomingSubject::Key(IncomingKey { ty, key })) 306 } else { 307 Err(IncomingSubjectErr::Missing) 308 } 309 } 310 311 // Modulo 10 Recursive 312 fn mod10_recursive(bytes: &[u8]) -> u8 { 313 const LOOKUP_TABLE: [u8; 10] = [0, 9, 4, 6, 8, 2, 7, 1, 3, 5]; 314 // Modulo 10 Recursive calculation 315 let mut carry = 0u8; 316 for &b in bytes { 317 // ASCII '0'-'9' is 0x30-0x39. Subtracting b'0' (48) gives the integer. 318 let digit = b - b'0'; 319 carry = LOOKUP_TABLE[((carry + digit) % 10) as usize]; 320 } 321 carry 322 } 323 324 /// Encode a public key as a QR-Bill reference 325 pub fn subject_fmt_qr_bill(key_bytes: &[u8]) -> String { 326 // High-Entropy Hash (SHA-256) to ensure even distribution 327 let hash = digest(&SHA256, key_bytes); 328 329 // Compute hash % 10^26 330 let hash_mod = hash.as_ref().chunks(3).fold(0u128, |rem, chunk| { 331 chunk.iter().fold(rem, |r, &b| r * 256 + b as u128) % 10u128.pow(26) 332 }); 333 334 // Format to 26 digits with leading zeros 335 let reference_base = format!("{:0>26}", hash_mod); 336 337 // Modulo 10 Recursive calculation 338 let carry = mod10_recursive(reference_base.as_bytes()); 339 let checksum = (10 - carry) % 10; 340 341 // Combine base (26) + checksum (1) = 27 characters 342 format!("{}{}", reference_base, checksum) 343 } 344 345 /// Check if a string is a valid QR-Bill reference 346 pub fn subject_is_qr_bill(reference: &str) -> bool { 347 // Quick length and numeric check 348 if reference.len() != 27 || !reference.chars().all(|c| c.is_ascii_digit()) { 349 return false; 350 } 351 352 // If the check digit is correct, the final carry will be 0 353 mod10_recursive(reference.as_bytes()) == 0 354 } 355 356 #[cfg(test)] 357 mod test { 358 use std::str::FromStr as _; 359 360 use taler_common::{ 361 api::{EddsaPublicKey, ShortHashCode}, 362 db::IncomingType, 363 types::url, 364 }; 365 366 use crate::subject::{ 367 Base32Quality, IncomingKey, IncomingSubject, IncomingSubjectErr, OutgoingSubject, 368 fmt_out_subject, mod10_recursive, parse_incoming_unstructured, parse_outgoing, 369 subject_fmt_qr_bill, subject_is_qr_bill, 370 }; 371 372 #[test] 373 fn qrbill() { 374 let reference = "210000000003139471430009017"; 375 376 let input = "21000000000313947143000901"; 377 let carry = mod10_recursive(input.as_bytes()); 378 let checksum = (10 - carry) % 10; 379 assert_eq!(checksum, 7); 380 381 assert_eq!(mod10_recursive(reference.as_bytes()), 0); 382 assert!(subject_is_qr_bill(reference)); 383 assert!(!subject_is_qr_bill(input)); 384 assert!(!subject_is_qr_bill("")); 385 assert!(!subject_is_qr_bill("210000000003139471430009019")); 386 assert!(!subject_is_qr_bill("21000000000313947143000901A")); 387 388 let key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"; 389 let key = EddsaPublicKey::from_str(key).unwrap(); 390 assert_eq!( 391 subject_fmt_qr_bill(key.as_ref()), 392 "442862674560948379842733643" 393 ); 394 } 395 396 #[test] 397 fn quality() { 398 assert_eq!( 399 Base32Quality::measure("4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"), 400 Base32Quality::UpperStandard 401 ); 402 assert_eq!( 403 Base32Quality::measure("4MZT6RS3RVB3B0E2RDMYW0YRA3Y0UPHYV0CYDE6XBB0YMPFXCEG0"), 404 Base32Quality::Upper 405 ); 406 assert_eq!( 407 Base32Quality::measure("4mZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"), 408 Base32Quality::Standard 409 ); 410 assert_eq!( 411 Base32Quality::measure("4mZT6RS3RVB3B0E2RDMYW0YRA3Y0UPHYV0CYDE6XBB0YMPFXCEG0"), 412 Base32Quality::Mixed 413 ); 414 } 415 416 #[test] 417 /** Test parsing logic */ 418 fn incoming_parse() { 419 let key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"; 420 let other = "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG"; 421 422 // Common checks 423 for ty in [IncomingType::reserve, IncomingType::kyc, IncomingType::map] { 424 let prefix = match ty { 425 IncomingType::reserve => "", 426 IncomingType::kyc => "KYC", 427 IncomingType::map => "MAP", 428 }; 429 let standard = &format!("{prefix}{key}"); 430 let (standard_l, standard_r) = standard.split_at(standard.len() / 2); 431 let mixed = &format!("{prefix}4mzt6RS3rvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0"); 432 let (mixed_l, mixed_r) = mixed.split_at(mixed.len() / 2); 433 let other_standard = &format!("{prefix}{other}"); 434 let other_mixed = 435 &format!("{prefix}TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60"); 436 let key = EddsaPublicKey::from_str(key).unwrap(); 437 let result = Ok(IncomingSubject::Key(IncomingKey { ty, key })); 438 439 // Check succeed if standard or mixed 440 for case in [standard, mixed] { 441 for test in [ 442 format!("noise {case} noise"), 443 format!("{case} noise to the right"), 444 format!("noise to the left {case}"), 445 format!(" {case} "), 446 format!("noise\n{case}\nnoise"), 447 format!("Test+{case}"), 448 ] { 449 assert_eq!(parse_incoming_unstructured(&test), result); 450 } 451 } 452 453 // Check succeed if standard or mixed and split 454 for (l, r) in [(standard_l, standard_r), (mixed_l, mixed_r)] { 455 for case in [ 456 format!("left {l}{r} right"), 457 format!("left {l} {r} right"), 458 format!("left {l}-{r} right"), 459 format!("left {l}+{r} right"), 460 format!("left {l}\n{r} right"), 461 format!("left {l}-+\n{r} right"), 462 format!("left {l} - {r} right"), 463 format!("left {l} + {r} right"), 464 format!("left {l} \n {r} right"), 465 format!("left {l} - + \n {r} right"), 466 ] { 467 assert_eq!(parse_incoming_unstructured(&case), result); 468 } 469 } 470 471 // Check concat parts 472 for chunk_size in 1..standard.len() { 473 let chunked: String = standard 474 .as_bytes() 475 .chunks(chunk_size) 476 .flat_map(|c| [std::str::from_utf8(c).unwrap(), " "]) 477 .collect(); 478 for case in [chunked.clone(), format!("left {chunked} right")] { 479 assert_eq!(parse_incoming_unstructured(&case), result); 480 } 481 } 482 483 // Check failed when multiple key 484 for case in [ 485 format!("{standard} {other_standard}"), 486 format!("{mixed} {other_mixed}"), 487 ] { 488 assert_eq!( 489 parse_incoming_unstructured(&case), 490 Err(IncomingSubjectErr::Ambiguous) 491 ); 492 } 493 494 // Check accept redundant key 495 for case in [ 496 format!("{standard} {standard} {mixed} {mixed}"), // Accept redundant key 497 format!("{standard} {other_mixed}"), // Prefer high quality 498 ] { 499 assert_eq!(parse_incoming_unstructured(&case), result); 500 } 501 502 // Check prefer prefixed over simple ones 503 for case in [ 504 format!("{standard_l}-{standard_r} {mixed_l}-{mixed_r}"), 505 format!("{mixed_l}-{mixed_r} {standard_l}-{standard_r}"), 506 ] { 507 let res = parse_incoming_unstructured(&case); 508 if !(ty == IncomingType::reserve 509 && matches!(res, Err(IncomingSubjectErr::Ambiguous))) 510 { 511 assert_eq!(res, result); 512 } 513 } 514 515 // Check failure if malformed or missing 516 for case in [ 517 "does not contain any reserve", // Check fail if none 518 &standard[1..], // Check fail if missing char 519 // "2MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0", // Check fail if not a valid key TODO aws-lc does not check 520 ] { 521 assert_eq!( 522 parse_incoming_unstructured(case), 523 Err(IncomingSubjectErr::Missing) 524 ); 525 } 526 527 if ty == IncomingType::kyc || ty == IncomingType::map { 528 // Prefer prefixed over unprefixed 529 for case in [format!("{other} {standard}"), format!("{other} {mixed}")] { 530 assert_eq!(parse_incoming_unstructured(&case), result); 531 } 532 } 533 } 534 } 535 536 #[test] 537 /** Test parsing logic using real cases */ 538 fn real() { 539 // Good reserve case 540 for (subject, key) in [ 541 ( 542 "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60", 543 "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60", 544 ), 545 ( 546 "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG", 547 "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG", 548 ), 549 ( 550 "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0", 551 "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0", 552 ), 553 ( 554 "KYCVEEXTBXBEMCS5R64C24GFNQVWBN5R2F9QSQ7PN8QXAP1NG4NG", 555 "KYCVEEXTBXBEMCS5R64C24GFNQVWBN5R2F9QSQ7PN8QXAP1NG4NG", 556 ), 557 ] { 558 assert_eq!( 559 Ok(IncomingSubject::Key(IncomingKey::reserve( 560 EddsaPublicKey::from_str(key).unwrap(), 561 ))), 562 parse_incoming_unstructured(subject) 563 ) 564 } 565 // Good kyc case 566 for (subject, key) in [( 567 "KYC JW398X85FWPKKMS0EYB6TQ1799RMY5DDXTZ FPW4YC3WJ2DWSJT70", 568 "JW398X85FWPKKMS0EYB6TQ1799RMY5DDXTZFPW4YC3WJ2DWSJT70", 569 )] { 570 assert_eq!( 571 Ok(IncomingSubject::Key(IncomingKey::kyc( 572 EddsaPublicKey::from_str(key).unwrap(), 573 ))), 574 parse_incoming_unstructured(subject) 575 ) 576 } 577 } 578 579 #[test] 580 fn outgoing() { 581 let wtid = ShortHashCode::rand(); 582 583 // Without metadata 584 let subject = format!("{wtid} http://exchange.example.com/"); 585 let parsed = parse_outgoing(&subject).unwrap(); 586 assert_eq!( 587 parsed, 588 OutgoingSubject { 589 wtid, 590 exchange_base_url: url("http://exchange.example.com/"), 591 metadata: None 592 } 593 ); 594 assert_eq!( 595 subject, 596 fmt_out_subject( 597 &parsed.wtid, 598 &parsed.exchange_base_url, 599 parsed.metadata.as_deref() 600 ) 601 ); 602 603 // With metadata 604 let subject = format!("Accounting:id.4 {wtid} http://exchange.example.com/"); 605 let parsed = parse_outgoing(&subject).unwrap(); 606 assert_eq!( 607 parsed, 608 OutgoingSubject { 609 wtid, 610 exchange_base_url: url("http://exchange.example.com/"), 611 metadata: Some("Accounting:id.4".into()) 612 } 613 ); 614 assert_eq!( 615 subject, 616 fmt_out_subject( 617 &parsed.wtid, 618 &parsed.exchange_base_url, 619 parsed.metadata.as_deref() 620 ) 621 ); 622 } 623 }