taler-rust

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

subject.rs (14838B)


      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 compact_str::CompactString;
     23 use taler_common::{
     24     api_common::{EddsaPublicKey, ShortHashCode},
     25     types::base32::{Base32Error, CROCKFORD_ALPHABET},
     26     types::url,
     27 };
     28 use url::Url;
     29 
     30 use crate::db::IncomingType;
     31 
     32 #[derive(Debug, Clone, PartialEq, Eq)]
     33 pub enum IncomingSubject {
     34     Reserve(EddsaPublicKey),
     35     Kyc(EddsaPublicKey),
     36 }
     37 
     38 impl IncomingSubject {
     39     pub fn ty(&self) -> IncomingType {
     40         match self {
     41             IncomingSubject::Reserve(_) => IncomingType::reserve,
     42             IncomingSubject::Kyc(_) => IncomingType::kyc,
     43         }
     44     }
     45 
     46     pub fn key(&self) -> &[u8] {
     47         match self {
     48             IncomingSubject::Kyc(key) | IncomingSubject::Reserve(key) => key.as_ref().as_ref(),
     49         }
     50     }
     51 }
     52 
     53 #[derive(Debug, PartialEq, Eq)]
     54 pub struct OutgoingSubject {
     55     pub wtid: ShortHashCode,
     56     pub exchange_base_url: Url,
     57     pub metadata: Option<CompactString>,
     58 }
     59 
     60 impl OutgoingSubject {
     61     /// Generate a random outgoing subject for https://exchange.test.com
     62     pub fn rand() -> Self {
     63         Self {
     64             wtid: ShortHashCode::rand(),
     65             exchange_base_url: url("https://exchange.test.com"),
     66             metadata: None,
     67         }
     68     }
     69 }
     70 
     71 /** Base32 quality by proximity to spec and error probability */
     72 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
     73 enum Base32Quality {
     74     /// Both mixed casing and mixed characters, that's weird
     75     Mixed,
     76     /// Standard but use lowercase, maybe the client shown lowercase in the UI
     77     Standard,
     78     /// Uppercase but mixed characters, its common when making typos
     79     Upper,
     80     /// Both uppercase and use the standard alphabet as it should
     81     UpperStandard,
     82 }
     83 
     84 impl Base32Quality {
     85     pub fn measure(s: &str) -> Self {
     86         let mut uppercase = true;
     87         let mut standard = true;
     88         for b in s.bytes() {
     89             uppercase &= b.is_ascii_uppercase();
     90             standard &= CROCKFORD_ALPHABET.contains(&b)
     91         }
     92         match (uppercase, standard) {
     93             (true, true) => Base32Quality::UpperStandard,
     94             (true, false) => Base32Quality::Upper,
     95             (false, true) => Base32Quality::Standard,
     96             (false, false) => Base32Quality::Mixed,
     97         }
     98     }
     99 }
    100 
    101 #[derive(Debug)]
    102 pub struct Candidate {
    103     subject: IncomingSubject,
    104     quality: Base32Quality,
    105 }
    106 
    107 #[derive(Debug, PartialEq, Eq)]
    108 pub enum IncomingSubjectResult {
    109     Success(IncomingSubject),
    110     Ambiguous,
    111 }
    112 
    113 #[derive(Debug, PartialEq, Eq, thiserror::Error)]
    114 pub enum IncomingSubjectErr {
    115     #[error("found multiple public keys")]
    116     Ambiguous,
    117 }
    118 
    119 #[derive(Debug, thiserror::Error)]
    120 pub enum OutgoingSubjectErr {
    121     #[error("missing parts")]
    122     MissingParts,
    123     #[error("malformed wtid: {0}")]
    124     Wtid(#[from] Base32Error<32>),
    125     #[error("malformed exchange url: {0}")]
    126     Url(#[from] url::ParseError),
    127 }
    128 
    129 /// Parse a talerable outgoing tranfer subject
    130 pub fn parse_outgoing(subject: &str) -> Result<OutgoingSubject, OutgoingSubjectErr> {
    131     let mut parts = subject.split(' ');
    132     let first = parts.next().ok_or(OutgoingSubjectErr::MissingParts)?;
    133     let second = parts.next().ok_or(OutgoingSubjectErr::MissingParts)?;
    134     Ok(if let Some(third) = parts.next() {
    135         OutgoingSubject {
    136             wtid: second.parse()?,
    137             exchange_base_url: third.parse()?,
    138             metadata: Some(first.into()),
    139         }
    140     } else {
    141         OutgoingSubject {
    142             wtid: first.parse()?,
    143             exchange_base_url: second.parse()?,
    144             metadata: None,
    145         }
    146     })
    147 }
    148 
    149 /// Format an outgoing subject
    150 pub fn fmt_outgoing_subject(wtid: &ShortHashCode, url: &Url, metadata: Option<&str>) -> String {
    151     let mut buf = String::new();
    152     if let Some(metadata) = metadata {
    153         buf.push_str(metadata);
    154         buf.push(' ');
    155     }
    156     write!(&mut buf, "{wtid} {url}").unwrap();
    157     buf
    158 }
    159 
    160 /**
    161  * Extract the public key from an unstructured incoming transfer subject.
    162  *
    163  * When a user enters the transfer object in an unstructured way, for ex in
    164  * their banking UI, they may mistakenly enter separators such as ' \n-+' and
    165  * make typos.
    166  * To parse them while ignoring user errors, we reconstruct valid keys from key
    167  * parts, resolving ambiguities where possible.
    168  **/
    169 pub fn parse_incoming_unstructured(
    170     subject: &str,
    171 ) -> Result<Option<IncomingSubject>, IncomingSubjectErr> {
    172     // We expect subject to be less than 65KB
    173     assert!(subject.len() <= u16::MAX as usize);
    174 
    175     const KEY_SIZE: usize = 52;
    176     const KYC_SIZE: usize = KEY_SIZE + 3;
    177 
    178     /** Parse an incoming subject */
    179     #[inline]
    180     fn parse_single(str: &str) -> Option<Candidate> {
    181         // Check key type
    182         let (is_kyc, raw) = match str.len() {
    183             KEY_SIZE => (false, str),
    184             KYC_SIZE => {
    185                 if let Some(key) = str.strip_prefix("KYC") {
    186                     (true, key)
    187                 } else {
    188                     return None;
    189                 }
    190             }
    191             _ => unreachable!(),
    192         };
    193 
    194         // Check key validity
    195         let key = EddsaPublicKey::from_str(raw).ok()?;
    196 
    197         let quality = Base32Quality::measure(raw);
    198         Some(Candidate {
    199             subject: if is_kyc {
    200                 IncomingSubject::Kyc(key)
    201             } else {
    202                 IncomingSubject::Reserve(key)
    203             },
    204             quality,
    205         })
    206     }
    207 
    208     // Find and concatenate valid parts of a keys
    209     let (parts, concatenated) = {
    210         let mut parts = Vec::with_capacity(4);
    211         let mut concatenated = String::with_capacity(subject.len().min(KYC_SIZE + 10));
    212         parts.push(0u16);
    213         for part in subject.as_bytes().split(|b| !b.is_ascii_alphanumeric()) {
    214             // SAFETY: part are all valid ASCII alphanumeric
    215             concatenated.push_str(unsafe { std::str::from_utf8_unchecked(part) });
    216             parts.push(concatenated.len() as u16);
    217         }
    218         (parts, concatenated)
    219     };
    220 
    221     // Find best candidates
    222     let mut best: Option<Candidate> = None;
    223     // For each part as a starting point
    224     for (i, &start) in parts.iter().enumerate() {
    225         // Use progressively longer concatenation
    226         for &end in parts[i..].iter().skip(1) {
    227             let len = (end - start) as usize;
    228             // Until they are to long to be a key
    229             if len > KYC_SIZE {
    230                 break;
    231             } else if len != KEY_SIZE && len != KYC_SIZE {
    232                 continue;
    233             }
    234 
    235             // Parse the concatenated parts
    236             // SAFETY: we now end.end <= concatenated.len
    237             let slice = unsafe { &concatenated.get_unchecked(start as usize..end as usize) };
    238             if let Some(other) = parse_single(slice) {
    239                 // On success update best candidate
    240                 match &mut best {
    241                     Some(best) => {
    242                         if other.quality > best.quality // We prefer high quality keys
    243                                 || matches!( // We prefer prefixed keys over reserve keys
    244                                     (&best.subject.ty(), &other.subject.ty()),
    245                                     (IncomingType::reserve, IncomingType::kyc | IncomingType::wad)
    246                                 )
    247                         {
    248                             *best = other
    249                         } else if best.subject.key() != other.subject.key() // If keys are different
    250                                 && best.quality == other.quality // Of same quality
    251                                 && !matches!( // And prefixing is different
    252                                     (&best.subject.ty(), &other.subject.ty()),
    253                                     (IncomingType::kyc | IncomingType::wad, IncomingType::reserve)
    254                                 )
    255                         {
    256                             return Err(IncomingSubjectErr::Ambiguous);
    257                         }
    258                     }
    259                     None => best = Some(other),
    260                 }
    261             }
    262         }
    263     }
    264 
    265     Ok(best.map(|it| it.subject))
    266 }
    267 
    268 #[test]
    269 /** Test parsing logic */
    270 fn parse() {
    271     let key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0";
    272     let other = "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG";
    273 
    274     // Common checks
    275     for ty in [IncomingType::reserve, IncomingType::kyc] {
    276         let prefix = match ty {
    277             IncomingType::reserve => "",
    278             IncomingType::kyc => "KYC",
    279             IncomingType::wad => unreachable!(),
    280         };
    281         let standard = &format!("{prefix}{key}");
    282         let (standard_l, standard_r) = standard.split_at(standard.len() / 2);
    283         let mixed = &format!("{prefix}4mzt6RS3rvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0");
    284         let (mixed_l, mixed_r) = mixed.split_at(mixed.len() / 2);
    285         let other_standard = &format!("{prefix}{other}");
    286         let other_mixed = &format!("{prefix}TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60");
    287 
    288         let result = Ok(Some(match ty {
    289             IncomingType::reserve => {
    290                 IncomingSubject::Reserve(EddsaPublicKey::from_str(key).unwrap())
    291             }
    292             IncomingType::kyc => IncomingSubject::Kyc(EddsaPublicKey::from_str(key).unwrap()),
    293             IncomingType::wad => unreachable!(),
    294         }));
    295 
    296         // Check succeed if standard or mixed
    297         for case in [standard, mixed] {
    298             for test in [
    299                 format!("noise {case} noise"),
    300                 format!("{case} noise to the right"),
    301                 format!("noise to the left {case}"),
    302                 format!("    {case}     "),
    303                 format!("noise\n{case}\nnoise"),
    304                 format!("Test+{case}"),
    305             ] {
    306                 assert_eq!(parse_incoming_unstructured(&test), result);
    307             }
    308         }
    309 
    310         // Check succeed if standard or mixed and split
    311         for (l, r) in [(standard_l, standard_r), (mixed_l, mixed_r)] {
    312             for case in [
    313                 format!("left {l}{r} right"),
    314                 format!("left {l} {r} right"),
    315                 format!("left {l}-{r} right"),
    316                 format!("left {l}+{r} right"),
    317                 format!("left {l}\n{r} right"),
    318                 format!("left {l}-+\n{r} right"),
    319                 format!("left {l} - {r} right"),
    320                 format!("left {l} + {r} right"),
    321                 format!("left {l} \n {r} right"),
    322                 format!("left {l} - + \n {r} right"),
    323             ] {
    324                 assert_eq!(parse_incoming_unstructured(&case), result);
    325             }
    326         }
    327 
    328         // Check concat parts
    329         for chunk_size in 1..standard.len() {
    330             let chunked: String = standard
    331                 .as_bytes()
    332                 .chunks(chunk_size)
    333                 .flat_map(|c| [std::str::from_utf8(c).unwrap(), " "])
    334                 .collect();
    335             for case in [chunked.clone(), format!("left {chunked} right")] {
    336                 assert_eq!(parse_incoming_unstructured(&case), result);
    337             }
    338         }
    339 
    340         // Check failed when multiple key
    341         for case in [
    342             format!("{standard} {other_standard}"),
    343             format!("{mixed} {other_mixed}"),
    344         ] {
    345             assert_eq!(
    346                 parse_incoming_unstructured(&case),
    347                 Err(IncomingSubjectErr::Ambiguous)
    348             );
    349         }
    350 
    351         // Check accept redundant key
    352         for case in [
    353             format!("{standard} {standard} {mixed} {mixed}"), // Accept redundant key
    354             format!("{standard} {other_mixed}"),              // Prefer high quality
    355         ] {
    356             assert_eq!(parse_incoming_unstructured(&case), result);
    357         }
    358 
    359         // Check prefer prefixed over simple
    360         for case in [format!("{mixed_l}-{mixed_r} {standard_l}-{standard_r}")] {
    361             let res = parse_incoming_unstructured(&case);
    362             if ty == IncomingType::reserve {
    363                 assert_eq!(res, Err(IncomingSubjectErr::Ambiguous));
    364             } else {
    365                 assert_eq!(res, result);
    366             }
    367         }
    368 
    369         // Check failure if malformed or missing
    370         for case in [
    371             "does not contain any reserve", // Check fail if none
    372             &standard[1..],                 // Check fail if missing char
    373                                             //"2MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0", // Check fail if not a valid key
    374         ] {
    375             assert_eq!(parse_incoming_unstructured(&case), Ok(None));
    376         }
    377 
    378         if ty == IncomingType::kyc {
    379             // Prefer prefixed over unprefixed
    380             for case in [format!("{other} {standard}"), format!("{other} {mixed}")] {
    381                 assert_eq!(parse_incoming_unstructured(&case), result);
    382             }
    383         }
    384     }
    385 }
    386 
    387 #[test]
    388 /** Test parsing logic using real cases */
    389 fn real() {
    390     // Good reserve case
    391     for (subject, key) in [
    392         (
    393             "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60",
    394             "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60",
    395         ),
    396         (
    397             "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG",
    398             "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG",
    399         ),
    400         (
    401             "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0",
    402             "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0",
    403         ),
    404         (
    405             "KYCVEEXTBXBEMCS5R64C24GFNQVWBN5R2F9QSQ7PN8QXAP1NG4NG",
    406             "KYCVEEXTBXBEMCS5R64C24GFNQVWBN5R2F9QSQ7PN8QXAP1NG4NG",
    407         ),
    408     ] {
    409         assert_eq!(
    410             Ok(Some(IncomingSubject::Reserve(
    411                 EddsaPublicKey::from_str(key).unwrap(),
    412             ))),
    413             parse_incoming_unstructured(subject)
    414         )
    415     }
    416     // Good kyc case
    417     for (subject, key) in [(
    418         "KYC JW398X85FWPKKMS0EYB6TQ1799RMY5DDXTZ FPW4YC3WJ2DWSJT70",
    419         "JW398X85FWPKKMS0EYB6TQ1799RMY5DDXTZFPW4YC3WJ2DWSJT70",
    420     )] {
    421         assert_eq!(
    422             Ok(Some(IncomingSubject::Kyc(
    423                 EddsaPublicKey::from_str(key).unwrap(),
    424             ))),
    425             parse_incoming_unstructured(subject)
    426         )
    427     }
    428 }