taler-rust

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

subject.rs (13841B)


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