taler-rust

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

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 }