taler-rust

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

commit 2a5852b120d1c3f7c6c22f1113b4edd929a9cad0
parent 8aa7ebfd6b4880a01af57b1dbdfb9a18e86744f3
Author: Antoine A <>
Date:   Tue,  4 Nov 2025 14:48:52 +0100

magnet-bank: clean api client logic and improve worker

Diffstat:
Mtaler-magnet-bank/src/bin/magnet-bank-harness.rs | 5++++-
Mtaler-magnet-bank/src/config.rs | 2+-
Mtaler-magnet-bank/src/db.rs | 8++++----
Mtaler-magnet-bank/src/dev.rs | 2+-
Mtaler-magnet-bank/src/lib.rs | 2+-
Dtaler-magnet-bank/src/magnet.rs | 574-------------------------------------------------------------------------------
Dtaler-magnet-bank/src/magnet/error.rs | 169-------------------------------------------------------------------------------
Dtaler-magnet-bank/src/magnet/oauth.rs | 161-------------------------------------------------------------------------------
Ataler-magnet-bank/src/magnet_api.rs | 20++++++++++++++++++++
Ataler-magnet-bank/src/magnet_api/api.rs | 245+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ataler-magnet-bank/src/magnet_api/client.rs | 324+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ataler-magnet-bank/src/magnet_api/oauth.rs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ataler-magnet-bank/src/magnet_api/types.rs | 265+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtaler-magnet-bank/src/main.rs | 2+-
Mtaler-magnet-bank/src/setup.rs | 15++++++++-------
Mtaler-magnet-bank/src/worker.rs | 87++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
16 files changed, 1086 insertions(+), 954 deletions(-)

