taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

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 }