taler-rust

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

commit 95c2f76677ca08b5ce1ce603cd2229f00ff885d1
parent 77cd5c7b937e54cb7eb4045798187a4e7943c642
Author: Antoine A <>
Date:   Mon, 30 Dec 2024 12:53:20 +0100

taler: refactor subject types and module structure

Diffstat:
MCargo.lock | 44+++++++++++++++++++++++++-------------------
MCargo.toml | 3+++
Mtaler-api/Cargo.toml | 8++++++++
Ataler-api/benches/subject.rs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtaler-api/src/lib.rs | 1+
Ataler-api/src/subject.rs | 371+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtaler-api/tests/api.rs | 6+++---
Mtaler-api/tests/common/db.rs | 6+++---
Mtaler-common/Cargo.toml | 9++-------
Dtaler-common/benches/subject.rs | 84-------------------------------------------------------------------------------
Mtaler-common/src/api_params.rs | 11++++++++++-
Mtaler-common/src/api_wire.rs | 10+++++-----
Mtaler-common/src/lib.rs | 1-
Dtaler-common/src/subject.rs | 318-------------------------------------------------------------------------------
14 files changed, 515 insertions(+), 441 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -281,9 +281,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.5" +version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31a0499c1dc64f458ad13872de75c0eb7e3fdb0e67964610c914b034fc5956e" +checksum = "8d6dbb628b8f8555f86d0323c2eb39e3ec81901f4b83e091db8a6a76d316a333" dependencies = [ "shlex", ] @@ -1236,9 +1236,12 @@ checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "jiff" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db69f08d4fb10524cacdb074c10b296299d71274ddbc830a8ee65666867002e9" +checksum = "24a46169c7a10358cdccfb179910e8a5a392fc291bdb409da9aeece5b19786d8" +dependencies = [ + "serde", +] [[package]] name = "js-sys" @@ -1665,9 +1668,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.37" +version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" dependencies = [ "proc-macro2", ] @@ -1906,9 +1909,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" +checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" [[package]] name = "ryu" @@ -1939,18 +1942,18 @@ checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba" [[package]] name = "serde" -version = "1.0.216" +version = "1.0.217" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" +checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.216" +version = "1.0.217" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" +checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" dependencies = [ "proc-macro2", "quote", @@ -1993,9 +1996,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.11.0" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" dependencies = [ "base64", "chrono", @@ -2011,9 +2014,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.11.0" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" dependencies = [ "darling", "proc-macro2", @@ -2358,9 +2361,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.91" +version = "2.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d53cbcb5a243bd33b7858b1d7f4aca2153490815872d86d955d6ea29f743c035" +checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058" dependencies = [ "proc-macro2", "quote", @@ -2389,7 +2392,10 @@ name = "taler-api" version = "0.1.0" dependencies = [ "axum", + "criterion", "dashmap", + "ed25519-dalek", + "fastrand", "http-body-util", "libdeflater", "listenfd", @@ -2398,6 +2404,7 @@ dependencies = [ "sqlx", "taler-common", "test-utils", + "thiserror 2.0.9", "tokio", "tracing", "tracing-subscriber", @@ -2410,7 +2417,6 @@ name = "taler-common" version = "0.1.0" dependencies = [ "criterion", - "ed25519-dalek", "fastrand", "jiff", "rand", diff --git a/Cargo.toml b/Cargo.toml @@ -13,3 +13,5 @@ tokio = { version = "1.42", features = ["macros"] } axum = "0.7.9" sqlx = { version = "0.8", default-features = false } url = { version = "2.2", features = ["serde"] } +criterion = { version = "0.5" } +fastrand = "2.2.0" +\ No newline at end of file diff --git a/taler-api/Cargo.toml b/taler-api/Cargo.toml @@ -17,12 +17,20 @@ sqlx = { workspace = true, features = [ ] } http-body-util = "0.1.2" libdeflater = "1.22.0" +ed25519-dalek = { version = "2.1.1", default-features = false } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal"] } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true axum.workspace = true url.workspace = true +thiserror.workspace = true taler-common = { path = "../taler-common" } [dev-dependencies] test-utils = { path = "../test-utils" } +criterion.workspace = true +fastrand.workspace = true + +[[bench]] +name = "subject" +harness = false diff --git a/taler-api/benches/subject.rs b/taler-api/benches/subject.rs @@ -0,0 +1,84 @@ +/* + This file is part of TALER + Copyright (C) 2024 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use taler_api::subject::parse_incoming_unstructured; + +fn parser(c: &mut Criterion) { + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789::: \t\t\t\t\n\n\n\n----++++"; + let mut rng = fastrand::Rng::with_seed(42); + let real_simple = [ + "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60", + "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG", + "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0", + "Taler KYC:TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60", + "KYC:00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG", + "Taler KYC:NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0", + ]; + let real_splitted = [ + "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60", + "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG", + "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0", + "Taler KYC:TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60", + "KYC: 00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG", + "Taler KIC:NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0", + ]; + let randoms: Vec<String> = (0..30) + .map(|_| { + (0..256) + .map(|_| *rng.choice(CHARS).unwrap() as char) + .collect() + }) + .collect(); + let chunks: Vec<String> = [ + "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60", + "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG", + "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0", + "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0", + ] + .iter() + .flat_map(|key| { + (1..key.len()).map(|chunk_size| { + key.as_bytes() + .chunks(chunk_size) + .flat_map(|c| [std::str::from_utf8(c).unwrap(), " "]) + .collect() + }) + }) + .collect(); + + fn bench<A: AsRef<str>>( + c: &mut Criterion, + name: &str, + cases: impl IntoIterator<Item = A> + Copy, + ) { + c.bench_function(name, |b| { + b.iter(|| { + for case in cases { + parse_incoming_unstructured(black_box(case.as_ref())); + } + }) + }); + } + + bench(c, "real_simple", real_simple); + bench(c, "real_splitted", real_splitted); + bench(c, "rng", &randoms); + bench(c, "chunks", &chunks); +} + +criterion_group!(benches, parser); +criterion_main!(benches); diff --git a/taler-api/src/lib.rs b/taler-api/src/lib.rs @@ -55,6 +55,7 @@ mod constants; pub mod db; pub mod error; pub mod notification; +pub mod subject; #[derive(Debug, Clone, Copy, Default)] #[must_use] diff --git a/taler-api/src/subject.rs b/taler-api/src/subject.rs @@ -0,0 +1,371 @@ +/* + This file is part of TALER + Copyright (C) 2024 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::{fmt::Debug, ops::Deref, str::FromStr}; + +use taler_common::{ + api_common::{EddsaPublicKey, ShortHashCode}, + types::base32::{Base32Error, CROCKFORD_ALPHABET}, +}; +use url::Url; + +use crate::db::IncomingType; + +#[derive(Debug, PartialEq, Eq)] +pub enum IncomingSubject { + Reserve(EddsaPublicKey), + Kyc(EddsaPublicKey), +} + +impl IncomingSubject { + pub fn ty(&self) -> IncomingType { + match self { + IncomingSubject::Reserve(_) => IncomingType::reserve, + IncomingSubject::Kyc(_) => IncomingType::kyc, + } + } + + pub fn key(&self) -> &[u8] { + match self { + IncomingSubject::Kyc(key) | IncomingSubject::Reserve(key) => key.deref(), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct OutgoingSubject(pub ShortHashCode, pub Url); + +/** Base32 quality by proximity to spec and error probability */ +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Base32Quality { + /// Both mixed casing and mixed characters, thats weird + Mixed, + /// Standard but use lowercase, maybe the client shown lowercase in the UI + Standard, + /// Uppercase but mixed characters, its common when making typos + Upper, + /// Both uppercase and use the standard alphabet as it should + UpperStandard, +} + +impl Base32Quality { + pub fn measure(s: &str) -> Self { + let mut uppercase = true; + let mut standard = true; + for b in s.bytes() { + uppercase &= b.is_ascii_uppercase(); + standard &= CROCKFORD_ALPHABET.contains(&b) + } + match (uppercase, standard) { + (true, true) => Base32Quality::UpperStandard, + (true, false) => Base32Quality::Upper, + (false, true) => Base32Quality::Standard, + (false, false) => Base32Quality::Mixed, + } + } +} + +#[derive(Debug)] +pub struct Candidate { + subject: IncomingSubject, + quality: Base32Quality, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum IncomingSubjectResult { + Success(IncomingSubject), + Ambiguous, +} + +#[derive(Debug, thiserror::Error)] +pub enum OutgoingSubjectErr { + #[error("missing parts")] + MissingParts, + #[error("malformed wtid: {0}")] + Wtid(#[from] Base32Error<32>), + #[error("malformed exchange url: {0}")] + Url(#[from] url::ParseError), +} + +/** + * Extract the wtid and exchange url from an outgoing transfer subject. + */ +pub fn parse_outgoing(subject: &str) -> Result<OutgoingSubject, OutgoingSubjectErr> { + let (wtid, base_url) = subject + .split_once(" ") + .ok_or(OutgoingSubjectErr::MissingParts)?; + let wtid = wtid.parse()?; + let base_url = base_url.parse()?; + Ok(OutgoingSubject(wtid, base_url)) +} + +/** + * Extract the public key from an unstructured incoming transfer subject. + * + * When a user enters the transfer object in an unstructured way, for ex in + * their banking UI, they may mistakenly enter separators such as ' \n-+' and + * make typos. + * To parse them while ignoring user errors, we reconstruct valid keys from key + * parts, resolving ambiguities where possible. + **/ +pub fn parse_incoming_unstructured(subject: &str) -> Option<IncomingSubjectResult> { + // We expect subject to be less than 65KB + assert!(subject.len() <= u16::MAX as usize); + /** Parse an incoming subject */ + fn parse_single(str: &str) -> Option<Candidate> { + // Check key type + let (is_kyc, raw) = if let Some(key) = str.strip_prefix("KYC:") { + (true, key) + } else { + (false, str) + }; + + // Check key validity + let key = EddsaPublicKey::from_str(raw).ok()?; + if ed25519_dalek::VerifyingKey::from_bytes(&key).is_err() { + return None; + } + + let quality = Base32Quality::measure(raw); + Some(Candidate { + subject: if is_kyc { + IncomingSubject::Kyc(key) + } else { + IncomingSubject::Reserve(key) + }, + quality, + }) + } + + // Find and concatenate valid parts of a keys + let (parts, concatenated) = { + let mut parts = Vec::new(); + let mut concatenated = String::new(); + let mut part = None; + for (i, b) in subject.as_bytes().iter().enumerate() { + if matches!(b, b'0'..=b':' | b'A'..=b'Z' | b'a'..=b'z') { + if part.is_none() { + part = Some(i); + } + } else if let Some(from) = part { + let start = concatenated.len() as u16; + concatenated.push_str(&subject[from..i]); + let end = concatenated.len() as u16; + parts.push(start..end); + part = None; + } + } + if let Some(from) = part { + let start = concatenated.len() as u16; + concatenated.push_str(&subject[from..]); + let end = concatenated.len() as u16; + parts.push(start..end); + } + (parts, concatenated) + }; + + // Find best candidates + let mut best: Option<Candidate> = None; + // For each part as a starting point + for (i, start) in parts.iter().enumerate() { + // Use progressively longer concatenation + for end in parts[i..].iter() { + let range = start.start..end.end; + // Until they are to long to be a key + if range.len() > 56 { + break; + } + // If the slice is the right size for a key (56B with prefix else 54B) + if range.len() == 52 || range.len() == 56 { + // Parse the concatenated parts + let slice = + unsafe { &concatenated.get_unchecked(start.start as usize..end.end as usize) }; + if let Some(other) = parse_single(slice) { + // On success update best candidate + match &mut best { + Some(best) => { + if other.quality > best.quality // We prefer high quality keys + || matches!( // We prefer prefixed keys over reserve keys + (&best.subject.ty(), &other.subject.ty()), + (IncomingType::reserve, IncomingType::kyc | IncomingType::wad) + ) + { + *best = other + } else if best.subject.key() != other.subject.key() // If keys are different + && best.quality == other.quality // Of same quality + && !matches!( // And prefixing is diferent + (&best.subject.ty(), &other.subject.ty()), + (IncomingType::kyc | IncomingType::wad, IncomingType::reserve) + ) + { + return Some(IncomingSubjectResult::Ambiguous); + } + } + None => best = Some(other), + } + } + } + } + } + + best.map(|it| IncomingSubjectResult::Success(it.subject)) +} + +#[test] +/** Test parsing logic */ +fn parse() { + let key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"; + let other = "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG"; + + // Common checkes + for ty in [IncomingType::reserve, IncomingType::kyc] { + let prefix = match ty { + IncomingType::reserve => "", + IncomingType::kyc => "KYC:", + IncomingType::wad => unreachable!(), + }; + let standard = &format!("{prefix}{key}"); + let (upper_l, upper_r) = standard.split_at(standard.len() / 2); + let mixed = &format!("{prefix}4mzt6RS3rvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0"); + let (mixed_l, mixed_r) = mixed.split_at(mixed.len() / 2); + let other_standard = &format!("{prefix}{other}"); + let other_mixed = &format!("{prefix}TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60"); + + let result = Some(IncomingSubjectResult::Success(match ty { + IncomingType::reserve => { + IncomingSubject::Reserve(EddsaPublicKey::from_str(key).unwrap()) + } + IncomingType::kyc => IncomingSubject::Kyc(EddsaPublicKey::from_str(key).unwrap()), + IncomingType::wad => unreachable!(), + })); + + // Check succeed if upper or mixed + for case in [standard, mixed] { + for test in [ + format!("noise {case} noise"), + format!("{case} noise to the right"), + format!("noise to the left {case}"), + format!(" {case} "), + format!("noise\n{case}\nnoise"), + format!("Test+{case}"), + ] { + assert_eq!(parse_incoming_unstructured(&test), result); + } + } + + // Check succeed if upper or mixed and split + for (l, r) in [(upper_l, upper_r), (mixed_l, mixed_r)] { + for case in [ + format!("left {l}{r} right"), + format!("left {l} {r} right"), + format!("left {l}-{r} right"), + format!("left {l}+{r} right"), + format!("left {l}\n{r} right"), + format!("left {l}-+\n{r} right"), + format!("left {l} - {r} right"), + format!("left {l} + {r} right"), + format!("left {l} \n {r} right"), + format!("left {l} - + \n {r} right"), + ] { + assert_eq!(parse_incoming_unstructured(&case), result); + } + } + + // Check concat parts + for chunk_size in 1..standard.len() { + let chunked: String = standard + .as_bytes() + .chunks(chunk_size) + .flat_map(|c| [std::str::from_utf8(c).unwrap(), " "]) + .collect(); + for case in [chunked.clone(), format!("left {chunked} right")] { + assert_eq!(parse_incoming_unstructured(&case), result); + } + } + + // Check failed when multiple key + for case in [ + format!("{standard} {other_standard}"), + format!("{mixed} {other_mixed}"), + ] { + assert_eq!( + parse_incoming_unstructured(&case), + Some(IncomingSubjectResult::Ambiguous) + ); + } + + // Check accept redundant key + for case in [ + format!("{standard} {standard} {mixed} {mixed}"), // Accept redundant key + format!("{standard} {other_mixed}"), // Prefer high quality + ] { + assert_eq!(parse_incoming_unstructured(&case), result); + } + + // Check prefer prefixed over simple + for case in [format!("{mixed_l}-{mixed_r} {upper_l}-{upper_r}")] { + let res = parse_incoming_unstructured(&case); + if ty == IncomingType::reserve { + assert_eq!(res, Some(IncomingSubjectResult::Ambiguous)); + } else { + assert_eq!(res, result); + } + } + + // Check failure if malformed or missing + for case in [ + "does not contain any reserve", // Check fail if none + &standard[0..standard.len() - 1], // Check fail if missing char + "2MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0", // Check fail if not a valid key + ] { + assert_eq!(parse_incoming_unstructured(&case), None); + } + + if ty == IncomingType::kyc { + // Prefer prefixed over unprefixed + for case in [format!("{other} {standard}"), format!("{other} {mixed}")] { + assert_eq!(parse_incoming_unstructured(&case), result); + } + } + } +} + +#[test] +/** Test parsing logic using real cases */ +fn real() { + // Good case + for (subject, key) in [ + ( + "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60", + "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60", + ), + ( + "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG", + "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG", + ), + ( + "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0", + "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0", + ), + ] { + assert_eq!( + Some(IncomingSubjectResult::Success(IncomingSubject::Reserve( + EddsaPublicKey::from_str(key).unwrap(), + ))), + parse_incoming_unstructured(subject) + ) + } +} diff --git a/taler-api/tests/api.rs b/taler-api/tests/api.rs @@ -249,9 +249,9 @@ async fn incoming_history(pool: PgPool) { it.incoming_transactions .into_iter() .map(|it| match it { - IncomingBankTransaction::IncomingReserveTransaction { row_id, .. } - | IncomingBankTransaction::IncomingWadTransaction { row_id, .. } - | IncomingBankTransaction::IncomingKycAuthTransaction { row_id, .. } => { + IncomingBankTransaction::Reserve { row_id, .. } + | IncomingBankTransaction::Wad { row_id, .. } + | IncomingBankTransaction::Kyc { row_id, .. } => { *row_id as i64 } }) diff --git a/taler-api/tests/common/db.rs b/taler-api/tests/common/db.rs @@ -282,21 +282,21 @@ pub async fn incoming_page( |r: PgRow| { let kind: IncomingType = r.try_get("type")?; Ok(match kind { - IncomingType::reserve => IncomingBankTransaction::IncomingReserveTransaction { + IncomingType::reserve => IncomingBankTransaction::Reserve { row_id: r.try_get_safeu64("incoming_transaction_id")?, date: r.try_get_timestamp("creation_time")?, amount: r.try_get_amount("amount", currency)?, debit_account: r.try_get_payto("debit_payto")?, reserve_pub: r.try_get_base32("reserve_pub")?, }, - IncomingType::kyc => IncomingBankTransaction::IncomingKycAuthTransaction { + IncomingType::kyc => IncomingBankTransaction::Kyc { row_id: r.try_get_safeu64("incoming_transaction_id")?, date: r.try_get_timestamp("creation_time")?, amount: r.try_get_amount("amount", currency)?, debit_account: r.try_get_payto("debit_payto")?, account_pub: r.try_get_base32("account_pub")?, }, - IncomingType::wad => IncomingBankTransaction::IncomingWadTransaction { + IncomingType::wad => IncomingBankTransaction::Wad { row_id: r.try_get_safeu64("incoming_transaction_id")?, date: r.try_get_timestamp("creation_time")?, amount: r.try_get_amount("amount", currency)?, diff --git a/taler-common/Cargo.toml b/taler-common/Cargo.toml @@ -6,22 +6,17 @@ edition = "2021" [dependencies] serde_with = "3.11.0" rand = "0.8" -fastrand = "2.2.0" serde_urlencoded = "0.7" jiff = { version = "0.1", default-features = false, features = ["std"] } -ed25519-dalek = { version = "2.1.1", default-features = false } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["raw_value"] } url.workspace = true thiserror.workspace = true +fastrand.workspace = true sqlx = { workspace = true, features = ["macros"] } [dev-dependencies] -criterion = { version = "0.5" } - -[[bench]] -name = "subject" -harness = false +criterion.workspace = true [[bench]] name = "base32" diff --git a/taler-common/benches/subject.rs b/taler-common/benches/subject.rs @@ -1,84 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2024 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use taler_common::subject::parse_incoming_unstructured; - -fn parser(c: &mut Criterion) { - const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789::: \t\t\t\t\n\n\n\n----++++"; - let mut rng = fastrand::Rng::with_seed(42); - let real_simple = [ - "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60", - "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG", - "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0", - "Taler KYC:TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60", - "KYC:00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG", - "Taler KYC:NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0", - ]; - let real_splitted = [ - "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60", - "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG", - "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0", - "Taler KYC:TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60", - "KYC: 00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG", - "Taler KIC:NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0", - ]; - let randoms: Vec<String> = (0..30) - .map(|_| { - (0..256) - .map(|_| *rng.choice(CHARS).unwrap() as char) - .collect() - }) - .collect(); - let chunks: Vec<String> = [ - "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60", - "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG", - "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0", - "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0", - ] - .iter() - .flat_map(|key| { - (1..key.len()).map(|chunk_size| { - key.as_bytes() - .chunks(chunk_size) - .flat_map(|c| [std::str::from_utf8(c).unwrap(), " "]) - .collect() - }) - }) - .collect(); - - fn bench<A: AsRef<str>>( - c: &mut Criterion, - name: &str, - cases: impl IntoIterator<Item = A> + Copy, - ) { - c.bench_function(name, |b| { - b.iter(|| { - for case in cases { - parse_incoming_unstructured(black_box(case.as_ref())); - } - }) - }); - } - - bench(c, "real_simple", real_simple); - bench(c, "real_splitted", real_splitted); - bench(c, "rng", &randoms); - bench(c, "chunks", &chunks); -} - -criterion_group!(benches, parser); -criterion_main!(benches); diff --git a/taler-common/src/api_params.rs b/taler-common/src/api_params.rs @@ -68,6 +68,15 @@ pub struct Page { pub offset: Option<i64>, } +impl Default for Page { + fn default() -> Self { + Self { + limit: 20, + offset: None, + } + } +} + impl Page { pub fn backward(&self) -> bool { self.limit < 0 @@ -93,7 +102,7 @@ impl HistoryParams { } } -#[derive(Debug)] +#[derive(Debug, Default)] pub struct History { pub page: Page, pub timeout_ms: Option<u64>, diff --git a/taler-common/src/api_wire.rs b/taler-common/src/api_wire.rs @@ -85,7 +85,7 @@ pub struct OutgoingHistory { pub debit_account: Payto, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] /// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-OutgoingBankTransaction> pub struct OutgoingBankTransaction { pub row_id: SafeU64, @@ -103,12 +103,12 @@ pub struct IncomingHistory { pub incoming_transactions: Vec<IncomingBankTransaction>, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "type")] /// <https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingBankTransaction> pub enum IncomingBankTransaction { #[serde(rename = "RESERVE")] - IncomingReserveTransaction { + Reserve { row_id: SafeU64, date: Timestamp, amount: Amount, @@ -116,7 +116,7 @@ pub enum IncomingBankTransaction { reserve_pub: EddsaPublicKey, }, #[serde(rename = "WAD")] - IncomingWadTransaction { + Wad { row_id: SafeU64, date: Timestamp, amount: Amount, @@ -125,7 +125,7 @@ pub enum IncomingBankTransaction { wad_id: WadId, }, #[serde(rename = "KYCAUTH")] - IncomingKycAuthTransaction { + Kyc { row_id: SafeU64, date: Timestamp, amount: Amount, diff --git a/taler-common/src/lib.rs b/taler-common/src/lib.rs @@ -18,7 +18,6 @@ pub mod api_common; pub mod api_params; pub mod api_wire; pub mod error_code; -pub mod subject; pub mod types; pub mod config { // TODO diff --git a/taler-common/src/subject.rs b/taler-common/src/subject.rs @@ -1,318 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2024 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use std::{fmt::Debug, str::FromStr}; - -use crate::{api_common::EddsaPublicKey, types::base32::CROCKFORD_ALPHABET}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum IncomingType { - Reserve, - Kyc, - Wad, -} - -#[derive(Debug, PartialEq, Eq)] -pub struct IncomingSubject(IncomingType, EddsaPublicKey); - -/** Base32 quality by proximity to spec and error probability */ -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum Base32Quality { - /// Both mixed casing and mixed characters, thats weird - Mixed, - /// Standard but use lowercase, maybe the client shown lowercase in the UI - Standard, - /// Uppercase but mixed characters, its common when making typos - Upper, - /// Both uppercase and use the standard alphabet as it should - UpperStandard, -} - -impl Base32Quality { - pub fn measure(s: &str) -> Self { - let mut uppercase = true; - let mut standard = true; - for b in s.bytes() { - uppercase &= b.is_ascii_uppercase(); - standard &= CROCKFORD_ALPHABET.contains(&b) - } - match (uppercase, standard) { - (true, true) => Base32Quality::UpperStandard, - (true, false) => Base32Quality::Upper, - (false, true) => Base32Quality::Standard, - (false, false) => Base32Quality::Mixed, - } - } -} - -#[derive(Debug)] -pub struct Candidate { - subject: IncomingSubject, - quality: Base32Quality, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum IncomingSubjectResult { - Success(IncomingSubject), - Ambiguous, -} - -/** - * Extract the public key from an unstructured incoming transfer subject. - * - * When a user enters the transfer object in an unstructured way, for ex in - * their banking UI, they may mistakenly enter separators such as ' \n-+' and - * make typos. - * To parse them while ignoring user errors, we reconstruct valid keys from key - * parts, resolving ambiguities where possible. - **/ -pub fn parse_incoming_unstructured(subject: &str) -> Option<IncomingSubjectResult> { - // We expect subject to be less than 65KB - assert!(subject.len() <= u16::MAX as usize); - /** Parse an incoming subject */ - fn parse_single(str: &str) -> Option<Candidate> { - // Check key type - let (kind, raw) = if let Some(key) = str.strip_prefix("KYC:") { - (IncomingType::Kyc, key) - } else { - (IncomingType::Reserve, str) - }; - - // Check key validity - let key = EddsaPublicKey::from_str(raw).ok()?; - if ed25519_dalek::VerifyingKey::from_bytes(&key).is_err() { - return None; - } - - let quality = Base32Quality::measure(raw); - Some(Candidate { - subject: IncomingSubject(kind, key), - quality, - }) - } - - // Find and concatenate valid parts of a keys - let (parts, concatenated) = { - let mut parts = Vec::new(); - let mut concatenated = String::new(); - let mut part = None; - for (i, b) in subject.as_bytes().iter().enumerate() { - if matches!(b, b'0'..=b':' | b'A'..=b'Z' | b'a'..=b'z') { - if part.is_none() { - part = Some(i); - } - } else if let Some(from) = part { - let start = concatenated.len() as u16; - concatenated.push_str(&subject[from..i]); - let end = concatenated.len() as u16; - parts.push(start..end); - part = None; - } - } - if let Some(from) = part { - let start = concatenated.len() as u16; - concatenated.push_str(&subject[from..]); - let end = concatenated.len() as u16; - parts.push(start..end); - } - (parts, concatenated) - }; - - // Find best candidates - let mut best: Option<Candidate> = None; - // For each part as a starting point - for (i, start) in parts.iter().enumerate() { - // Use progressively longer concatenation - for end in parts[i..].iter() { - let range = start.start..end.end; - // Until they are to long to be a key - if range.len() > 56 { - break; - } - // If the slice is the right size for a key (56B with prefix else 54B) - if range.len() == 52 || range.len() == 56 { - // Parse the concatenated parts - let slice = - unsafe { &concatenated.get_unchecked(start.start as usize..end.end as usize) }; - if let Some(other) = parse_single(slice) { - // On success update best candidate - match &mut best { - Some(best) => { - if other.quality > best.quality // We prefer high quality keys - || matches!( // We prefer prefixed keys over reserve keys - (&best.subject.0, &other.subject.0), - (IncomingType::Reserve, IncomingType::Kyc | IncomingType::Wad) - ) - { - *best = other - } else if best.subject.1 != other.subject.1 // If keys are different - && best.quality == other.quality // Of same quality - && !matches!( // And prefixing is diferent - (&best.subject.0, &other.subject.0), - (IncomingType::Kyc | IncomingType::Wad, IncomingType::Reserve) - ) - { - return Some(IncomingSubjectResult::Ambiguous); - } - } - None => best = Some(other), - } - } - } - } - } - - best.map(|it| IncomingSubjectResult::Success(it.subject)) -} - -#[test] -/** Test parsing logic */ -fn parse() { - let key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"; - let other = "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG"; - - // Common checkes - for (ty, prefix) in [(IncomingType::Reserve, ""), (IncomingType::Kyc, "KYC:")] { - let standard = &format!("{prefix}{key}"); - let (upper_l, upper_r) = standard.split_at(standard.len() / 2); - let mixed = &format!("{prefix}4mzt6RS3rvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0"); - let (mixed_l, mixed_r) = mixed.split_at(mixed.len() / 2); - let other_standard = &format!("{prefix}{other}"); - let other_mixed = &format!("{prefix}TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60"); - - let result = Some(IncomingSubjectResult::Success(IncomingSubject( - ty, - EddsaPublicKey::from_str(key).unwrap(), - ))); - - // Check succeed if upper or mixed - for case in [standard, mixed] { - for test in [ - format!("noise {case} noise"), - format!("{case} noise to the right"), - format!("noise to the left {case}"), - format!(" {case} "), - format!("noise\n{case}\nnoise"), - format!("Test+{case}"), - ] { - assert_eq!(parse_incoming_unstructured(&test), result); - } - } - - // Check succeed if upper or mixed and split - for (l, r) in [(upper_l, upper_r), (mixed_l, mixed_r)] { - for case in [ - format!("left {l}{r} right"), - format!("left {l} {r} right"), - format!("left {l}-{r} right"), - format!("left {l}+{r} right"), - format!("left {l}\n{r} right"), - format!("left {l}-+\n{r} right"), - format!("left {l} - {r} right"), - format!("left {l} + {r} right"), - format!("left {l} \n {r} right"), - format!("left {l} - + \n {r} right"), - ] { - assert_eq!(parse_incoming_unstructured(&case), result); - } - } - - // Check concat parts - for chunk_size in 1..standard.len() { - let chunked: String = standard - .as_bytes() - .chunks(chunk_size) - .flat_map(|c| [std::str::from_utf8(c).unwrap(), " "]) - .collect(); - for case in [chunked.clone(), format!("left {chunked} right")] { - assert_eq!(parse_incoming_unstructured(&case), result); - } - } - - // Check failed when multiple key - for case in [ - format!("{standard} {other_standard}"), - format!("{mixed} {other_mixed}"), - ] { - assert_eq!( - parse_incoming_unstructured(&case), - Some(IncomingSubjectResult::Ambiguous) - ); - } - - // Check accept redundant key - for case in [ - format!("{standard} {standard} {mixed} {mixed}"), // Accept redundant key - format!("{standard} {other_mixed}"), // Prefer high quality - ] { - assert_eq!(parse_incoming_unstructured(&case), result); - } - - // Check prefer prefixed over simple - for case in [format!("{mixed_l}-{mixed_r} {upper_l}-{upper_r}")] { - let res = parse_incoming_unstructured(&case); - if ty == IncomingType::Reserve { - assert_eq!(res, Some(IncomingSubjectResult::Ambiguous)); - } else { - assert_eq!(res, result); - } - } - - // Check failure if malformed or missing - for case in [ - "does not contain any reserve", // Check fail if none - &standard[0..standard.len() - 1], // Check fail if missing char - "2MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0", // Check fail if not a valid key - ] { - assert_eq!(parse_incoming_unstructured(&case), None); - } - - if ty == IncomingType::Kyc { - // Prefer prefixed over unprefixed - for case in [format!("{other} {standard}"), format!("{other} {mixed}")] { - assert_eq!(parse_incoming_unstructured(&case), result); - } - } - } -} - -#[test] -/** Test parsing logic using real cases */ -fn real() { - // Good case - for (subject, key) in [ - ( - "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60", - "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60", - ), - ( - "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG", - "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG", - ), - ( - "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0", - "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0", - ), - ] { - assert_eq!( - Some(IncomingSubjectResult::Success(IncomingSubject( - IncomingType::Reserve, - EddsaPublicKey::from_str(key).unwrap(), - ))), - parse_incoming_unstructured(subject) - ) - } -}