diff --git a/taler-magnet-bank/src/bin/magnet-bank-harness.rs b/taler-magnet-bank/src/bin/magnet-bank-harness.rs @@ -36,7 +36,10 @@ use taler_magnet_bank::{ config::{HarnessCfg, parse_db_cfg}, db::{self, TransferResult}, failure_injection::{FailureLogic, InjectedErr, set_failure_logic}, - magnet::{Account, ApiClient, AuthClient}, + magnet_api::{ + client::{ApiClient, AuthClient}, + types::Account, + }, setup, worker::{Worker, WorkerError}, }; diff --git a/taler-magnet-bank/src/config.rs b/taler-magnet-bank/src/config.rs @@ -26,7 +26,7 @@ use taler_common::{ types::payto::PaytoURI, }; -use crate::{FullHuPayto, HuIban, magnet::Token}; +use crate::{FullHuPayto, HuIban, magnet_api::oauth::Token}; pub fn parse_db_cfg(cfg: &Config) -> Result<DbCfg, ValueErr> { DbCfg::parse(cfg.section("magnet-bankdb-postgres")) diff --git a/taler-magnet-bank/src/db.rs b/taler-magnet-bank/src/db.rs @@ -33,7 +33,7 @@ use taler_common::{ }; use tokio::sync::watch::{Receiver, Sender}; -use crate::{FullHuPayto, constant::CURRENCY, magnet::TxStatus}; +use crate::{FullHuPayto, constant::CURRENCY, magnet_api::types::TxStatus}; pub async fn notification_listener( pool: PgPool, @@ -78,7 +78,7 @@ impl Display for TxIn { } = self; write!( f, - "{value_date} {amount} {code} ({} {}) '{subject}'", + "{value_date} {code} {amount} ({} {}) '{subject}'", debtor.bban(), debtor.name ) @@ -107,7 +107,7 @@ impl Display for TxOut { } = self; write!( f, - "{value_date} {amount} {code} ({} {}) {status:?} '{subject}'", + "{value_date} {code} {amount} ({} {}) {status:?} '{subject}'", creditor.bban(), &creditor.name ) @@ -715,7 +715,7 @@ mod test { TxIn, TxOut, TxOutKind, make_transfer, register_bounce_tx_in, register_tx_in, register_tx_in_admin, register_tx_out, }, - magnet::TxStatus, + magnet_api::types::TxStatus, magnet_payto, }; diff --git a/taler-magnet-bank/src/dev.rs b/taler-magnet-bank/src/dev.rs @@ -26,7 +26,7 @@ use tracing::info; use crate::{ HuPayto, TransferHuPayto, config::WorkerCfg, - magnet::{AuthClient, Direction}, + magnet_api::{client::AuthClient, types::Direction}, setup, worker::{Tx, extract_tx_info}, }; diff --git a/taler-magnet-bank/src/lib.rs b/taler-magnet-bank/src/lib.rs @@ -29,7 +29,7 @@ pub mod config; pub mod constant; pub mod db; pub mod dev; -pub mod magnet; +pub mod magnet_api; pub mod setup; pub mod worker; pub mod failure_injection { diff --git a/taler-magnet-bank/src/magnet.rs b/taler-magnet-bank/src/magnet.rs @@ -1,574 +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 base64::{Engine, prelude::BASE64_STANDARD}; -use error::ApiResult; -use jiff::Timestamp; -use p256::{ - PublicKey, - ecdsa::{DerSignature, SigningKey, signature::Signer as _}, -}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use spki::EncodePublicKey; -use taler_common::types::amount; - -use crate::{ - HuIban, - magnet::{error::MagnetBuilder, oauth::OAuthBuilder}, -}; - -pub mod error; -mod oauth; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Token { - #[serde(rename = "oauth_token")] - pub key: String, - #[serde(rename = "oauth_token_secret")] - pub secret: String, -} - -#[derive(Debug, Deserialize)] -pub struct TokenAuth { - pub oauth_token: String, - pub oauth_verifier: String, -} - -#[derive(Debug, Deserialize)] -pub struct Consumer { - #[serde(rename = "consumerKey")] - pub key: String, - #[serde(rename = "megnevezes")] - pub name: String, - #[serde(rename = "callbackUri")] - pub callback_uri: String, - #[serde(rename = "elettartam")] - pub lifetime: u64, -} - -#[derive(Debug, Deserialize)] -pub struct TokenInfo { - #[serde(rename = "keszult")] - pub created: jiff::Timestamp, - #[serde(rename = "lejarat")] - pub expiration: jiff::Timestamp, - #[serde(rename = "kliensinfo")] - pub client_info: Option<String>, - pub consumer: Consumer, - #[serde(rename = "hitelesitett")] - pub authenticated: bool, -} - -#[derive(Debug, Deserialize)] -pub struct SmsCodeSubmission { - #[serde(rename = "csatorna")] - pub channel: String, - #[serde(rename = "hovaMentKi")] - pub sent_to: Vec<String>, -} - -#[derive(Debug, Deserialize)] -pub struct ScaResult { - #[serde(rename = "csatorna")] - pub channel: String, - #[serde(rename = "hovaMentKi")] - pub sent_to: Vec<String>, -} - -#[derive(Debug, Deserialize)] -pub struct Partner { - #[serde(rename = "megnevezes")] - pub name: String, - #[serde(rename = "kod")] - pub code: u64, - #[serde(rename = "adoszam")] - pub tax_number: Option<String>, - #[serde(rename = "ebUfallapot")] - pub status: String, // TODO enum -} - -#[derive(Debug, Deserialize)] -pub struct AccountType { - #[serde(rename = "kod")] - pub code: u64, - #[serde(rename = "megnevezes")] - pub name: String, -} - -#[derive(Debug, Deserialize)] -pub struct Currency { - #[serde(rename = "jel")] - pub symbol: amount::Currency, - #[serde(rename = "megnevezes")] - pub name: String, -} - -#[derive(Debug, Deserialize)] -pub struct Account { - #[serde(rename = "alapertelmezett")] - pub default: bool, - #[serde(rename = "bankszamlaTipus")] - pub ty: AccountType, - #[serde(rename = "deviza")] - pub currency: Currency, - #[serde(rename = "ibanSzamlaszam")] - pub iban: HuIban, - #[serde(rename = "kod")] - pub code: u64, - #[serde(rename = "szamlaszam")] - pub number: String, - #[serde(rename = "tulajdonosKod")] - pub owner_code: u64, - #[serde(rename = "lakossagi")] - pub resident: bool, - #[serde(rename = "megnevezes")] - pub name: Option<String>, - pub partner: Partner, -} - -#[derive(Debug, Deserialize)] -pub struct BalanceMini { - #[serde(rename = "pozicio")] - pub balance: f64, - #[serde(rename = "frissites")] - pub last_update_time: jiff::Timestamp, -} - -#[derive(Debug, Deserialize)] -pub struct PartnerAccounts { - pub partner: Partner, - #[serde(rename = "bankszamlaList")] - pub bank_accounts: Vec<Account>, - #[serde(rename = "kertJogosultsag")] - pub requested_permission: u64, -} - -#[derive(Debug, Deserialize)] -pub struct PartnerList { - #[serde(rename = "partnerSzamlaList")] - pub partners: Vec<PartnerAccounts>, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum TxStatus { - #[serde(rename = "G")] - ToBeRecorded, - #[serde(rename = "1")] - PendingFirstSignature, - #[serde(rename = "2")] - PendingSecondSignature, - #[serde(rename = "F")] - PendingProcessing, - #[serde(rename = "L")] - Verified, - #[serde(rename = "R")] - PartiallyCompleted, - #[serde(rename = "T")] - Completed, - #[serde(rename = "E")] - Rejected, - #[serde(rename = "M")] - Canceled, - #[serde(rename = "P")] - UnderReview, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum Direction { - #[serde(rename = "T")] - Outgoing, - #[serde(rename = "J")] - Incoming, - #[serde(rename = "M")] - Both, -} - -#[derive(Debug, Deserialize)] -pub struct TxInfo { - #[serde(rename = "alairas1idopont")] - pub first_signature: Option<Timestamp>, - #[serde(rename = "alairas2idopont")] - pub second_signature: Option<Timestamp>, - #[serde(rename = "alairo1")] - pub first_signatory: Option<Partner>, - #[serde(rename = "alairo2")] - pub second_signatory: Option<Partner>, - #[serde(rename = "reszteljesites")] - pub partial_execution: bool, - #[serde(rename = "sorbaallitas")] - pub queued: bool, - #[serde(rename = "kod")] - pub code: u64, - #[serde(rename = "bankszamla")] - pub bank_account: Account, - #[serde(rename = "deviza")] - pub currency: Currency, - #[serde(rename = "osszegSigned")] - pub amount: f64, - #[serde(rename = "kozlemeny")] - pub subject: String, - #[serde(rename = "statusz")] - pub status: TxStatus, - #[serde(rename = "tranzakcioAltipus")] - pub kind: Option<String>, - #[serde(rename = "eredetiErteknap")] - pub tx_date: jiff::civil::Date, - #[serde(rename = "erteknap")] - pub value_date: jiff::civil::Date, - #[serde(rename = "eszamla")] - pub counter_account: String, - #[serde(rename = "epartner")] - pub counter_name: String, - #[serde(rename = "tranzakcioTipus")] - pub ty: Option<String>, - pub eam: Option<u64>, -} - -#[derive(Debug, Deserialize)] -struct TxInfoWrapper { - #[serde(rename = "tranzakcio")] - info: TxInfo, -} - -#[derive(Debug, Deserialize)] -struct AccountWrapper { - #[serde(rename = "bankszamla")] - account: Account, -} - -#[derive(Debug, Deserialize)] -pub struct Transaction { - #[serde(rename = "kod")] - pub code: u64, - #[serde(rename = "bankszamla")] - pub bank_account: String, - #[serde(rename = "bankszamlaTulajdonos")] - pub bank_account_owner: String, - #[serde(rename = "deviza")] - pub currency: amount::Currency, - #[serde(rename = "osszeg")] - pub amount: f64, - #[serde(rename = "kozlemeny")] - pub subject: String, - #[serde(rename = "statusz")] - pub status: TxStatus, - #[serde(rename = "tranzakcioAltipus")] - pub kind: Option<String>, - #[serde(rename = "eredetiErteknap")] - pub tx_date: jiff::civil::Date, - #[serde(rename = "erteknap")] - pub value_date: jiff::civil::Date, - #[serde(rename = "eszamla")] - pub counter_account: String, - #[serde(rename = "epartner")] - pub counter_name: String, - #[serde(rename = "tranzakcioTipus")] - pub ty: Option<String>, - pub eam: Option<u64>, -} - -#[derive(Debug, Deserialize)] -pub struct Next { - #[serde(rename = "next")] - pub next_id: u64, - #[serde(rename = "nextTipus")] - pub next_type: String, -} - -#[derive(Debug, Deserialize)] -pub struct TransactionPage { - #[serde(flatten)] - pub next: Option<Next>, - #[serde(rename = "tranzakcioList", default)] - pub list: Vec<TransactionWrapper>, -} - -#[derive(Debug, Deserialize)] -pub struct TransactionWrapper { - #[serde(rename = "tranzakcioDto")] - pub tx: Transaction, -} - -pub struct AuthClient<'a> { - client: &'a reqwest::Client, - api_url: &'a reqwest::Url, - consumer: &'a Token, -} - -impl<'a> AuthClient<'a> { - pub fn new( - client: &'a reqwest::Client, - api_url: &'a reqwest::Url, - consumer: &'a Token, - ) -> Self { - Self { - client, - api_url, - consumer, - } - } - - pub fn join(&self, path: &str) -> reqwest::Url { - self.api_url.join(path).unwrap() - } - - pub async fn token_request(&self) -> ApiResult<Token> { - self.client - .get(self.join("/NetBankOAuth/token/request")) - .query(&[("oauth_callback", "oob")]) - .oauth(self.consumer, None, None) - .await - .magnet_call_encoded() - .await - } - - pub async fn token_access( - &self, - token_request: &Token, - token_auth: &TokenAuth, - ) -> ApiResult<Token> { - self.client - .get(self.join("/NetBankOAuth/token/access")) - .oauth( - self.consumer, - Some(token_request), - Some(&token_auth.oauth_verifier), - ) - .await - .magnet_call_encoded() - .await - } - - pub fn upgrade(self, access: &'a Token) -> ApiClient<'a> { - ApiClient { - client: self.client, - api_url: self.api_url, - consumer: self.consumer, - access, - } - } -} - -pub struct ApiClient<'a> { - client: &'a reqwest::Client, - api_url: &'a reqwest::Url, - consumer: &'a Token, - access: &'a Token, -} - -impl ApiClient<'_> { - fn join(&self, path: &str) -> reqwest::Url { - self.api_url.join(path).unwrap() - } - - pub async fn token_info(&self) -> ApiResult<TokenInfo> { - self.client - .get(self.join("/RESTApi/resources/v2/token")) - .oauth(self.consumer, Some(self.access), None) - .await - .magnet_json() - .await - } - - pub async fn request_sms_code(&self) -> ApiResult<SmsCodeSubmission> { - self.client - .get(self.join("/RESTApi/resources/v2/kodszo/sms/token")) - .oauth(self.consumer, Some(self.access), None) - .await - .magnet_json() - .await - } - - pub async fn perform_sca(&self, code: &str) -> ApiResult<()> { - self.client - .put(self.join("/RESTApi/resources/v2/token/SCA")) - .json(&json!({ - "kodszo": code - })) - .oauth(self.consumer, Some(self.access), None) - .await - .magnet_empty() - .await - } - - pub async fn upload_public_key(&self, key: &SigningKey) -> ApiResult<Value> { - let public_key = PublicKey::from_secret_scalar(key.as_nonzero_scalar()); - let der = public_key.to_public_key_der().unwrap().to_vec(); - self.client - .post(self.join("/RESTApi/resources/v2/token/public-key")) - .json(&json!({ - "keyData": BASE64_STANDARD.encode(der) - })) - .oauth(self.consumer, Some(self.access), None) - .await - .magnet_json() - .await - } - - pub async fn list_accounts(&self) -> ApiResult<PartnerList> { - self.client - .get(self.join("/RESTApi/resources/v2/partnerszamla/0")) - .oauth(self.consumer, Some(self.access), None) - .await - .magnet_json() - .await - } - - pub async fn account(&self, bban: &str) -> ApiResult<Account> { - Ok(self - .client - .get(self.join(&format!("/RESTApi/resources/v2/bankszamla/{bban}"))) - .oauth(self.consumer, Some(self.access), None) - .await - .magnet_json::<AccountWrapper>() - .await? - .account) - } - - pub async fn balance_mini(&self, bban: &str) -> ApiResult<BalanceMini> { - self.client - .get(self.join(&format!("/RESTApi/resources/v2/egyenleg/{bban}/szukitett"))) - .oauth(self.consumer, Some(self.access), None) - .await - .magnet_json() - .await - } - - pub async fn page_tx( - &self, - direction: Direction, - limit: u16, - bban: &str, - next: &Option<Next>, - status: &Option<TxStatus>, - ) -> ApiResult<TransactionPage> { - let mut req = self.client.get(self.join(&format!( - "/RESTApi/resources/v2/tranzakcio/paginator/{bban}/{limit}" - ))); - if let Some(next) = next { - req = req - .query(&[("nextId", next.next_id)]) - .query(&[("nextTipus", &next.next_type)]); - } - if let Some(status) = status { - req = req.query(&[("statusz", status)]); - } - req.query(&[("terheles", direction)]) - .query(&[("tranzakciofrissites", true), ("ascending", true)]) - .oauth(self.consumer, Some(self.access), None) - .await - .magnet_call() - .await - } - - pub async fn init_tx( - &self, - account_code: u64, - amount: f64, - subject: &str, - date: &jiff::civil::Date, - creditor_name: &str, - creditor_bban: &str, - ) -> ApiResult<TxInfo> { - #[derive(Serialize)] - struct Req<'a> { - #[serde(rename = "bankszamlaKod")] - account_code: u64, - #[serde(rename = "osszeg")] - amount: f64, - #[serde(rename = "kozlemeny")] - subject: &'a str, - #[serde(rename = "ertekNap")] - date: &'a jiff::civil::Date, - #[serde(rename = "ellenpartner")] - creditor_name: &'a str, - #[serde(rename = "ellenszamla")] - creditor_account: &'a str, - } - - Ok(self - .client - .post(self.join("/RESTApi/resources/v2/esetiatutalas")) - .json(&Req { - account_code, - amount, - subject, - date, - creditor_name, - creditor_account: creditor_bban, - }) - .oauth(self.consumer, Some(self.access), None) - .await - .magnet_call::<TxInfoWrapper>() - .await? - .info) - } - - pub async fn submit_tx( - &self, - signing_key: &SigningKey, - bban: &str, - tx_code: u64, - amount: f64, - date: &jiff::civil::Date, - creditor_bban: &str, - ) -> ApiResult<TxInfo> { - #[derive(Serialize)] - struct Req<'a> { - #[serde(rename = "tranzakcioKod")] - tx_code: u64, - #[serde(rename = "forrasszamla")] - debtor: &'a str, - #[serde(rename = "ellenszamla")] - creditor: &'a str, - #[serde(rename = "osszeg")] - amount: f64, - #[serde(rename = "ertekNap")] - date: &'a jiff::civil::Date, - signature: &'a str, - } - - let content: String = format!("{tx_code};{bban};{creditor_bban};{amount};{date};"); - let signature: DerSignature = signing_key.sign(content.as_bytes()); - let encoded = BASE64_STANDARD.encode(signature.as_bytes()); - Ok(self - .client - .put(self.join("/RESTApi/resources/v2/tranzakcio/alairas")) - .json(&Req { - tx_code, - debtor: bban, - creditor: creditor_bban, - amount, - date, - signature: &encoded, - }) - .oauth(self.consumer, Some(self.access), None) - .await - .magnet_call::<TxInfoWrapper>() - .await? - .info) - } - - pub async fn delete_tx(&self, tx_code: u64) -> ApiResult<()> { - self.client - .delete(self.join(&format!("/RESTApi/resources/v2/tranzakcio/{tx_code}"))) - .oauth(self.consumer, Some(self.access), None) - .await - .magnet_empty() - .await - } -} diff --git a/taler-magnet-bank/src/magnet/error.rs b/taler-magnet-bank/src/magnet/error.rs @@ -1,169 +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 reqwest::{Response, StatusCode, header}; -use serde::{Deserialize, de::DeserializeOwned}; -use thiserror::Error; -use tracing::{Level, error}; - -#[derive(Deserialize, Debug)] -struct Header { - #[serde(rename = "errorCode")] - pub error_code: Option<u16>, -} - -#[derive(Deserialize, Debug)] -struct Empty {} - -#[derive(Deserialize, Error, Debug)] -#[error("{error_code} {short_message} '{long_message}'")] -pub struct MagnetError { - #[serde(rename = "errorCode")] - pub error_code: u16, - #[serde(rename = "shortMessage")] - pub short_message: String, - #[serde(rename = "longMessage")] - pub long_message: String, -} - -#[derive(Error, Debug)] -pub enum ApiError { - #[error("transport: {0}")] - Transport(FmtSource<reqwest::Error>), - #[error("magnet {0}")] - Magnet(#[from] MagnetError), - #[error("JSON body: {0}")] - Json(#[from] serde_path_to_error::Error<serde_json::Error>), - #[error("form body: {0}")] - Form(#[from] serde_urlencoded::de::Error), - #[error("status {0}")] - Status(StatusCode), - #[error("status {0} '{1}'")] - StatusCause(StatusCode, String), -} - -#[derive(Debug)] -pub struct FmtSource<E: std::error::Error>(E); - -fn fmt_with_source( - f: &mut std::fmt::Formatter<'_>, - mut e: &dyn std::error::Error, -) -> std::fmt::Result { - loop { - write!(f, "{}", &e)?; - if let Some(source) = e.source() { - write!(f, ": ")?; - e = source; - } else { - return Ok(()); - } - } -} - -impl<E: std::error::Error> std::fmt::Display for FmtSource<E> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - fmt_with_source(f, &self.0) - } -} - -impl<E: std::error::Error> From<E> for FmtSource<E> { - fn from(value: E) -> Self { - Self(value) - } -} - -impl From<reqwest::Error> for ApiError { - fn from(value: reqwest::Error) -> Self { - Self::Transport(FmtSource(value)) - } -} - -pub type ApiResult<R> = std::result::Result<R, ApiError>; - -/** Handle error from magnet API calls */ -async fn error_handling(res: reqwest::Result<Response>) -> ApiResult<String> { - let res = res?; - let status = res.status(); - match status { - StatusCode::OK => Ok(res.text().await?), - StatusCode::BAD_REQUEST => Err(ApiError::Status(status)), - StatusCode::FORBIDDEN => { - let cause = res - .headers() - .get(header::WWW_AUTHENTICATE) - .map(|s| s.to_str().unwrap_or_default()) - .unwrap_or_default(); - Err(ApiError::StatusCause(status, cause.to_string())) - } - _ => { - if tracing::enabled!(Level::DEBUG) { - tracing::debug!("unexpected error: {:?}", &res); - let body = res.text().await; - tracing::debug!("unexpected error body: {:?}", body); - } - Err(ApiError::Status(status)) - } - } -} - -/** Parse JSON and track error path */ -fn parse<'de, T: Deserialize<'de>>(str: &'de str) -> ApiResult<T> { - let deserializer = &mut serde_json::Deserializer::from_str(str); - serde_path_to_error::deserialize(deserializer).map_err(ApiError::Json) -} - -/** Parse magnet JSON response */ -async fn magnet_json<T: DeserializeOwned>(res: reqwest::Result<Response>) -> ApiResult<T> { - let body = error_handling(res).await?; - let header: Header = parse(&body)?; - if header.error_code.unwrap_or(200) == 200 { - parse(&body) - } else { - Err(ApiError::Magnet(parse(&body)?)) - } -} - -/** Parse magnet URL encoded response into our own type */ -async fn magnet_url<T: DeserializeOwned>(response: reqwest::Result<Response>) -> ApiResult<T> { - let body = error_handling(response).await?; - serde_urlencoded::from_str(&body).map_err(ApiError::Form) -} - -pub(crate) trait MagnetBuilder { - async fn magnet_call_encoded<T: DeserializeOwned>(self) -> ApiResult<T>; - async fn magnet_call<T: DeserializeOwned>(self) -> ApiResult<T>; - async fn magnet_empty(self) -> ApiResult<()>; - async fn magnet_json<R: DeserializeOwned>(self) -> ApiResult<R>; -} - -impl MagnetBuilder for reqwest::Result<Response> { - async fn magnet_call_encoded<T: DeserializeOwned>(self) -> ApiResult<T> { - magnet_url(self).await - } - - async fn magnet_call<T: DeserializeOwned>(self) -> ApiResult<T> { - magnet_json(self).await - } - - async fn magnet_empty(self) -> ApiResult<()> { - magnet_json::<Empty>(self).await?; - Ok(()) - } - - async fn magnet_json<R: DeserializeOwned>(self) -> ApiResult<R> { - magnet_json(self).await - } -} diff --git a/taler-magnet-bank/src/magnet/oauth.rs b/taler-magnet-bank/src/magnet/oauth.rs @@ -1,161 +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::{borrow::Cow, time::SystemTime}; - -use base64::{Engine as _, prelude::BASE64_STANDARD}; -use hmac::{Hmac, Mac}; -use percent_encoding::NON_ALPHANUMERIC; -use rand_core::RngCore; -use reqwest::header::HeaderValue; -use sha1::Sha1; - -use super::Token; - -type HmacSha1 = Hmac<Sha1>; - -/** Generate a secure OAuth nonce */ -fn oauth_nonce() -> String { - // Generate 8 secure random bytes - let mut buf = [0u8; 8]; - rand_core::OsRng.fill_bytes(&mut buf); - // Encode as base64 string - BASE64_STANDARD.encode(buf) -} - -/** Generate an OAuth timestamp */ -fn oauth_timestamp() -> u64 { - let start = SystemTime::now(); - let since_the_epoch = start - .duration_since(std::time::UNIX_EPOCH) - .expect("Time went backwards"); - - since_the_epoch.as_secs() -} - -/** Generate a valid OAuth Authorization header */ -fn oauth_header( - method: &reqwest::Method, - url: &reqwest::Url, - consumer: &Token, - access: Option<&Token>, - verifier: Option<&str>, -) -> String { - // Per request value - let oauth_nonce = oauth_nonce(); - let oauth_timestamp = oauth_timestamp().to_string(); - - // Base string - let base_string = { - let oauth_data = { - let mut oauth_query: Vec<(&str, &str)> = vec![ - ("oauth_consumer_key", &consumer.key), - ("oauth_nonce", &oauth_nonce), - ("oauth_signature_method", "HMAC-SHA1"), - ("oauth_timestamp", &oauth_timestamp), - ]; - if let Some(token) = &access { - oauth_query.push(("oauth_token", &token.key)); - } - if let Some(verifier) = &verifier { - oauth_query.push(("oauth_verifier", verifier)); - } - oauth_query.push(("oauth_version", "1.0")); - let mut all_query: Vec<_> = oauth_query - .into_iter() - .map(|(a, b)| (Cow::Borrowed(a), Cow::Borrowed(b))) - .chain(url.query_pairs()) - .collect(); - all_query.sort_unstable(); - - let mut tmp: form_urlencoded::Serializer<'_, String> = - form_urlencoded::Serializer::new(String::new()); - for (k, v) in all_query { - tmp.append_pair(&k, &v); - } - tmp.finish() - }; - let mut stripped = url.clone(); - stripped.set_query(None); - form_urlencoded::Serializer::new(String::new()) - .append_key_only(method.as_str()) - .append_key_only(stripped.as_str()) - .append_key_only(&oauth_data) - .finish() - }; - - // Signature - let key = { - let mut buf = consumer.secret.clone(); - buf.push('&'); - if let Some(token) = access { - buf.push_str(&token.secret); - } - buf - }; - let signature = HmacSha1::new_from_slice(key.as_bytes()) - .expect("HMAC can take key of any size") - .chain_update(base_string.as_bytes()) - .finalize() - .into_bytes(); - let signature_encoded = BASE64_STANDARD.encode(signature); - - // Authorization header - { - let mut buf = "OAuth ".to_string(); - let mut append = |key: &str, value: &str| { - buf.push_str(key); - buf.push_str("=\""); - for part in percent_encoding::percent_encode(value.as_bytes(), NON_ALPHANUMERIC) { - buf.push_str(part); - } - buf.push_str("\","); - }; - append("oauth_consumer_key", &consumer.key); - append("oauth_nonce", &oauth_nonce); - append("oauth_signature_method", "HMAC-SHA1"); - append("oauth_timestamp", &oauth_timestamp); - if let Some(token) = &access { - append("oauth_token", &token.key); - } - if let Some(verifier) = &verifier { - append("oauth_verifier", verifier); - } - append("oauth_version", "1.0"); - append("oauth_signature", &signature_encoded); - buf - } -} - -pub trait OAuthBuilder<T> { - async fn oauth(self, consumer: &Token, access: Option<&Token>, verifier: Option<&str>) -> T; -} - -impl OAuthBuilder<reqwest::Result<reqwest::Response>> for reqwest::RequestBuilder { - async fn oauth( - self, - consumer: &Token, - access: Option<&Token>, - verifier: Option<&str>, - ) -> reqwest::Result<reqwest::Response> { - let (client, req) = self.build_split(); - let mut req = req?; - let header = oauth_header(req.method(), req.url(), consumer, access, verifier); - req.headers_mut() - .append("Authorization", HeaderValue::from_str(&header).unwrap()); - client.execute(req).await - } -} diff --git a/taler-magnet-bank/src/magnet_api.rs b/taler-magnet-bank/src/magnet_api.rs @@ -0,0 +1,20 @@ +/* + 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/> +*/ + +pub mod api; +pub mod client; +pub mod oauth; +pub mod types; diff --git a/taler-magnet-bank/src/magnet_api/api.rs b/taler-magnet-bank/src/magnet_api/api.rs @@ -0,0 +1,245 @@ +/* + 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::borrow::Cow; + +use reqwest::{Client, Method, RequestBuilder, Response, StatusCode, Url, header}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use thiserror::Error; +use tracing::{Level, error}; + +use crate::magnet_api::oauth::{Token, oauth}; + +#[derive(Deserialize, Debug)] +struct Header { + #[serde(rename = "errorCode")] + pub error_code: Option<u16>, +} + +#[derive(Deserialize, Debug)] +struct Empty {} + +#[derive(Deserialize, Error, Debug)] +#[error("{error_code} {short_message} '{long_message}'")] +pub struct MagnetError { + #[serde(rename = "errorCode")] + pub error_code: u16, + #[serde(rename = "shortMessage")] + pub short_message: String, + #[serde(rename = "longMessage")] + pub long_message: String, +} + +#[derive(Error, Debug)] +#[error("{method} {path} {kind}")] +pub struct ApiErr { + pub path: Cow<'static, str>, + pub method: Method, + pub kind: ErrKind, +} + +#[derive(Error, Debug)] +pub enum ErrKind { + #[error("transport: {0}")] + Transport(FmtSource<reqwest::Error>), + #[error("magnet {0}")] + Magnet(#[from] MagnetError), + #[error("JSON body: {0}")] + Json(#[from] serde_path_to_error::Error<serde_json::Error>), + #[error("form body: {0}")] + Form(#[from] serde_urlencoded::de::Error), + #[error("status {0}")] + Status(StatusCode), + #[error("status {0} '{1}'")] + StatusCause(StatusCode, String), +} + +#[derive(Debug)] +pub struct FmtSource<E: std::error::Error>(E); + +fn fmt_with_source( + f: &mut std::fmt::Formatter<'_>, + mut e: &dyn std::error::Error, +) -> std::fmt::Result { + loop { + write!(f, "{}", &e)?; + if let Some(source) = e.source() { + write!(f, ": ")?; + e = source; + } else { + return Ok(()); + } + } +} + +impl<E: std::error::Error> std::fmt::Display for FmtSource<E> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fmt_with_source(f, &self.0) + } +} + +impl<E: std::error::Error> From<E> for FmtSource<E> { + fn from(value: E) -> Self { + Self(value) + } +} + +impl From<reqwest::Error> for ErrKind { + fn from(value: reqwest::Error) -> Self { + Self::Transport(FmtSource(value)) + } +} + +pub type ApiResult<R> = std::result::Result<R, ApiErr>; + +/** Handle error from magnet API calls */ +async fn error_handling(res: reqwest::Result<Response>) -> Result<String, ErrKind> { + let res = res?; + let status = res.status(); + match status { + StatusCode::OK => Ok(res.text().await?), + StatusCode::BAD_REQUEST => Err(ErrKind::Status(status)), + StatusCode::FORBIDDEN => { + let cause = res + .headers() + .get(header::WWW_AUTHENTICATE) + .map(|s| s.to_str().unwrap_or_default()) + .unwrap_or_default(); + Err(ErrKind::StatusCause(status, cause.to_string())) + } + _ => { + if tracing::enabled!(Level::DEBUG) { + tracing::debug!("unexpected error: {:?}", &res); + let body = res.text().await; + tracing::debug!("unexpected error body: {:?}", body); + } + Err(ErrKind::Status(status)) + } + } +} + +/** Parse JSON and track error path */ +fn parse<'de, T: Deserialize<'de>>(str: &'de str) -> Result<T, ErrKind> { + let deserializer = &mut serde_json::Deserializer::from_str(str); + serde_path_to_error::deserialize(deserializer).map_err(ErrKind::Json) +} + +/** Parse magnet JSON response */ +async fn magnet_json<T: DeserializeOwned>(res: reqwest::Result<Response>) -> Result<T, ErrKind> { + let body = error_handling(res).await?; + let header: Header = parse(&body)?; + if header.error_code.unwrap_or(200) == 200 { + parse(&body) + } else { + Err(ErrKind::Magnet(parse(&body)?)) + } +} + +/** Parse magnet URL encoded response into our own type */ +async fn magnet_url<T: DeserializeOwned>( + response: reqwest::Result<Response>, +) -> Result<T, ErrKind> { + let body = error_handling(response).await?; + serde_urlencoded::from_str(&body).map_err(ErrKind::Form) +} + +pub struct MagnetRequest<'a> { + path: Cow<'static, str>, + method: Method, + builder: RequestBuilder, + consumer: &'a Token, + access: Option<&'a Token>, + verifier: Option<&'a str>, +} + +impl<'a> MagnetRequest<'a> { + pub fn new( + client: &Client, + method: Method, + base_url: &Url, + path: impl Into<Cow<'static, str>>, + consumer: &'a Token, + access: Option<&'a Token>, + verifier: Option<&'a str>, + ) -> Self { + let path = path.into(); + let url = base_url.join(&path).unwrap(); + let builder = client.request(method.clone(), url); + Self { + path, + method, + builder, + consumer, + access, + verifier, + } + } + + pub fn query<T: Serialize + ?Sized>(mut self, query: &T) -> Self { + self.builder = self.builder.query(query); + self + } + + pub fn json<T: Serialize + ?Sized>(mut self, json: &T) -> Self { + self.builder = self.builder.json(json); + self + } + + pub async fn parse_url<T: DeserializeOwned>(self) -> ApiResult<T> { + let Self { + path, + builder, + method, + consumer, + access, + verifier, + } = self; + let (client, req) = builder.build_split(); + async { + let mut req = req?; + oauth(&mut req, consumer, access, verifier); + let res = client.execute(req).await; + magnet_url(res).await + } + .await + .map_err(|kind| ApiErr { path, method, kind }) + } + + pub async fn parse_json<T: DeserializeOwned>(self) -> ApiResult<T> { + let Self { + path, + builder, + method, + consumer, + access, + verifier, + } = self; + let (client, req) = builder.build_split(); + async { + let mut req = req?; + oauth(&mut req, consumer, access, verifier); + let res = client.execute(req).await; + magnet_json(res).await + } + .await + .map_err(|kind| ApiErr { path, method, kind }) + } + + pub async fn parse_empty(self) -> ApiResult<()> { + self.parse_json::<Empty>().await?; + Ok(()) + } +} diff --git a/taler-magnet-bank/src/magnet_api/client.rs b/taler-magnet-bank/src/magnet_api/client.rs @@ -0,0 +1,324 @@ +/* + 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::borrow::Cow; + +use base64::{Engine as _, prelude::BASE64_STANDARD}; +use p256::{ + PublicKey, + ecdsa::{DerSignature, SigningKey, signature::Signer as _}, + pkcs8::EncodePublicKey, +}; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::magnet_api::{ + api::{ApiResult, MagnetRequest}, + oauth::{Token, TokenAuth}, + types::{ + Account, BalanceMini, Direction, Next, PartnerList, SmsCodeSubmission, TokenInfo, + TransactionPage, Tx, TxStatus, + }, +}; + +#[derive(Debug, Deserialize)] +struct TxWrapper { + #[serde(rename = "tranzakcio")] + tx: Tx, +} + +#[derive(Debug, Deserialize)] +pub struct AccountWrapper { + #[serde(rename = "bankszamla")] + account: Account, +} + +pub struct AuthClient<'a> { + client: &'a reqwest::Client, + pub api_url: &'a reqwest::Url, + consumer: &'a Token, +} + +impl<'a> AuthClient<'a> { + pub fn new( + client: &'a reqwest::Client, + api_url: &'a reqwest::Url, + consumer: &'a Token, + ) -> Self { + Self { + client, + api_url, + consumer, + } + } + + fn request( + &self, + method: Method, + path: impl Into<Cow<'static, str>>, + access: Option<&'a Token>, + verifier: Option<&'a str>, + ) -> MagnetRequest<'_> { + MagnetRequest::new( + self.client, + method, + self.api_url, + path, + self.consumer, + access, + verifier, + ) + } + + pub async fn token_request(&self) -> ApiResult<Token> { + self.request(Method::GET, "/NetBankOAuth/token/request", None, None) + .query(&[("oauth_callback", "oob")]) + .parse_url() + .await + } + + pub async fn token_access( + &self, + token_request: &Token, + token_auth: &TokenAuth, + ) -> ApiResult<Token> { + self.request( + Method::GET, + "/NetBankOAuth/token/access", + Some(token_request), + Some(&token_auth.oauth_verifier), + ) + .parse_url() + .await + } + + pub fn upgrade(self, access: &'a Token) -> ApiClient<'a> { + ApiClient { + client: self.client, + api_url: self.api_url, + consumer: self.consumer, + access, + } + } +} + +pub struct ApiClient<'a> { + client: &'a reqwest::Client, + api_url: &'a reqwest::Url, + consumer: &'a Token, + access: &'a Token, +} + +impl ApiClient<'_> { + fn request(&self, method: Method, path: impl Into<Cow<'static, str>>) -> MagnetRequest<'_> { + MagnetRequest::new( + self.client, + method, + self.api_url, + path, + self.consumer, + Some(self.access), + None, + ) + } + + pub async fn token_info(&self) -> ApiResult<TokenInfo> { + self.request(Method::GET, "/RESTApi/resources/v2/token") + .parse_json() + .await + } + + pub async fn request_sms_code(&self) -> ApiResult<SmsCodeSubmission> { + self.request(Method::GET, "/RESTApi/resources/v2/kodszo/sms/token") + .parse_json() + .await + } + + pub async fn perform_sca(&self, code: &str) -> ApiResult<()> { + self.request(Method::PUT, "/RESTApi/resources/v2/token/SCA") + .json(&json!({ + "kodszo": code + })) + .parse_empty() + .await + } + + pub async fn upload_public_key(&self, key: &SigningKey) -> ApiResult<Value> { + let public_key = PublicKey::from_secret_scalar(key.as_nonzero_scalar()); + let der = public_key.to_public_key_der().unwrap().to_vec(); + self.request(Method::POST, "/RESTApi/resources/v2/token/public-key") + .json(&json!({ + "keyData": BASE64_STANDARD.encode(der) + })) + .parse_json() + .await + } + + pub async fn list_accounts(&self) -> ApiResult<PartnerList> { + self.request(Method::GET, "/RESTApi/resources/v2/partnerszamla/0") + .parse_json() + .await + } + + pub async fn account(&self, bban: &str) -> ApiResult<Account> { + Ok(self + .request( + Method::GET, + format!("/RESTApi/resources/v2/bankszamla/{bban}"), + ) + .parse_json::<AccountWrapper>() + .await? + .account) + } + + pub async fn balance_mini(&self, bban: &str) -> ApiResult<BalanceMini> { + self.request( + Method::GET, + format!("/RESTApi/resources/v2/egyenleg/{bban}/szukitett"), + ) + .parse_json() + .await + } + + pub async fn get_tx(&self, code: u64) -> ApiResult<Tx> { + Ok(self + .request( + Method::GET, + format!("/RESTApi/resources/v2/tranzakcio/{code}"), + ) + .parse_json::<TxWrapper>() + .await? + .tx) + } + + pub async fn page_tx( + &self, + direction: Direction, + limit: u16, + bban: &str, + next: &Option<Next>, + status: &Option<TxStatus>, + ) -> ApiResult<TransactionPage> { + let mut req = self.request( + Method::GET, + format!("/RESTApi/resources/v2/tranzakcio/paginator/{bban}/{limit}"), + ); + if let Some(next) = next { + req = req + .query(&[("nextId", next.next_id)]) + .query(&[("nextTipus", &next.next_type)]); + } + if let Some(status) = status { + req = req.query(&[("statusz", status)]); + } + req.query(&[("terheles", direction)]) + .query(&[("tranzakciofrissites", true), ("ascending", true)]) + .parse_json() + .await + } + + pub async fn init_tx( + &self, + account_code: u64, + amount: f64, + subject: &str, + date: &jiff::civil::Date, + creditor_name: &str, + creditor_bban: &str, + ) -> ApiResult<Tx> { + #[derive(Serialize)] + struct Req<'a> { + #[serde(rename = "bankszamlaKod")] + account_code: u64, + #[serde(rename = "osszeg")] + amount: f64, + #[serde(rename = "kozlemeny")] + subject: &'a str, + #[serde(rename = "ertekNap")] + date: &'a jiff::civil::Date, + #[serde(rename = "ellenpartner")] + creditor_name: &'a str, + #[serde(rename = "ellenszamla")] + creditor_account: &'a str, + } + + Ok(self + .request(Method::POST, "/RESTApi/resources/v2/esetiatutalas") + .json(&Req { + account_code, + amount, + subject, + date, + creditor_name, + creditor_account: creditor_bban, + }) + .parse_json::<TxWrapper>() + .await? + .tx) + } + + pub async fn submit_tx( + &self, + signing_key: &SigningKey, + bban: &str, + tx_code: u64, + amount: f64, + date: &jiff::civil::Date, + creditor_bban: &str, + ) -> ApiResult<Tx> { + #[derive(Serialize)] + struct Req<'a> { + #[serde(rename = "tranzakcioKod")] + tx_code: u64, + #[serde(rename = "forrasszamla")] + debtor: &'a str, + #[serde(rename = "ellenszamla")] + creditor: &'a str, + #[serde(rename = "osszeg")] + amount: f64, + #[serde(rename = "ertekNap")] + date: &'a jiff::civil::Date, + signature: &'a str, + } + + let content: String = format!("{tx_code};{bban};{creditor_bban};{amount};{date};"); + let signature: DerSignature = signing_key.sign(content.as_bytes()); + let encoded = BASE64_STANDARD.encode(signature.as_bytes()); + Ok(self + .request(Method::PUT, "/RESTApi/resources/v2/tranzakcio/alairas") + .json(&Req { + tx_code, + debtor: bban, + creditor: creditor_bban, + amount, + date, + signature: &encoded, + }) + .parse_json::<TxWrapper>() + .await? + .tx) + } + + pub async fn delete_tx(&self, tx_code: u64) -> ApiResult<()> { + self.request( + Method::DELETE, + format!("/RESTApi/resources/v2/tranzakcio/{tx_code}"), + ) + .parse_empty() + .await + } +} diff --git a/taler-magnet-bank/src/magnet_api/oauth.rs b/taler-magnet-bank/src/magnet_api/oauth.rs @@ -0,0 +1,159 @@ +/* + 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::{borrow::Cow, time::SystemTime}; + +use base64::{Engine as _, prelude::BASE64_STANDARD}; +use hmac::{Hmac, Mac}; +use percent_encoding::NON_ALPHANUMERIC; +use rand_core::RngCore; +use reqwest::{Request, header::HeaderValue}; +use serde::{Deserialize, Serialize}; +use sha1::Sha1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Token { + #[serde(rename = "oauth_token")] + pub key: String, + #[serde(rename = "oauth_token_secret")] + pub secret: String, +} + +#[derive(Debug, Deserialize)] +pub struct TokenAuth { + pub oauth_token: String, + pub oauth_verifier: String, +} + +/** Generate a secure OAuth nonce */ +fn oauth_nonce() -> String { + // Generate 8 secure random bytes + let mut buf = [0u8; 8]; + rand_core::OsRng.fill_bytes(&mut buf); + // Encode as base64 string + BASE64_STANDARD.encode(buf) +} + +/** Generate an OAuth timestamp */ +fn oauth_timestamp() -> u64 { + let start = SystemTime::now(); + let since_the_epoch = start + .duration_since(std::time::UNIX_EPOCH) + .expect("Time went backwards"); + + since_the_epoch.as_secs() +} + +/** Generate a valid OAuth Authorization header */ +fn oauth_header( + method: &reqwest::Method, + url: &reqwest::Url, + consumer: &Token, + access: Option<&Token>, + verifier: Option<&str>, +) -> String { + // Per request value + let oauth_nonce = oauth_nonce(); + let oauth_timestamp = oauth_timestamp().to_string(); + + // Base string + let base_string = { + let oauth_data = { + let mut oauth_query: Vec<(&str, &str)> = vec![ + ("oauth_consumer_key", &consumer.key), + ("oauth_nonce", &oauth_nonce), + ("oauth_signature_method", "HMAC-SHA1"), + ("oauth_timestamp", &oauth_timestamp), + ]; + if let Some(token) = &access { + oauth_query.push(("oauth_token", &token.key)); + } + if let Some(verifier) = &verifier { + oauth_query.push(("oauth_verifier", verifier)); + } + oauth_query.push(("oauth_version", "1.0")); + let mut all_query: Vec<_> = oauth_query + .into_iter() + .map(|(a, b)| (Cow::Borrowed(a), Cow::Borrowed(b))) + .chain(url.query_pairs()) + .collect(); + all_query.sort_unstable(); + + let mut tmp: form_urlencoded::Serializer<'_, String> = + form_urlencoded::Serializer::new(String::new()); + for (k, v) in all_query { + tmp.append_pair(&k, &v); + } + tmp.finish() + }; + let mut stripped = url.clone(); + stripped.set_query(None); + form_urlencoded::Serializer::new(String::new()) + .append_key_only(method.as_str()) + .append_key_only(stripped.as_str()) + .append_key_only(&oauth_data) + .finish() + }; + + // Signature + let key = { + let mut buf = consumer.secret.clone(); + buf.push('&'); + if let Some(token) = access { + buf.push_str(&token.secret); + } + buf + }; + let signature = Hmac::<Sha1>::new_from_slice(key.as_bytes()) + .expect("HMAC can take key of any size") + .chain_update(base_string.as_bytes()) + .finalize() + .into_bytes(); + let signature_encoded = BASE64_STANDARD.encode(signature); + + // Authorization header + { + let mut buf = "OAuth ".to_string(); + let mut append = |key: &str, value: &str| { + buf.push_str(key); + buf.push_str("=\""); + for part in percent_encoding::percent_encode(value.as_bytes(), NON_ALPHANUMERIC) { + buf.push_str(part); + } + buf.push_str("\","); + }; + append("oauth_consumer_key", &consumer.key); + append("oauth_nonce", &oauth_nonce); + append("oauth_signature_method", "HMAC-SHA1"); + append("oauth_timestamp", &oauth_timestamp); + if let Some(token) = &access { + append("oauth_token", &token.key); + } + if let Some(verifier) = &verifier { + append("oauth_verifier", verifier); + } + append("oauth_version", "1.0"); + append("oauth_signature", &signature_encoded); + buf + } +} + +/** Perform OAuth on an HTTP request */ +pub fn oauth(req: &mut Request, consumer: &Token, access: Option<&Token>, verifier: Option<&str>) { + let header = oauth_header(req.method(), req.url(), consumer, access, verifier); + req.headers_mut() + .append("Authorization", HeaderValue::from_str(&header).unwrap()); +} diff --git a/taler-magnet-bank/src/magnet_api/types.rs b/taler-magnet-bank/src/magnet_api/types.rs @@ -0,0 +1,265 @@ +/* + 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 jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use taler_common::types::amount; + +use crate::HuIban; + +#[derive(Debug, Deserialize)] +pub struct Consumer { + #[serde(rename = "consumerKey")] + pub key: String, + #[serde(rename = "megnevezes")] + pub name: String, + #[serde(rename = "callbackUri")] + pub callback_uri: String, + #[serde(rename = "elettartam")] + pub lifetime: u64, +} + +#[derive(Debug, Deserialize)] +pub struct TokenInfo { + #[serde(rename = "keszult")] + pub created: jiff::Timestamp, + #[serde(rename = "lejarat")] + pub expiration: jiff::Timestamp, + #[serde(rename = "kliensinfo")] + pub client_info: Option<String>, + pub consumer: Consumer, + #[serde(rename = "hitelesitett")] + pub authenticated: bool, +} + +#[derive(Debug, Deserialize)] +pub struct SmsCodeSubmission { + #[serde(rename = "csatorna")] + pub channel: String, + #[serde(rename = "hovaMentKi")] + pub sent_to: Vec<String>, +} + +#[derive(Debug, Deserialize)] +pub struct ScaResult { + #[serde(rename = "csatorna")] + pub channel: String, + #[serde(rename = "hovaMentKi")] + pub sent_to: Vec<String>, +} + +#[derive(Debug, Deserialize)] +pub struct Partner { + #[serde(rename = "megnevezes")] + pub name: String, + #[serde(rename = "kod")] + pub code: u64, + #[serde(rename = "adoszam")] + pub tax_number: Option<String>, + #[serde(rename = "ebUfallapot")] + pub status: String, // TODO enum +} + +#[derive(Debug, Deserialize)] +pub struct AccountType { + #[serde(rename = "kod")] + pub code: u64, + #[serde(rename = "megnevezes")] + pub name: String, +} + +#[derive(Debug, Deserialize)] +pub struct Currency { + #[serde(rename = "jel")] + pub symbol: amount::Currency, + #[serde(rename = "megnevezes")] + pub name: String, +} + +#[derive(Debug, Deserialize)] +pub struct Account { + #[serde(rename = "alapertelmezett")] + pub default: bool, + #[serde(rename = "bankszamlaTipus")] + pub ty: AccountType, + #[serde(rename = "deviza")] + pub currency: Currency, + #[serde(rename = "ibanSzamlaszam")] + pub iban: HuIban, + #[serde(rename = "kod")] + pub code: u64, + #[serde(rename = "szamlaszam")] + pub number: String, + #[serde(rename = "tulajdonosKod")] + pub owner_code: u64, + #[serde(rename = "lakossagi")] + pub resident: bool, + #[serde(rename = "megnevezes")] + pub name: Option<String>, + pub partner: Partner, +} + +#[derive(Debug, Deserialize)] +pub struct BalanceMini { + #[serde(rename = "pozicio")] + pub balance: f64, + #[serde(rename = "frissites")] + pub last_update_time: jiff::Timestamp, +} + +#[derive(Debug, Deserialize)] +pub struct PartnerAccounts { + pub partner: Partner, + #[serde(rename = "bankszamlaList")] + pub bank_accounts: Vec<Account>, + #[serde(rename = "kertJogosultsag")] + pub requested_permission: u64, +} + +#[derive(Debug, Deserialize)] +pub struct PartnerList { + #[serde(rename = "partnerSzamlaList")] + pub partners: Vec<PartnerAccounts>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TxStatus { + #[serde(rename = "G")] + ToBeRecorded, + #[serde(rename = "1")] + PendingFirstSignature, + #[serde(rename = "2")] + PendingSecondSignature, + #[serde(rename = "F")] + PendingProcessing, + #[serde(rename = "L")] + Verified, + #[serde(rename = "R")] + PartiallyCompleted, + #[serde(rename = "T")] + Completed, + #[serde(rename = "E")] + Rejected, + #[serde(rename = "M")] + Canceled, + #[serde(rename = "P")] + UnderReview, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Direction { + #[serde(rename = "T")] + Outgoing, + #[serde(rename = "J")] + Incoming, + #[serde(rename = "M")] + Both, +} + +#[derive(Debug, Deserialize)] +pub struct Tx { + #[serde(rename = "alairas1idopont")] + pub first_signature: Option<Timestamp>, + #[serde(rename = "alairas2idopont")] + pub second_signature: Option<Timestamp>, + #[serde(rename = "alairo1")] + pub first_signatory: Option<Partner>, + #[serde(rename = "alairo2")] + pub second_signatory: Option<Partner>, + #[serde(rename = "reszteljesites")] + pub partial_execution: bool, + #[serde(rename = "sorbaallitas")] + pub queued: bool, + #[serde(rename = "kod")] + pub code: u64, + #[serde(rename = "bankszamla")] + pub bank_account: Account, + #[serde(rename = "deviza")] + pub currency: Currency, + #[serde(rename = "osszegSigned")] + pub amount: f64, + #[serde(rename = "kozlemeny")] + pub subject: String, + #[serde(rename = "statusz")] + pub status: TxStatus, + #[serde(rename = "tranzakcioAltipus")] + pub kind: Option<String>, + #[serde(rename = "eredetiErteknap")] + pub tx_date: jiff::civil::Date, + #[serde(rename = "erteknap")] + pub value_date: jiff::civil::Date, + #[serde(rename = "eszamla")] + pub counter_account: String, + #[serde(rename = "epartner")] + pub counter_name: String, + #[serde(rename = "tranzakcioTipus")] + pub ty: Option<String>, + pub eam: Option<u64>, + pub partner: Partner, +} + +#[derive(Debug, Deserialize)] +pub struct Transaction { + #[serde(rename = "kod")] + pub code: u64, + #[serde(rename = "bankszamla")] + pub bank_account: String, + #[serde(rename = "bankszamlaTulajdonos")] + pub bank_account_owner: String, + #[serde(rename = "deviza")] + pub currency: amount::Currency, + #[serde(rename = "osszeg")] + pub amount: f64, + #[serde(rename = "kozlemeny")] + pub subject: String, + #[serde(rename = "statusz")] + pub status: TxStatus, + #[serde(rename = "tranzakcioAltipus")] + pub kind: Option<String>, + #[serde(rename = "eredetiErteknap")] + pub tx_date: jiff::civil::Date, + #[serde(rename = "erteknap")] + pub value_date: jiff::civil::Date, + #[serde(rename = "eszamla")] + pub counter_account: String, + #[serde(rename = "epartner")] + pub counter_name: String, + #[serde(rename = "tranzakcioTipus")] + pub ty: Option<String>, + pub eam: Option<u64>, +} + +#[derive(Debug, Deserialize)] +pub struct Next { + #[serde(rename = "next")] + pub next_id: u64, + #[serde(rename = "nextTipus")] + pub next_type: String, +} + +#[derive(Debug, Deserialize)] +pub struct TransactionPage { + #[serde(flatten)] + pub next: Option<Next>, + #[serde(rename = "tranzakcioList", default)] + pub list: Vec<TransactionWrapper>, +} + +#[derive(Debug, Deserialize)] +pub struct TransactionWrapper { + #[serde(rename = "tranzakcioDto")] + pub tx: Transaction, +} diff --git a/taler-magnet-bank/src/main.rs b/taler-magnet-bank/src/main.rs @@ -30,7 +30,7 @@ use taler_magnet_bank::{ api::MagnetApi, config::{ServeCfg, WorkerCfg, parse_db_cfg}, dev::{self, DevCmd}, - magnet::AuthClient, + magnet_api::client::AuthClient, setup, worker::Worker, }; diff --git a/taler-magnet-bank/src/setup.rs b/taler-magnet-bank/src/setup.rs @@ -20,12 +20,10 @@ use p256::ecdsa::SigningKey; use taler_common::{json_file, types::base32::Base32}; use tracing::{info, warn}; +use crate::magnet_api::{api::ErrKind, client::AuthClient, oauth::Token}; use crate::{ config::WorkerCfg, - magnet::{ - AuthClient, Token, TokenAuth, - error::{ApiError, MagnetError}, - }, + magnet_api::{api::MagnetError, oauth::TokenAuth}, }; #[derive(Default, Debug, serde::Deserialize, serde::Serialize)] @@ -94,7 +92,10 @@ pub async fn setup(cfg: WorkerCfg, reset: bool) -> anyhow::Result<()> { // TODO Ask MagnetBank if they could support out-of-band configuration println!( "Login at {}?oauth_token={}", - client.join("/NetBankOAuth/authtoken.xhtml"), + client + .api_url + .join("/NetBankOAuth/authtoken.xhtml") + .unwrap(), token_request.key ); let auth_url = passterm::prompt_password_tty(Some("Enter the result URL>"))?; @@ -121,7 +122,7 @@ pub async fn setup(cfg: WorkerCfg, reset: bool) -> anyhow::Result<()> { 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") + if !matches!(e.kind, ErrKind::Magnet(MagnetError { ref short_message, .. }) if short_message == "TOKEN_SCA_HITELESITETT") { return Err(e.into()); } @@ -142,7 +143,7 @@ pub async fn setup(cfg: WorkerCfg, reset: bool) -> anyhow::Result<()> { }; 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") + if !matches!(e.kind, ErrKind::Magnet(MagnetError { ref short_message, .. }) if short_message== "KULCS_MAR_HASZNALATBAN") { return Err(e.into()); } diff --git a/taler-magnet-bank/src/worker.rs b/taler-magnet-bank/src/worker.rs @@ -32,9 +32,10 @@ use crate::{ config::AccountType, db::{self, AddIncomingResult, Initiated, TxIn, TxOut, TxOutKind}, failure_injection::{InjectedErr, fail_point}, - magnet::{ - ApiClient, Direction, Transaction, TxStatus, - error::{ApiError, MagnetError}, + magnet_api::{ + api::{ApiErr, ErrKind}, + client::ApiClient, + types::{Direction, Transaction, TxStatus}, }, }; @@ -43,7 +44,7 @@ pub enum WorkerError { #[error(transparent)] Db(#[from] sqlx::Error), #[error(transparent)] - Api(#[from] ApiError), + Api(#[from] ApiErr), #[error(transparent)] Injected(#[from] InjectedErr), } @@ -307,33 +308,31 @@ impl Worker<'_> { .await?; info } - // Check if error is permanent - Err(ApiError::Magnet(MagnetError { - error_code, - short_message, - long_message, - })) if matches!( - (error_code, short_message.as_str()), - (404, "BSZLA_NEM_TALALHATO") // Unknown account - | (409, "FORRAS_SZAMLA_ESZAMLA_EGYEZIK") // Same account - ) => - { - let e = MagnetError { - error_code, - short_message, - long_message, - }; - db::initiated_submit_permanent_failure( - &mut *self.db, - tx.id, - &Timestamp::now(), - &e.to_string(), - ) - .await?; - error!(target: "worker", "initiated tx {tx} failed: {e}"); - return WorkerResult::Ok(()); + Err(e) => { + if let ApiErr { + kind: ErrKind::Magnet(e), + .. + } = &e + { + // Check if error is permanent + if matches!( + (e.error_code, e.short_message.as_str()), + (404, "BSZLA_NEM_TALALHATO") // Unknown account + | (409, "FORRAS_SZAMLA_ESZAMLA_EGYEZIK") // Same account + ) { + db::initiated_submit_permanent_failure( + &mut *self.db, + tx.id, + &Timestamp::now(), + &e.to_string(), + ) + .await?; + error!(target: "worker", "initiated tx {tx} failed: {e}"); + return WorkerResult::Ok(()); + } + } + return Err(e.into()); } - Err(e) => return WorkerResult::Err(WorkerError::Api(e)), }; trace!(target: "worker", "init tx {}", info.code); @@ -353,8 +352,9 @@ impl Worker<'_> { ) -> WorkerResult { debug!(target: "worker", "submit tx {tx_code}"); fail_point("submit-tx")?; - // Sign initiated transaction, on failure we will retry - self.client + // Submit an initiated transaction, on failure we will retry + match self + .client .submit_tx( self.key, self.account_number, @@ -363,8 +363,27 @@ impl Worker<'_> { date, creditor, ) - .await?; - Ok(()) + .await + { + Ok(_) => Ok(()), + Err(e) => { + if let ApiErr { + kind: ErrKind::Magnet(e), + .. + } = &e + { + // Check if soft failure + if matches!( + (e.error_code, e.short_message.as_str()), + (409, "TRANZAKCIO_ROSSZ_STATUS") // Already summited or cannot be signed + ) { + warn!(target: "worker", "submit tx {tx_code}: {e}"); + return Ok(()); + } + } + Err(e.into()) + } + } } }