taler-rust

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

commit aeb633aa5bfae3373fc8dba312840d94aee3eeaf
parent 7a64f76dc2b923b730b5d38c24c00901538a7653
Author: Antoine A <>
Date:   Thu, 13 Feb 2025 17:00:24 +0100

magnet-bank: setup improvements and fixes

Diffstat:
Mcommon/taler-common/src/config.rs | 1-
Mcommon/taler-common/src/lib.rs | 2+-
Mtaler-magnet-bank/src/config.rs | 22++++++++++++++++++++--
Mtaler-magnet-bank/src/dev.rs | 21+++++++++++----------
Dtaler-magnet-bank/src/keys.rs | 147-------------------------------------------------------------------------------
Mtaler-magnet-bank/src/lib.rs | 2+-
Mtaler-magnet-bank/src/magnet.rs | 4++--
Mtaler-magnet-bank/src/magnet/error.rs | 15++++++++-------
Mtaler-magnet-bank/src/main.rs | 29+++++++----------------------
Ataler-magnet-bank/src/setup.rs | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 229 insertions(+), 193 deletions(-)

diff --git a/common/taler-common/src/config.rs b/common/taler-common/src/config.rs @@ -400,7 +400,6 @@ pub mod parser { let bin_path = path_dir.join(self.exec_name); if bin_path.exists() { if let Some(parent) = path_dir.parent() { - dbg!(parent); return parent.canonicalize().map_err(|e| (parent.to_path_buf(), e)); } } diff --git a/common/taler-common/src/lib.rs b/common/taler-common/src/lib.rs @@ -27,11 +27,11 @@ pub mod api_revenue; pub mod api_wire; pub mod cli; pub mod config; +pub mod db; pub mod error_code; pub mod json_file; mod log; pub mod types; -pub mod db; #[derive(clap::Parser, Debug, Clone)] pub struct CommonArgs { diff --git a/taler-magnet-bank/src/config.rs b/taler-magnet-bank/src/config.rs @@ -20,9 +20,12 @@ use base64::{prelude::BASE64_STANDARD, Engine}; use reqwest::Url; use sqlx::postgres::PgConnectOptions; use taler_api::{auth::AuthMethod, Serve}; -use taler_common::config::{map_config, Config, Section, ValueErr}; +use taler_common::{ + config::{map_config, Config, Section, ValueErr}, + types::payto::PaytoURI, +}; -use crate::magnet::Token; +use crate::{magnet::Token, FullHuPayto, HuIban}; pub struct DbCfg { pub cfg: PgConnectOptions, @@ -39,6 +42,14 @@ impl DbCfg { } } +pub fn parse_account_payto(cfg: &Config) -> Result<FullHuPayto, ValueErr> { + let sect = cfg.section("magnet-bank"); + let iban: HuIban = sect.parse("iban", "IBAN").require()?; + let name = sect.str("NAME").require()?; + + Ok(FullHuPayto::new(iban, name)) +} + pub struct ApiCfg { pub auth: AuthMethod, } @@ -70,6 +81,7 @@ impl ApiCfg { } pub struct ServeCfg { + pub payto: PaytoURI, pub serve: Serve, pub wire_gateway: Option<ApiCfg>, pub revenue: Option<ApiCfg>, @@ -77,6 +89,8 @@ pub struct ServeCfg { impl ServeCfg { pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let payto = parse_account_payto(cfg)?; + let sect = cfg.section("magnet-bank-httpd"); let serve = map_config!(sect, "serve", "SERVE", @@ -97,6 +111,7 @@ impl ServeCfg { let revenue = ApiCfg::parse(cfg.section("magnet-bank-httpd-revenue-api"))?; Ok(Self { + payto: payto.as_payto(), serve, wire_gateway, revenue, @@ -105,6 +120,7 @@ impl ServeCfg { } pub struct WorkerCfg { + pub payto: FullHuPayto, pub api_url: Url, pub consumer: Token, pub keys_path: String, @@ -112,8 +128,10 @@ pub struct WorkerCfg { impl WorkerCfg { pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let payto = parse_account_payto(cfg)?; let sect = cfg.section("magnet-bank-worker"); Ok(Self { + payto, api_url: sect.parse("URL", "API_URL").require()?, consumer: Token { key: sect.str("CONSUMER_KEY").require()?, diff --git a/taler-magnet-bank/src/dev.rs b/taler-magnet-bank/src/dev.rs @@ -19,16 +19,16 @@ use clap::ValueEnum; use jiff::Zoned; use taler_common::{ config::Config, - types::{amount::Amount, iban::IBAN, payto::PaytoImpl}, + types::{amount::Amount, payto::PaytoImpl}, }; use tracing::info; use crate::{ config::WorkerCfg, - keys, magnet::{AuthClient, Direction}, + setup, worker::{extract_tx_info, Tx}, - FullHuPayto, HuPayto, TransferHuPayto, + HuPayto, TransferHuPayto, }; #[derive(Debug, Clone, PartialEq, Eq, ValueEnum)] @@ -51,9 +51,9 @@ pub enum DevCmd { }, Transfer { #[clap(long)] - debtor: TransferHuPayto, + debtor: HuPayto, #[clap(long)] - creditor: FullHuPayto, + creditor: TransferHuPayto, #[clap(long)] amount: Option<Amount>, #[clap(long)] @@ -63,7 +63,7 @@ pub enum DevCmd { pub async fn dev(cfg: Config, cmd: DevCmd) -> anyhow::Result<()> { let cfg = WorkerCfg::parse(&cfg)?; - let keys = keys::load(&cfg)?; + let keys = setup::load(&cfg)?; let client = reqwest::Client::new(); let client = AuthClient::new(&client, &cfg.api_url, &cfg.consumer).upgrade(&keys.access_token); match cmd { @@ -71,8 +71,7 @@ pub async fn dev(cfg: Config, cmd: DevCmd) -> anyhow::Result<()> { let res = client.list_accounts().await?; for partner in res.partners { for account in partner.bank_accounts { - let iban: IBAN = account.iban.parse()?; - let payto = iban.as_full_payto(&partner.partner.name); + let payto = account.iban.as_full_payto(&partner.partner.name); info!("{} {} {}", account.code, account.currency.symbol, payto); } } @@ -109,12 +108,14 @@ pub async fn dev(cfg: Config, cmd: DevCmd) -> anyhow::Result<()> { subject, } => { let account = client.account(debtor.bban()).await?; - let amount = debtor + let amount = creditor .amount + .clone() .or(amount) .ok_or_else(|| anyhow!("Missing amount"))?; - let subject = debtor + let subject = creditor .subject + .clone() .or(subject) .ok_or_else(|| anyhow!("Missing subject"))?; diff --git a/taler-magnet-bank/src/keys.rs b/taler-magnet-bank/src/keys.rs @@ -1,147 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2025 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::io::ErrorKind; - -use p256::ecdsa::SigningKey; -use taler_common::{json_file, types::base32::Base32}; -use tracing::info; - -use crate::{ - config::WorkerCfg, - magnet::{ - error::{ApiError, MagnetError}, - AuthClient, Token, TokenAuth, - }, -}; - -#[derive(Default, Debug, serde::Deserialize, serde::Serialize)] -struct KeysFile { - access_token: Option<Token>, - signing_key: Option<Base32<32>>, -} - -#[derive(Debug)] -pub struct Keys { - pub access_token: Token, - pub signing_key: SigningKey, -} - -pub fn load(cfg: &WorkerCfg) -> anyhow::Result<Keys> { - // Load JSON file - let file: KeysFile = match json_file::load(&cfg.keys_path) { - Ok(file) => file, - Err(e) => return Err(anyhow::anyhow!("Could not magnet keys: {e}")), - }; - - fn incomplete_err() -> anyhow::Error { - anyhow::anyhow!("Missing magnet keys, run 'taler-magnet-bank setup' first") - } - - // Check full - let access_token = file.access_token.ok_or_else(incomplete_err)?; - let signing_key = file.signing_key.ok_or_else(incomplete_err)?; - - // Load signing key - - let signing_key = SigningKey::from_slice(&*signing_key)?; - - Ok(Keys { - access_token, - signing_key, - }) -} - -pub async fn setup(cfg: WorkerCfg, reset: bool) -> anyhow::Result<()> { - if reset { - if let Err(e) = std::fs::remove_file(&cfg.keys_path) { - if e.kind() != ErrorKind::NotFound { - Err(e)?; - } - } - } - let mut keys = match json_file::load(&cfg.keys_path) { - Ok(existing) => existing, - Err(e) if e.kind() == ErrorKind::NotFound => KeysFile::default(), - Err(e) => Err(e)?, - }; - let client = reqwest::Client::new(); - let client = AuthClient::new(&client, &cfg.api_url, &cfg.consumer); - - info!("Setup OAuth access token"); - if keys.access_token.is_none() { - let token_request = client.token_request().await?; - - // TODO how to do it in a generic way ? - // TODO Ask MagnetBank if they could support out-of-band configuration - println!( - "Login at {}?oauth_token={}", - client.join("/NetBankOAuth/authtoken.xhtml"), - token_request.key - ); - let auth_url = passterm::prompt_password_tty(Some("Enter the result URL>"))?; - let auth_url = reqwest::Url::parse(&auth_url)?; - let token_auth: TokenAuth = - serde_urlencoded::from_str(auth_url.query().unwrap_or_default())?; - assert_eq!(token_request.key, token_auth.oauth_token); - - let access_token = client.token_access(&token_request, &token_auth).await?; - keys.access_token = Some(access_token); - json_file::persist(&cfg.keys_path, &keys)?; - } - - let client = client.upgrade(keys.access_token.as_ref().unwrap()); - - info!("Setup Strong Customer Authentication"); - // TODO find a proper way to check if SCA is required without triggering SCA.GLOBAL_FEATURE_NOT_ENABLED - let request = client.request_sms_code().await?; - println!( - "A SCA code have been sent through {} to {}", - request.channel, - request.sent_to.join(", ") - ); - let sca_code = passterm::prompt_password_tty(Some("Enter the code>"))?; - if let Err(e) = client.perform_sca(&sca_code).await { - // Ignore error if SCA already performed - if !matches!(e, ApiError::Magnet(MagnetError { ref short_message, .. }) if short_message == "TOKEN_SCA_HITELESITETT") - { - return Err(e.into()); - } - } - - info!("Setup public key"); - // TODO find a proper way to check if a public key have been setup - // TODO use the better from/to_array API in the next version of the crypto lib - let signing_key = match keys.signing_key { - Some(bytes) => SigningKey::from_slice(bytes.as_ref())?, - None => { - let rand = SigningKey::random(&mut rand_core::OsRng); - let array: [u8; 32] = rand.to_bytes().as_slice().try_into().unwrap(); - keys.signing_key = Some(Base32::from(array)); - json_file::persist(&cfg.keys_path, &keys)?; - rand - } - }; - if let Err(e) = client.upload_public_key(&signing_key).await { - // Ignore error if public key already uploaded - if !matches!(e, ApiError::Magnet(MagnetError { ref short_message, .. }) if short_message == "KULCS_MAR_HASZNALATBAN") - { - return Err(e.into()); - } - } - - Ok(()) -} diff --git a/taler-magnet-bank/src/lib.rs b/taler-magnet-bank/src/lib.rs @@ -26,8 +26,8 @@ pub mod config; pub mod constant; pub mod db; pub mod dev; -pub mod keys; pub mod magnet; +pub mod setup; pub mod worker; pub mod failure_injection { pub fn fail_point(_name: &'static str) { diff --git a/taler-magnet-bank/src/magnet.rs b/taler-magnet-bank/src/magnet.rs @@ -24,7 +24,7 @@ use p256::{ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use spki::EncodePublicKey; -use taler_common::types::amount; +use taler_common::types::{amount, iban::IBAN}; use crate::magnet::{error::MagnetBuilder, oauth::OAuthBuilder}; @@ -123,7 +123,7 @@ pub struct Account { #[serde(rename = "deviza")] pub currency: Currency, #[serde(rename = "ibanSzamlaszam")] - pub iban: String, + pub iban: IBAN, #[serde(rename = "kod")] pub code: u64, #[serde(rename = "szamlaszam")] diff --git a/taler-magnet-bank/src/magnet/error.rs b/taler-magnet-bank/src/magnet/error.rs @@ -21,7 +21,7 @@ use tracing::{error, Level}; #[derive(Deserialize, Debug)] struct Header { - #[serde(alias = "errorCode")] + #[serde(rename = "errorCode")] pub error_code: Option<u16>, } @@ -31,11 +31,11 @@ struct Empty {} #[derive(Deserialize, Error, Debug)] #[error("{error_code} {short_message} '{long_message}'")] pub struct MagnetError { - #[serde(alias = "errorCode")] + #[serde(rename = "errorCode")] pub error_code: u16, - #[serde(alias = "shortMessage")] + #[serde(rename = "shortMessage")] pub short_message: String, - #[serde(alias = "longMessage")] + #[serde(rename = "longMessage")] pub long_message: String, } @@ -129,9 +129,10 @@ fn parse<'de, T: Deserialize<'de>>(str: &'de str) -> ApiResult<T> { async fn magnet_json<T: DeserializeOwned>(res: reqwest::Result<Response>) -> ApiResult<T> { let body = error_handling(res).await?; let header: Header = parse(&body)?; - match header.error_code { - Some(_) => Err(ApiError::Magnet(parse(&body)?)), - None => parse(&body), + if header.error_code.unwrap_or(200) == 200 { + parse(&body) + } else { + Err(ApiError::Magnet(parse(&body)?)) } } diff --git a/taler-magnet-bank/src/main.rs b/taler-magnet-bank/src/main.rs @@ -22,18 +22,15 @@ use taler_common::{ cli::ConfigCmd, config::{parser::ConfigSource, Config}, db::{dbinit, pool}, - taler_main, - types::payto::{payto, PaytoURI}, - CommonArgs, + taler_main, CommonArgs, }; use taler_magnet_bank::{ adapter::MagnetApi, config::{DbCfg, ServeCfg, WorkerCfg}, dev::{self, DevCmd}, - keys, magnet::AuthClient, + setup, worker::Worker, - HuPayto, }; pub fn long_version() -> &'static str { @@ -76,8 +73,6 @@ enum Command { /// Execute once and return #[clap(long, short)] transient: bool, - // TODO account in config - account: PaytoURI, }, /// Run taler-magnet-bank HTTP server Serve { @@ -97,7 +92,7 @@ async fn app(args: Args, cfg: Config) -> anyhow::Result<()> { match args.cmd { Command::Setup { reset } => { let cfg = WorkerCfg::parse(&cfg)?; - keys::setup(cfg, reset).await? + setup::setup(cfg, reset).await? } Command::Dbinit { reset } => { let cfg = DbCfg::parse(&cfg)?; @@ -115,13 +110,7 @@ async fn app(args: Args, cfg: Config) -> anyhow::Result<()> { let db = DbCfg::parse(&cfg)?; let pool = pool(db.cfg, "magnet_bank").await?; let cfg = ServeCfg::parse(&cfg)?; - let api = Arc::new( - MagnetApi::start( - pool, - payto("payto://iban/HU02162000031000164800000000?receiver-name=name"), - ) - .await, - ); + let api = Arc::new(MagnetApi::start(pool, cfg.payto).await); let mut builder = TalerApiBuilder::new(); if let Some(cfg) = cfg.wire_gateway { builder = builder.wire_gateway(api.clone(), cfg.auth); @@ -132,19 +121,15 @@ async fn app(args: Args, cfg: Config) -> anyhow::Result<()> { builder.serve(cfg.serve, None).await?; } } - Command::Worker { - account, - transient: _, - } => { + Command::Worker { transient: _ } => { let db = DbCfg::parse(&cfg)?; let pool = pool(db.cfg, "magnet_bank").await?; let cfg = WorkerCfg::parse(&cfg)?; - let keys = keys::load(&cfg)?; + let keys = setup::load(&cfg)?; let client = reqwest::Client::new(); let client = AuthClient::new(&client, &cfg.api_url, &cfg.consumer).upgrade(&keys.access_token); - let account = HuPayto::try_from(&account)?; - let account = client.account(account.bban()).await?; + let account = client.account(cfg.payto.bban()).await?; let mut db = pool.acquire().await?.detach(); // TODO run in loop and handle errors let mut worker = Worker { diff --git a/taler-magnet-bank/src/setup.rs b/taler-magnet-bank/src/setup.rs @@ -0,0 +1,179 @@ +/* + This file is part of TALER + Copyright (C) 2025 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::io::ErrorKind; + +use p256::ecdsa::SigningKey; +use taler_common::{json_file, types::base32::Base32}; +use tracing::{info, warn}; + +use crate::{ + config::WorkerCfg, + magnet::{ + error::{ApiError, MagnetError}, + AuthClient, Token, TokenAuth, + }, +}; + +#[derive(Default, Debug, serde::Deserialize, serde::Serialize)] +struct KeysFile { + access_token: Option<Token>, + signing_key: Option<Base32<32>>, +} + +#[derive(Debug)] +pub struct Keys { + pub access_token: Token, + pub signing_key: SigningKey, +} + +pub fn load(cfg: &WorkerCfg) -> anyhow::Result<Keys> { + // Load JSON file + let file: KeysFile = match json_file::load(&cfg.keys_path) { + Ok(file) => file, + Err(e) => { + return Err(anyhow::anyhow!( + "Could not read magnet keys at '{}': {}", + cfg.keys_path, + e.kind() + )) + } + }; + + fn incomplete_err() -> anyhow::Error { + anyhow::anyhow!("Missing magnet keys, run 'taler-magnet-bank setup' first") + } + + // Check full + let access_token = file.access_token.ok_or_else(incomplete_err)?; + let signing_key = file.signing_key.ok_or_else(incomplete_err)?; + + // Load signing key + + let signing_key = SigningKey::from_slice(&*signing_key)?; + + Ok(Keys { + access_token, + signing_key, + }) +} + +pub async fn setup(cfg: WorkerCfg, reset: bool) -> anyhow::Result<()> { + if reset { + if let Err(e) = std::fs::remove_file(&cfg.keys_path) { + if e.kind() != ErrorKind::NotFound { + Err(e)?; + } + } + } + let mut keys = match json_file::load(&cfg.keys_path) { + Ok(existing) => existing, + Err(e) if e.kind() == ErrorKind::NotFound => KeysFile::default(), + Err(e) => Err(e)?, + }; + let client = reqwest::Client::new(); + let client = AuthClient::new(&client, &cfg.api_url, &cfg.consumer); + + info!("Setup OAuth access token"); + if keys.access_token.is_none() { + let token_request = client.token_request().await?; + + // TODO how to do it in a generic way ? + // TODO Ask MagnetBank if they could support out-of-band configuration + println!( + "Login at {}?oauth_token={}", + client.join("/NetBankOAuth/authtoken.xhtml"), + token_request.key + ); + let auth_url = passterm::prompt_password_tty(Some("Enter the result URL>"))?; + let auth_url = reqwest::Url::parse(&auth_url)?; + let token_auth: TokenAuth = + serde_urlencoded::from_str(auth_url.query().unwrap_or_default())?; + assert_eq!(token_request.key, token_auth.oauth_token); + + let access_token = client.token_access(&token_request, &token_auth).await?; + keys.access_token = Some(access_token); + json_file::persist(&cfg.keys_path, &keys)?; + } + + let client = client.upgrade(keys.access_token.as_ref().unwrap()); + + info!("Setup Strong Customer Authentication"); + // TODO find a proper way to check if SCA is required without triggering SCA.GLOBAL_FEATURE_NOT_ENABLED + let request = client.request_sms_code().await?; + println!( + "A SCA code have been sent through {} to {}", + request.channel, + request.sent_to.join(", ") + ); + let sca_code = passterm::prompt_password_tty(Some("Enter the code>"))?; + if let Err(e) = client.perform_sca(&sca_code).await { + // Ignore error if SCA already performed + if !matches!(e, ApiError::Magnet(MagnetError { ref short_message, .. }) if short_message == "TOKEN_SCA_HITELESITETT") + { + return Err(e.into()); + } + } + + info!("Setup public key"); + // TODO find a proper way to check if a public key have been setup + // TODO use the better from/to_array API in the next version of the crypto lib + let signing_key = match keys.signing_key { + Some(bytes) => SigningKey::from_slice(bytes.as_ref())?, + None => { + let rand = SigningKey::random(&mut rand_core::OsRng); + let array: [u8; 32] = rand.to_bytes().as_slice().try_into().unwrap(); + keys.signing_key = Some(Base32::from(array)); + json_file::persist(&cfg.keys_path, &keys)?; + rand + } + }; + if let Err(e) = client.upload_public_key(&signing_key).await { + // Ignore error if public key already uploaded + if !matches!(e, ApiError::Magnet(MagnetError { ref short_message, .. }) if short_message== "KULCS_MAR_HASZNALATBAN") + { + return Err(e.into()); + } + } + + info!("Check account"); + let res = client.list_accounts().await?; + let mut ibans = Vec::new(); + for partner in res.partners { + for account in partner.bank_accounts { + if cfg.payto.0 == account.iban { + if partner.partner.name != cfg.payto.name { + warn!( + "Expected name '{}' from config got '{}' from bank", + cfg.payto.name, partner.partner.name + ); + } + return Ok(()); + } else { + ibans.push(account.iban); + } + } + } + Err(anyhow::anyhow!( + "Unknown account {} from config, expected one of the following account {}", + cfg.payto.0, + ibans + .into_iter() + .map(|it| it.to_string()) + .collect::<Vec<_>>() + .join(", ") + )) +}