libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 5c66545f2ce01c7eb25dbb091871334b406f2687
parent e5ba71f533773395048627e4f45c9c46dfff4407
Author: Antoine A <>
Date:   Thu, 30 Apr 2026 20:36:37 +0200

bank: start implementing token & account logic

Diffstat:
MCargo.lock | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 2+-
Acrates/libeufin-bank/Cargo.toml | 37+++++++++++++++++++++++++++++++++++++
Acrates/libeufin-bank/src/api.rs | 294+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-bank/src/api/account.rs | 444+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-bank/src/api/token.rs | 291++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-bank/src/auth.rs | 306+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-bank/src/config.rs | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-bank/src/db.rs | 23+++++++++++++++++++++++
Acrates/libeufin-bank/src/db/account.rs | 298+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-bank/src/db/token.rs | 244+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-bank/src/lib.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-bank/src/payto.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-bank/src/pw.rs | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-ebics/src/test.rs | 2+-
Mcrates/libeufin-nexus/Cargo.toml | 1+
Mcrates/libeufin-nexus/src/config.rs | 14++++++--------
Mcrates/libeufin-nexus/src/db.rs | 39++++-----------------------------------
Mcrates/libeufin-nexus/src/db/payment.rs | 12++++++------
Mcrates/libeufin-nexus/src/fetch.rs | 4++--
Mcrates/libeufin-nexus/src/test.rs | 2+-
21 files changed, 2622 insertions(+), 54 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -276,6 +276,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] +name = "bcrypt" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523ab528ce3a7ada6597f8ccf5bd8d85ebe26d5edf311cad4d1d3cfb2d357ac6" +dependencies = [ + "base64", + "blowfish", + "getrandom 0.4.2", + "subtle", + "zeroize", +] + +[[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -294,6 +307,16 @@ dependencies = [ ] [[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -385,6 +408,16 @@ dependencies = [ ] [[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] name = "clap" version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1428,6 +1461,15 @@ dependencies = [ ] [[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1597,6 +1639,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] +name = "libeufin-bank" +version = "1.5.0" +dependencies = [ + "anyhow", + "aws-lc-rs", + "axum", + "bcrypt", + "clap", + "compact_str", + "const_format", + "getrandom 0.4.2", + "jiff", + "libeufin-ebics", + "owo-colors", + "reedline", + "regex", + "serde", + "serde_json", + "shlex", + "sqlx", + "taler-api", + "taler-build", + "taler-common", + "taler-macros", + "taler-test-utils", + "tokio", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] name = "libeufin-ebics" version = "1.5.0" dependencies = [ @@ -1653,6 +1728,7 @@ dependencies = [ "taler-api", "taler-build", "taler-common", + "taler-macros", "taler-test-utils", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml @@ -11,7 +11,7 @@ repository = "https://git.taler.net/libeufin.git" license-file = "COPYING" [workspace.dependencies] -axum = { version = "0.8", features = ["ws"] } +axum = { version = "0.8", features = ["ws", "macros"] } tracing = "0.1" thiserror = "2" anyhow = "1.0" diff --git a/crates/libeufin-bank/Cargo.toml b/crates/libeufin-bank/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "libeufin-bank" +version.workspace = true +edition.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license-file.workspace = true + +[dependencies] +libeufin-ebics.workspace = true +tokio.workspace = true +tracing.workspace = true +anyhow.workspace = true +jiff.workspace = true +serde_json.workspace = true +taler-common.workspace = true +taler-api.workspace = true +taler-build.workspace = true +taler-test-utils.workspace = true +taler-macros.workspace = true +clap.workspace = true +aws-lc-rs.workspace = true +serde.workspace = true +sqlx.workspace = true +compact_str.workspace = true +uuid.workspace = true +getrandom.workspace = true +axum.workspace = true +url = "2.5" +reedline = "0.47" +regex = "1.12" +const_format = { version = "0.2", features = ["rust_1_83"] } +tracing-subscriber = "0.3" +owo-colors = "4.3" +shlex = "1.3" +bcrypt = "0.19.0" diff --git a/crates/libeufin-bank/src/api.rs b/crates/libeufin-bank/src/api.rs @@ -0,0 +1,294 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* LibEuFin 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. + +* LibEuFin 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 LibEuFin; see the file COPYING. If not, see +* <http://www.gnu.org/licenses/> +*/ + +use sqlx::PgPool; + +use crate::config::BankCfg; + +pub mod account; +pub mod token; + +pub struct BankState { + pub db: PgPool, + pub cfg: BankCfg, +} + +#[cfg(test)] +pub mod test { + use std::{cell::RefCell, collections::BTreeMap, ops::Deref, sync::Arc}; + + use axum::{ + Router, + http::{Method, header::AUTHORIZATION}, + }; + use compact_str::CompactString; + use sqlx::{PgPool, Postgres, pool::PoolConnection}; + use taler_api::api::TalerRouter; + use taler_common::{ + config::Config, + types::{ + amount::{Amount, Decimal}, + payto::{IbanPayto, Payto}, + }, + }; + use taler_test_utils::{ + db::db_test_setup, + json, + server::{TestRequest, TestServer as _}, + }; + + use crate::{ + CONFIG_SOURCE, + api::{ + BankState, + account::{account_api, create_admin_account, rand_iban_payto}, + token::token_api, + }, + config::BankCfg, + db::{self, account::AccountCreationResult}, + payto::LibeufinId, + }; + + pub struct BankTestCtx { + pub merchant_payto: IbanPayto, + pub exchange_payto: IbanPayto, + pub customer_payto: IbanPayto, + pub unknown_payto: IbanPayto, + pub tmp_payto: IbanPayto, + pub admin_payto: IbanPayto, + pub db: PgPool, + pub server: Router, + tokens: RefCell<BTreeMap<CompactString, String>>, + } + + impl BankTestCtx { + fn pw_auth(req: TestRequest, username: Option<&str>) -> TestRequest { + let username: CompactString = username + .unwrap_or_else(|| Self::extract_username(req.url.path())) + .into(); + req.basic_auth(&username, &format!("{username}-password")) + } + + fn extract_username(path: &str) -> &str { + if path.contains("admin") { + return "admin"; + } else { + path.split('/').nth(2).unwrap() + } + } + + async fn cached_token(&self, username: &str) -> String { + if !self.tokens.borrow().contains_key(username) { + // Create new token + let res = Self::pw_auth( + self.server.post(&format!("/accounts/{username}/token")), + Some(username), + ) + .json(json!({ + "scope": "readwrite", + "duration": { + "d_us": "forever" + } + })) + .await + .assert_ok_json::<serde_json::Value>(); + let token = res["access_token"].as_str().unwrap(); + self.tokens + .borrow_mut() + .insert(username.into(), format!("Bearer {token}")); + } + self.tokens.borrow()[username].clone() + } + + async fn token_auth_request( + &self, + method: Method, + path: &str, + username: Option<&str>, + ) -> TestRequest { + let username = username.unwrap_or_else(|| Self::extract_username(path)); + let token = self.cached_token(username).await; + self.server + .method(method, path) + .header(AUTHORIZATION, token) + } + + pub async fn postpw(&self, path: &str) -> TestRequest { + Self::pw_auth(self.server.method(Method::POST, path), None) + } + + pub async fn geta(&self, path: &str) -> TestRequest { + self.token_auth_request(Method::GET, path, None).await + } + + pub async fn posta(&self, path: &str) -> TestRequest { + self.token_auth_request(Method::POST, path, None).await + } + + pub async fn patcha(&self, path: &str) -> TestRequest { + self.token_auth_request(Method::PATCH, path, None).await + } + + pub async fn deletea(&self, path: &str) -> TestRequest { + self.token_auth_request(Method::DELETE, path, None).await + } + + pub async fn get_admin(&self, path: &str) -> TestRequest { + self.token_auth_request(Method::GET, path, Some("admin")) + .await + } + + pub async fn post_admin(&self, path: &str) -> TestRequest { + self.token_auth_request(Method::POST, path, Some("admin")) + .await + } + + pub async fn patch_admin(&self, path: &str) -> TestRequest { + self.token_auth_request(Method::PATCH, path, Some("admin")) + .await + } + + pub async fn delete_admin(&self, path: &str) -> TestRequest { + self.token_auth_request(Method::DELETE, path, Some("admin")) + .await + } + } + + impl Deref for BankTestCtx { + type Target = Router; + + fn deref(&self) -> &Self::Target { + &self.server + } + } + + pub async fn db_setup() -> (PoolConnection<Postgres>, PgPool) { + db_test_setup(CONFIG_SOURCE).await + } + + pub async fn bank_setup_conf(conf: &str) -> BankTestCtx { + let (_, db) = db_setup().await; + let cfg = Config::from_file( + CONFIG_SOURCE, + Some(format!("../../libeufin-bank/conf/{conf}")), + ) + .unwrap(); + + let state = Arc::new(BankState { + db: db.clone(), + cfg: BankCfg::parse(cfg).unwrap(), + }); + + let server = token_api() + .merge(account_api()) + .with_state(state.clone()) + .finalize(); + + let merchant_payto = rand_iban_payto(); + let exchange_payto = rand_iban_payto(); + let customer_payto = rand_iban_payto(); + let unknown_payto = rand_iban_payto(); + let tmp_payto = rand_iban_payto(); + + db::account::create( + &db, + &state.cfg.ctx, + &state.cfg.pw_crypto, + "merchant", + "merchant-password", + "Merchant", + None, + None, + None, + LibeufinId::IBAN(merchant_payto.clone().into_inner()), + false, + false, + Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), + Amount::zero(&state.cfg.regional_currency), + &[], + false, + None, + ) + .await + .unwrap(); + db::account::create( + &db, + &state.cfg.ctx, + &state.cfg.pw_crypto, + "exchange", + "exchange-password", + "Exchange", + None, + None, + None, + LibeufinId::IBAN(exchange_payto.clone().into_inner()), + false, + false, + Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), + Amount::zero(&state.cfg.regional_currency), + &[], + false, + None, + ) + .await + .unwrap(); + db::account::create( + &db, + &state.cfg.ctx, + &state.cfg.pw_crypto, + "customer", + "customer-password", + "Customer", + None, + None, + None, + LibeufinId::IBAN(customer_payto.clone().into_inner()), + false, + false, + Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), + Amount::zero(&state.cfg.regional_currency), + &[], + false, + None, + ) + .await + .unwrap(); + + let res = create_admin_account(&db, &state.cfg, Some("admin-password")) + .await + .unwrap(); + + let admin_payto = match res { + AccountCreationResult::Success(payto) => payto, + _ => unreachable!(), + }; + + BankTestCtx { + merchant_payto, + exchange_payto, + customer_payto, + unknown_payto, + tmp_payto, + admin_payto: Payto::new(admin_payto.into_inner().expect_iban().unwrap().clone()), + server, + db, + tokens: RefCell::new(BTreeMap::new()), + } + } +} diff --git a/crates/libeufin-bank/src/api/account.rs b/crates/libeufin-bank/src/api/account.rs @@ -0,0 +1,444 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* LibEuFin 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. + +* LibEuFin 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 LibEuFin; see the file COPYING. If not, see +* <http://www.gnu.org/licenses/> +*/ + +use std::sync::{Arc, LazyLock}; + +use axum::{Json, Router, extract::State, routing::post}; +use compact_str::CompactString; +use regex::Regex; +use sqlx::PgPool; +use taler_api::{ + error::{ApiResult, failure, failure_code}, + extract::Req, +}; +use taler_common::{ + error_code::ErrorCode::{self, GENERIC_JSON_INVALID}, + types::{ + amount::Amount, + base32::Base32, + iban::{Country, IBAN}, + payto::{BankID, IbanPayto, Payto}, + }, +}; + +use crate::{ + TanChannel, + api::BankState, + auth::RegistrationAuth, + config::{BankCfg, WireMethod}, + db::account::AccountCreationResult, + payto::{FullBankPayto, LibeufinId, XTalerBank}, + pw::checkpw, +}; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ChallengeContactData { + pub email: Option<CompactString>, + pub phone: Option<CompactString>, +} + +impl ChallengeContactData { + pub fn validate(&self) -> ApiResult<()> { + static EMAIL_PATTERN: LazyLock<Regex> = LazyLock::new(|| { + Regex::new("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}$").unwrap() + }); + static PHONE_PATTERN: LazyLock<Regex> = + LazyLock::new(|| Regex::new("^\\+?[0-9]+$").unwrap()); + + if let Some(email) = &self.email + && !EMAIL_PATTERN.is_match(email) + { + return Err(failure( + GENERIC_JSON_INVALID, + format_args!("email contact data '{email}' is malformed"), + )); + } + if let Some(phone) = &self.phone + && !PHONE_PATTERN.is_match(phone) + { + return Err(failure( + GENERIC_JSON_INVALID, + format_args!("phone contact data '{phone}' is malformed"), + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct RegisterAccountRequest { + pub username: CompactString, + pub password: CompactString, + pub name: CompactString, + #[serde(default)] + pub is_public: bool, + #[serde(default)] + pub is_taler_exchange: bool, + pub contact_data: Option<ChallengeContactData>, + pub cashout_payto_uri: Option<IbanPayto>, + pub payto_uri: Option<Payto<LibeufinId>>, + pub debit_threshold: Option<Amount>, + pub tan_channel: Option<TanChannel>, + pub tan_channels: Option<Vec<TanChannel>>, + pub conversion_rate_class_id: Option<u64>, +} + +impl RegisterAccountRequest { + pub fn validate(&self) -> ApiResult<()> { + static USERNAME_REGEX: LazyLock<Regex> = + LazyLock::new(|| Regex::new("^[a-zA-Z0-9-._~]{1,126}$").unwrap()); + if !USERNAME_REGEX.is_match(&self.username) { + return Err(failure( + GENERIC_JSON_INVALID, + format_args!( + "username '{}' is malformed, must match [a-zA-Z0-9-._~]{{1,126}}", + self.username + ), + )); + } + if self.tan_channel.is_some() && self.tan_channels.is_some() { + return Err(failure( + GENERIC_JSON_INVALID, + format_args!("you must only use either tan_channel or tan_channels"), + )); + } + if let Some(contact_data) = &self.contact_data { + contact_data.validate()?; + } + Ok(()) + } + + pub fn channels(&self) -> &[TanChannel] { + if let Some(many) = &self.tan_channels { + many + } else if let Some(one) = &self.tan_channel { + std::slice::from_ref(one) + } else { + &[] + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct RegisterAccountResponse { + pub internal_payto_uri: FullBankPayto, +} + +pub fn account_api() -> Router<Arc<BankState>> { + Router::new().route( + "/accounts", + post( + async |_: RegistrationAuth, + State(state): State<Arc<BankState>>, + Req(req): Req<RegisterAccountRequest>| + -> ApiResult<Json<RegisterAccountResponse>> { + match create_account(&state.db, &state.cfg, &req, false).await? { + AccountCreationResult::BonusBalanceInsufficient => { + Err(failure_code(ErrorCode::BANK_UNALLOWED_DEBIT)) + } + AccountCreationResult::UsernameReuse => { + Err(failure_code(ErrorCode::BANK_REGISTER_USERNAME_REUSE)) + } + AccountCreationResult::PayToReuse => { + Err(failure_code(ErrorCode::BANK_REGISTER_PAYTO_URI_REUSE)) + } + AccountCreationResult::UnknownConversionClass => { + todo!() + } + AccountCreationResult::Success(payto) => Ok(Json(RegisterAccountResponse { + internal_payto_uri: payto, + })), + } + }, + ), + ) +} + +pub fn rand_iban_payto() -> IbanPayto { + let iban = IBAN::random(Country::DE); + IbanPayto::new(BankID { iban, bic: None }) +} + +pub async fn create_account( + db: &PgPool, + cfg: &BankCfg, + req: &RegisterAccountRequest, + is_admin: bool, +) -> ApiResult<AccountCreationResult> { + req.validate()?; + + if matches!(req.username.as_str(), "admin" | "bank") { + return Err(failure_code(ErrorCode::BANK_RESERVED_USERNAME_CONFLICT)); + } + + let channels = req.channels(); + + if !is_admin { + if req.debit_threshold.is_some() { + return Err(failure_code(ErrorCode::BANK_NON_ADMIN_PATCH_DEBT_LIMIT)); + } else if req.conversion_rate_class_id.is_some() { + return Err(failure_code( + ErrorCode::BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS, + )); + } else if !channels.is_empty() { + return Err(failure_code(ErrorCode::BANK_NON_ADMIN_SET_TAN_CHANNEL)); + } + } + + for channel in channels { + if !cfg.tan_channels.contains_key(channel) { + return Err(failure( + ErrorCode::BANK_TAN_CHANNEL_NOT_SUPPORTED, + format_args!("unsupported tan channel {channel}"), + )); + } + + let missing_info = match channel { + TanChannel::sms => req.contact_data.as_ref().map(|it| &it.phone).is_some(), + TanChannel::email => req.contact_data.as_ref().map(|it| &it.email).is_some(), + }; + + if missing_info { + return Err(failure( + ErrorCode::BANK_MISSING_TAN_INFO, + format_args!("missing info for tan channel{channel}"), + )); + } + } + + if req.username == "exchange" && !req.is_taler_exchange {} + + checkpw(&req.password, cfg.pwd_check_quality)?; + + let create = async |payto: LibeufinId| { + crate::db::account::create( + db, + &cfg.ctx, + &cfg.pw_crypto, + &req.username, + &req.password, + &req.name, + req.contact_data.as_ref().and_then(|it| it.email.as_deref()), + req.contact_data.as_ref().and_then(|it| it.phone.as_deref()), + req.cashout_payto_uri.as_ref(), + payto, + req.is_public, + req.is_taler_exchange, + req.debit_threshold.unwrap_or(cfg.default_debt_limit), + if req.is_taler_exchange { + Amount::zero(&cfg.regional_currency) + } else { + cfg.registration_bonus + }, + channels, + req.payto_uri.is_some(), + req.conversion_rate_class_id, + ) + .await + }; + + match cfg.wire_method { + WireMethod::iban => { + if let Some(payto) = &req.payto_uri { + let bank_id = payto.expect_iban()?; + Ok(create(LibeufinId::IBAN(bank_id.clone())).await?) + } else { + let mut retry = 5; + loop { + let payto = rand_iban_payto(); + let res = create(LibeufinId::IBAN(payto.into_inner())).await?; + if res == AccountCreationResult::PayToReuse && retry > 0 { + retry -= 1; + continue; + } + return Ok(res); + } + } + } + WireMethod::x_taler_bank => { + if let Some(payto) = &req.payto_uri { + let libeufin_id = payto.expect_xtaler_bank()?; + if libeufin_id.username != req.username { + return Err(failure( + ErrorCode::GENERIC_JSON_INVALID, + format_args!( + "Expected a payto uri for '{}' got one for '{}'", + req.username, libeufin_id.username + ), + )); + } + } + Ok(create(LibeufinId::XTalerBank(XTalerBank { + hostname: cfg.ctx.hostname.clone(), + username: req.username.clone(), + })) + .await?) + } + } +} + +/** + * This function creates the admin account ONLY IF it was + * NOT found in the database. It sets it to a random password that + * is only meant to be overridden by a dedicated CLI tool + */ +pub async fn create_admin_account( + db: &PgPool, + cfg: &BankCfg, + pw: Option<&str>, +) -> anyhow::Result<AccountCreationResult> { + // TODO is this secure enough ? + let pw = pw + .map(|it| it.to_owned()) + .unwrap_or_else(|| Base32::<32>::rand().to_string()); + + let payto = match cfg.wire_method { + WireMethod::iban => LibeufinId::IBAN(rand_iban_payto().into_inner()), + WireMethod::x_taler_bank => LibeufinId::XTalerBank(XTalerBank { + hostname: cfg.ctx.hostname.clone(), + username: CompactString::const_new("admin"), + }), + }; + + Ok(crate::db::account::create( + db, + &cfg.ctx, + &cfg.pw_crypto, + "admin", + &pw, + "Bank administrator", + None, + None, + None, + payto, + false, + false, + cfg.default_debt_limit, + Amount::zero(&cfg.regional_currency), + &[], + false, + None, + ) + .await?) +} + +#[cfg(test)] +pub mod test { + + use taler_common::{error_code::ErrorCode, types::payto::FullPayto}; + use taler_test_utils::{json, server::TestServer as _}; + + use crate::{ + api::{ + account::{RegisterAccountResponse, rand_iban_payto}, + test::bank_setup_conf, + }, + payto::LibeufinId, + }; + + #[tokio::test] + async fn create() { + let ctx = bank_setup_conf("test.conf").await; + + // Check generated payto + { + let body = json!({ + "username": "john", + "password": "password", + "name": "John" + }); + // Check ok + let payto = ctx + .post("/accounts") + .json(&body) + .await + .assert_ok_json::<RegisterAccountResponse>() + .internal_payto_uri; + // Check idempotency + assert_eq!( + payto, + ctx.post("/accounts") + .json(&body) + .await + .assert_ok_json::<RegisterAccountResponse>() + .internal_payto_uri + ); + // Check idempotency with payto + ctx.post("/accounts") + .json(&json!(body + { + "payto_uri": payto + })) + .await + .assert_ok_json::<RegisterAccountResponse>(); + // Check payto conflict + ctx.post("/accounts") + .json(&json!(body + { + "payto_uri": rand_iban_payto() + })) + .await + .assert_error(ErrorCode::BANK_REGISTER_USERNAME_REUSE); + } + + // Check given payto + { + let name = "Jane"; + let payto = rand_iban_payto(); + let body = json!({ + "username": "foo", + "password": "password", + "name": name, + "is_public": true, + "payto_uri": payto, + "is_taler_exchange": true + }); + let full = FullPayto::new(LibeufinId::IBAN(payto.into_inner()), name); + // Check ok + assert_eq!( + full, + ctx.post("/accounts") + .json(&body) + .await + .assert_ok_json::<RegisterAccountResponse>() + .internal_payto_uri + ); + // Check idempotency + assert_eq!( + full, + ctx.post("/accounts") + .json(&body) + .await + .assert_ok_json::<RegisterAccountResponse>() + .internal_payto_uri + ); + } + + // Check admin only debit_threshold + let body = json!({ + "username": "bat", + "password": "password", + "name": "Bat", + "debit_threshold": "KUDOS:42" + }); + ctx.post("/accounts") + .json(&body) + .await + .assert_error(ErrorCode::BANK_NON_ADMIN_PATCH_DEBT_LIMIT); + // TODO check ok admin + } +} diff --git a/crates/libeufin-bank/src/api/token.rs b/crates/libeufin-bank/src/api/token.rs @@ -0,0 +1,291 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* LibEuFin 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. + +* LibEuFin 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 LibEuFin; see the file COPYING. If not, see +* <http://www.gnu.org/licenses/> +*/ + +use std::{sync::Arc, time::Duration}; + +use axum::{Json, Router, extract::State, routing::post}; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use taler_api::{error::failure, extract::Req}; +use taler_common::{ + error_code::ErrorCode::{self}, + types::{ + base32::Base32, + timestamp::{RelativeTime, TalerTimestamp}, + }, +}; + +use crate::{ + api::BankState, + auth::{RefreshAuth, TOKEN_PREFIX, TokenScope}, + db::token::{TokenCreationResult, access, create}, +}; + +pub struct TokenInfo { + pub creation_time: TalerTimestamp, + pub expiration: TalerTimestamp, + pub scope: TokenScope, + pub refreshable: bool, + pub description: Option<String>, + pub last_access: TalerTimestamp, + pub row_id: u64, + pub token_id: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenRequest { + pub scope: TokenScope, + pub duration: Option<RelativeTime>, + pub description: Option<String>, + #[serde(default)] + pub refreshable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenSuccessResponse { + pub access_token: String, + pub expiration: TalerTimestamp, +} + +pub fn token_api() -> Router<Arc<BankState>> { + Router::new().route( + "/accounts/{username}/token", + post( + async |RefreshAuth { username, token }: RefreshAuth, + State(state): State<Arc<BankState>>, + Req(req): Req<TokenRequest>| { + if let Some(token) = token { + // This block checks permissions ONLY IF the call was authenticated with a token + let token = access(&state.db, &token, &Timestamp::now()).await?; + let Some(token) = token else { + return Err(failure( + ErrorCode::BANK_UNMANAGED_EXCEPTION, + "Token used to auth not found in the database", + )); + }; + if !req.scope.logical().is_valid_scope(token.scope, true) { + return Err(failure( + ErrorCode::GENERIC_TOKEN_PERMISSION_INSUFFICIENT, + "Impossible to refresh a token with a larger scope", + )); + } + } + + // TODO secure random + let new = Base32::<32>::rand(); + let creation = Timestamp::now(); + let expiration = match req + .duration + .unwrap_or(RelativeTime::Duration(Duration::from_hours(24))) + { + RelativeTime::Forever => TalerTimestamp::Never, + RelativeTime::Duration(duration) => { + TalerTimestamp::Timestamp(creation + duration) + } + }; + match create( + &state.db, + &username, + new.as_ref(), + &creation, + &expiration, + &req.scope, + req.refreshable, + req.description.as_deref(), + false, + ) + .await? + { + TokenCreationResult::Success => Ok(Json(TokenSuccessResponse { + access_token: format!("{TOKEN_PREFIX}{new}"), + expiration, + })), + TokenCreationResult::TanRequired => todo!(), + } + }, + ), + ) +} + +#[cfg(test)] +pub mod test { + + use std::str::FromStr; + + use axum::http::header::AUTHORIZATION; + use jiff::{SignedDuration, Timestamp}; + use taler_common::{ + error_code::ErrorCode, + types::{base32::Base32, timestamp::TalerTimestamp}, + }; + use taler_test_utils::{json, server::TestServer}; + + use crate::{ + api::{test::bank_setup_conf, token::TokenSuccessResponse}, + auth::TOKEN_PREFIX, + db::token::access, + }; + + #[tokio::test] + async fn create() { + let ctx = bank_setup_conf("test.conf").await; + + // TODO auth routine + + // Unknown account + ctx.post("/accounts/merchant/token") + .basic_auth("Unknown", "password") + .await + .assert_error(ErrorCode::GENERIC_UNAUTHORIZED); + + // Wrong account + ctx.post("/accounts/merchant/token") + .basic_auth("merchant", "wrong-password") + .await + .assert_error(ErrorCode::GENERIC_UNAUTHORIZED); + + // Wrong account + ctx.post("/accounts/merchant/token") + .basic_auth("exchange", "wrong-password") + .await + .assert_error(ErrorCode::GENERIC_UNAUTHORIZED); + + // Default token duration + let res: TokenSuccessResponse = ctx + .postpw("/accounts/merchant/token") + .await + .json(json!({ "scope": "readonly" })) + .await + .assert_ok_json(); + // Checking that the token lifetime defaulted to 24 hours + let token = access( + &ctx.db, + Base32::<32>::from_str(res.access_token.strip_prefix(TOKEN_PREFIX).unwrap()) + .unwrap() + .as_ref(), + &Timestamp::now(), + ) + .await + .unwrap() + .unwrap(); + let lifetime = token.creation.duration_until(token.expiration); + assert_eq!(lifetime, SignedDuration::from_hours(24)); + + // Check valid refresh scope + for (from_scope, to_scope) in [ + ("readwrite", "readwrite"), + ("readonly", "readonly"), + ("revenue", "revenue"), + ("readwrite", "readonly"), + ("readwrite", "revenue"), + ("readonly", "revenue"), + ] { + let res: TokenSuccessResponse = ctx + .postpw("/accounts/merchant/token") + .await + .json(json!({ "scope": from_scope, "refreshable": true })) + .await + .assert_ok_json(); + ctx.post("/accounts/merchant/token") + .header(AUTHORIZATION, format!("Bearer {}", res.access_token)) + .json(json!({ "scope": to_scope })) + .await + .assert_ok(); + } + + // Check invalid refresh scope + for (from_scope, to_scope) in [ + ("readonly", "readwrite"), + ("revenue", "readonly"), + ("revenue", "readwrite"), + ] { + let res: TokenSuccessResponse = ctx + .postpw("/accounts/merchant/token") + .await + .json(json!({ "scope": from_scope, "refreshable": true })) + .await + .assert_ok_json(); + ctx.post("/accounts/merchant/token") + .header(AUTHORIZATION, format!("Bearer {}", res.access_token)) + .json(json!({ "scope": to_scope })) + .await + .assert_error(ErrorCode::GENERIC_TOKEN_PERMISSION_INSUFFICIENT); + } + + // Check no refreshable + let res: TokenSuccessResponse = ctx + .postpw("/accounts/merchant/token") + .await + .json(json!({ "scope": "readonly" })) + .await + .assert_ok_json(); + ctx.post("/accounts/merchant/token") + .header(AUTHORIZATION, format!("Bearer {}", res.access_token)) + .json(json!({ "scope": "readonly" })) + .await + .assert_error(ErrorCode::GENERIC_TOKEN_PERMISSION_INSUFFICIENT); + + // Check 'forever' case + let res: TokenSuccessResponse = ctx + .postpw("/accounts/merchant/token") + .await + .json(json!({ + "scope": "readonly", + "duration": { + "d_us": "forever" + } + })) + .await + .assert_ok_json(); + assert_eq!(res.expiration, TalerTimestamp::Never); + + // Check too big or invalid durations + ctx.postpw("/accounts/merchant/token") + .await + .json(json!({ + "scope": "readonly", + "duration": { + "d_us": "invalid" + } + })) + .await + .assert_error(ErrorCode::GENERIC_JSON_INVALID); + + ctx.postpw("/accounts/merchant/token") + .await + .json(json!({ + "scope": "readonly", + "duration": { + "d_us": i64::MAX + } + })) + .await + .assert_error(ErrorCode::GENERIC_JSON_INVALID); + ctx.postpw("/accounts/merchant/token") + .await + .json(json!({ + "scope": "readonly", + "duration": { + "d_us": -1 + } + })) + .await + .assert_error(ErrorCode::GENERIC_JSON_INVALID); + } +} diff --git a/crates/libeufin-bank/src/auth.rs b/crates/libeufin-bank/src/auth.rs @@ -0,0 +1,306 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* LibEuFin 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. + +* LibEuFin 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 LibEuFin; see the file COPYING. If not, see +* <http://www.gnu.org/licenses/> +*/ + +use std::{fmt::Display, sync::Arc}; + +use axum::{ + RequestPartsExt as _, + extract::{FromRequestParts, Path}, + http::{ + HeaderValue, + header::{AUTHORIZATION, WWW_AUTHENTICATE}, + request::Parts, + }, +}; +use compact_str::CompactString; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use taler_api::error::{ApiError, ApiResult, failure, failure_code, unauthorized}; +use taler_common::{ + encoding::{base32, base64}, + error_code::ErrorCode, +}; +use tracing::warn; + +use crate::{ + api::BankState, + db::{ + account::{BankInfo, CheckPasswordResult, check_password}, + token::access_info, + }, + payto::BankCtx, + pw::PwCrypto, +}; + +pub const TOKEN_PREFIX: &str = "secret-token:"; + +#[derive(Clone, PartialEq)] +#[allow(non_camel_case_types)] +pub enum TokenLogicalScope { + readonly, + readwrite, + revenue, + refreshable, + readonly_wiregateway, + readwrite_wiregateway, + observability, +} + +impl TokenLogicalScope { + pub fn is_valid_scope(&self, scope: TokenScope, refreshable: bool) -> bool { + match self { + TokenLogicalScope::readonly => { + matches!(scope, TokenScope::readonly | TokenScope::readwrite) + } + TokenLogicalScope::readwrite => matches!(scope, TokenScope::readwrite), + TokenLogicalScope::revenue => matches!( + scope, + TokenScope::readonly | TokenScope::readwrite | TokenScope::revenue + ), + TokenLogicalScope::refreshable => refreshable, + TokenLogicalScope::readonly_wiregateway => matches!( + scope, + TokenScope::readonly | TokenScope::readwrite | TokenScope::wiregateway + ), + TokenLogicalScope::readwrite_wiregateway => { + matches!(scope, TokenScope::readwrite | TokenScope::wiregateway) + } + TokenLogicalScope::observability => matches!( + scope, + TokenScope::readonly | TokenScope::readwrite | TokenScope::observability + ), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)] +#[sqlx(type_name = "token_scope_enum")] +#[allow(non_camel_case_types)] +pub enum TokenScope { + readonly, + readwrite, + revenue, + wiregateway, + observability, +} + +impl TokenScope { + pub fn logical(&self) -> TokenLogicalScope { + match self { + TokenScope::readonly => TokenLogicalScope::readonly, + TokenScope::readwrite => TokenLogicalScope::readwrite, + TokenScope::revenue => TokenLogicalScope::revenue, + TokenScope::wiregateway => TokenLogicalScope::readwrite_wiregateway, + TokenScope::observability => TokenLogicalScope::observability, + } + } +} + +pub struct RegistrationAuth; + +impl FromRequestParts<Arc<BankState>> for RegistrationAuth { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc<BankState>, + ) -> Result<Self, Self::Rejection> { + if state.cfg.allow_registration { + return Ok(RegistrationAuth); + } + let (info, _) = auth_request( + &state.db, + &state.cfg.ctx, + &state.cfg.pw_crypto, + TokenLogicalScope::readwrite, + false, + state.cfg.basic_auth_compat, + parts, + ) + .await?; + if info.is_admin() { + return Err(failure( + ErrorCode::GENERIC_FORBIDDEN, + "Only administrator allowed", + )); + } + Ok(RegistrationAuth) + } +} + +pub struct RefreshAuth { + pub username: CompactString, + pub token: Option<Vec<u8>>, +} + +impl FromRequestParts<Arc<BankState>> for RefreshAuth { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc<BankState>, + ) -> Result<Self, Self::Rejection> { + let Path(username): Path<CompactString> = parts.extract().await?; + let (info, token) = auth_request( + &state.db, + &state.cfg.ctx, + &state.cfg.pw_crypto, + TokenLogicalScope::refreshable, + true, + state.cfg.basic_auth_compat, + parts, + ) + .await?; + if info.is_admin() { + return Err(failure( + ErrorCode::GENERIC_FORBIDDEN, + "Only administrator allowed", + )); + } + Ok(RefreshAuth { username, token }) + } +} + +pub struct AdminRWAuth(BankInfo); + +impl FromRequestParts<Arc<BankState>> for AdminRWAuth { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc<BankState>, + ) -> Result<Self, Self::Rejection> { + let (info, _) = auth_request( + &state.db, + &state.cfg.ctx, + &state.cfg.pw_crypto, + TokenLogicalScope::readwrite, + false, + false, + parts, + ) + .await?; + Ok(Self(info)) + } +} + +/** + * Authenticate an HTTP request for [requiredScope] according to the scheme that is mentioned + * in the Authorization header. + * The allowed schemes are either 'Basic' or 'Bearer'. + * + * Returns the authenticated customer username. + */ +async fn auth_request( + db: &PgPool, + ctx: &BankCtx, + pw_crypto: &PwCrypto, + scope: TokenLogicalScope, + allow_pw: bool, + compat_pw: bool, + parts: &Parts, +) -> ApiResult<(BankInfo, Option<Vec<u8>>)> { + fn headers_malformed(hint: impl Display) -> ApiError { + ApiError::new(ErrorCode::GENERIC_HTTP_HEADERS_MALFORMED).with_hint(hint) + } + + let header = parts.headers.get(AUTHORIZATION); + let Some(authorisation) = header else { + let err = failure( + ErrorCode::GENERIC_UNAUTHORIZED, + "Authorization header not found", + ); + if allow_pw || compat_pw { + return Err(err.with_header( + WWW_AUTHENTICATE, + HeaderValue::from_static(r#"Basic realm="LibEuFin Bank", charset="UTF-8""#), + )); + } else { + return Err(err); + } + }; + + let Some((hscheme, parameter)) = authorisation + .to_str() + .ok() + .and_then(|it| it.split_once(' ')) + else { + return Err(failure( + ErrorCode::GENERIC_UNAUTHORIZED, + "Authorization header is malformed", + )); + }; + + match hscheme { + "Basic" => { + let Some(decoded) = base64::decode(parameter) + .ok() + .and_then(|decoded| String::from_utf8(decoded).ok()) + else { + return Err(headers_malformed( + "Malformed Basic auth credentials found in the Authorization header", + )); + }; + let Some((username, pw)) = decoded.split_once(":") else { + return Err(headers_malformed( + "Malformed Basic auth credentials found in the Authorization header", + )); + }; + if !allow_pw { + warn!(target: "api", "User '{username}' used deprecated password auth"); + if !compat_pw { + return Err(unauthorized("Authorization method 'Basic' not supported")); + } + } + + match check_password(db, ctx, pw_crypto, username, pw).await? { + CheckPasswordResult::UnknownAccount => Err(unauthorized("Unknown account")), + CheckPasswordResult::PasswordMismatch => Err(unauthorized("Bad password")), + CheckPasswordResult::Locked => Err(failure_code(ErrorCode::BANK_ACCOUNT_LOCKED)), + CheckPasswordResult::Success(info) => Ok((info, None)), + } + } + "Bearer" => { + let Some(token) = parameter.strip_prefix(TOKEN_PREFIX) else { + return Err(headers_malformed("Bearer token malformed")); + }; + let decoded = base32::decode(token.as_bytes()).map_err(headers_malformed)?; + + let now = Timestamp::now(); + let Some((token, info)) = access_info(db, ctx, &decoded, &now).await? else { + return Err(failure_code(ErrorCode::GENERIC_TOKEN_UNKNOWN)); + }; + + if token.expiration < now { + return Err(failure_code(ErrorCode::GENERIC_TOKEN_EXPIRED)); + } else if !scope.is_valid_scope(token.scope, token.is_refreshable) { + return Err(failure_code( + ErrorCode::GENERIC_TOKEN_PERMISSION_INSUFFICIENT, + )); + } + + Ok((info, Some(decoded))) + } + _ => Err(failure( + ErrorCode::GENERIC_UNAUTHORIZED, + format!("Authorization method '{hscheme}' wrong or not supported"), + )), + } +} diff --git a/crates/libeufin-bank/src/config.rs b/crates/libeufin-bank/src/config.rs @@ -0,0 +1,201 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* LibEuFin 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. + +* LibEuFin 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 LibEuFin; see the file COPYING. If not, see +* <http://www.gnu.org/licenses/> +*/ + +use std::collections::BTreeMap; + +use compact_str::CompactString; +use jiff::Span; +use taler_api::config::DbCfg; +use taler_common::{ + config::{Config, ValueErr}, + map_config, + types::amount::{Amount, Currency}, +}; +use tracing::warn; +use url::Url; + +use crate::{TanChannel, payto::BankCtx, pw::PwCrypto}; + +pub fn parse_db_cfg(cfg: &Config) -> Result<DbCfg, ValueErr> { + DbCfg::parse(cfg.section("libeufin-bankdb-postgres")) +} + +pub struct CurrencySpecification { + pub name: CompactString, + pub num_fractional_input_digits: u64, + pub num_fractional_normal_digits: u64, + pub num_fractional_trailing_zero_digits: u64, + pub alt_unit_names: BTreeMap<CompactString, CompactString>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, taler_macros::EnumMeta)] +#[enum_meta(Str)] +pub enum WireMethod { + iban, + x_taler_bank, +} + +/** Configuration for libeufin-bank */ +pub struct BankCfg { + pub cfg: Config, + pub name: CompactString, + pub base_url: Url, + pub regional_currency: Currency, + pub regional_currency_spec: CurrencySpecification, + pub wire_transfer_fees: Amount, + pub min_amount: Amount, + pub max_amount: Amount, + pub allow_registration: bool, + pub allow_account_deletion: bool, + pub allow_edit_name: bool, + pub allow_edit_cashout: bool, + pub default_debt_limit: Amount, + pub registration_bonus: Amount, + pub suggested_withdrawal_exchange: Option<String>, + pub allow_conversion: bool, + pub fiat_currency: Option<(Currency, CurrencySpecification)>, + pub spa_path: Option<String>, + pub tan_channels: BTreeMap<TanChannel, (String, BTreeMap<CompactString, CompactString>)>, + pub ctx: BankCtx, + pub wire_method: WireMethod, + pub pw_crypto: PwCrypto, + pub gc_abort_after: Span, + pub gc_clean_after: Span, + pub gc_delete_after: Span, + pub pwd_check_quality: bool, + pub basic_auth_compat: bool, +} + +impl BankCfg { + fn currency_specification( + cfg: &Config, + currency: &Currency, + ) -> Result<CurrencySpecification, ValueErr> { + for s in cfg.sections() { + if s.name.starts_with("CURRENCY-") + && s.currency("code").require()? == *currency + && s.boolean("enabled").require()? + { + return Ok(CurrencySpecification { + name: s.cstr("name").require()?, + num_fractional_input_digits: s.number("fractional_input_digits").require()?, + num_fractional_normal_digits: s.number("fractional_normal_digits").require()?, + num_fractional_trailing_zero_digits: s + .number("fractional_trailing_zero_digits") + .require()?, + alt_unit_names: s.json("alt_unit_names").require()?, + }); + } + } + warn!(target: "config", "Missing currency specification for {currency}, using sane defaults"); + Ok(CurrencySpecification { + name: currency.as_ref().into(), + num_fractional_input_digits: 2, + num_fractional_normal_digits: 2, + num_fractional_trailing_zero_digits: 2, + alt_unit_names: [("0".into(), currency.as_ref().into())].into(), + }) + } + + pub fn parse(cfg: Config) -> Result<Self, ValueErr> { + let s = cfg.section("libeufin-bank"); + let base_url = s.base_url("base_url").require()?; + let hostname = base_url.host_str().unwrap_or_default().into(); + + let wire_method = s + .parse("payment target type", "wire_type") + .default(WireMethod::iban)?; + + let currency = s.currency("currency").require()?; + let fiat = if s.boolean("WireMethod").default(false)? { + let currency = s.currency("fiat_currency").require()?; + let spec = Self::currency_specification(&cfg, &currency)?; + Some((currency, spec)) + } else { + None + }; + let zero = Amount::zero(&currency); + let max = Amount::max(&currency); + let mut tan_channels = BTreeMap::new(); + for channel in TanChannel::entries { + if let Some(path) = s.path(&format!("tan_{channel}")).opt()? { + tan_channels.insert( + *channel, + ( + path, + s.json(&format!("tan_{channel}_env")) + .default(BTreeMap::default())?, + ), + ); + } + } + Ok(BankCfg { + name: s + .cstr("name") + .default(CompactString::const_new("Taler Bank"))?, + base_url, + regional_currency: currency, + regional_currency_spec: Self::currency_specification(&cfg, &currency)?, + wire_transfer_fees: s.amount("wire_transfer_fees", &currency).default(zero)?, + min_amount: s + .amount("min_wire_transfer_amount", &currency) + .default(zero)?, + max_amount: s + .amount("max_wire_transfer_amount", &currency) + .default(max)?, + allow_registration: s.boolean("allow_registration").default(false)?, + allow_account_deletion: s.boolean("allow_account_deletion").default(false)?, + allow_edit_name: s.boolean("allow_edit_name").default(false)?, + allow_edit_cashout: s.boolean("allow_edit_cashout_payto_uri").default(false)?, + default_debt_limit: s.amount("default_debt_limit", &currency).default(zero)?, + registration_bonus: s.amount("registration_bonus", &currency).default(zero)?, + suggested_withdrawal_exchange: s.str("suggested_withdrawal_exchange").opt()?, + allow_conversion: s.boolean("allow_conversion").default(false)?, + fiat_currency: fiat, + spa_path: s.path("spa").opt()?, + tan_channels, + ctx: BankCtx { + bic: s.parse("bic", "iban_payto_bic").opt()?, + hostname, + }, + wire_method, + pw_crypto: map_config!(s, "password hash algorithm", "pwd_hash_algorithm", + "bcrypt" => { s.json::<BcryptCfg>("pwd_hash_config").require()?.into() } + ) + .require()?, + gc_abort_after: s.span("gc_abort_after").require()?, + gc_clean_after: s.span("gc_clean_after").require()?, + gc_delete_after: s.span("gc_delete_after").require()?, + pwd_check_quality: s.boolean("pwd_check").require()?, + basic_auth_compat: s.boolean("pwd_auth_compat").require()?, + cfg, + }) + } +} + +#[derive(serde::Deserialize)] +struct BcryptCfg { + cost: u32, +} + +impl Into<PwCrypto> for BcryptCfg { + fn into(self) -> PwCrypto { + PwCrypto::Bcrypt { cost: self.cost } + } +} diff --git a/crates/libeufin-bank/src/db.rs b/crates/libeufin-bank/src/db.rs @@ -0,0 +1,23 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* LibEuFin 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. + +* LibEuFin 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 LibEuFin; see the file COPYING. If not, see +* <http://www.gnu.org/licenses/> +*/ + +const SCHEMA: &str = "libeufin_bank"; + +pub mod account; +pub mod token; diff --git a/crates/libeufin-bank/src/db/account.rs b/crates/libeufin-bank/src/db/account.rs @@ -0,0 +1,298 @@ +/* + This file is part of TALER + Copyright (C) 2026 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 compact_str::CompactString; +use jiff::Timestamp; +use sqlx::{PgPool, Row as _, postgres::PgRow}; +use taler_api::db::{BindHelper as _, PgError, TypeHelper as _}; +use taler_common::types::{ + amount::Amount, + payto::{BankID, IbanPayto}, +}; + +use crate::{ + BankCtx, TanChannel, + payto::{FullBankPayto, LibeufinId, sql_bank_payto}, + pw::PwCrypto, +}; + +const MAX_TOKEN_CREATION_ATTEMPTS: u16 = 5; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccountCreationResult { + Success(FullBankPayto), + UsernameReuse, + PayToReuse, + UnknownConversionClass, + BonusBalanceInsufficient, +} + +/** Create new account */ +pub async fn create( + db: &PgPool, + ctx: &BankCtx, + pw_crypto: &PwCrypto, + username: &str, + password: &str, + name: &str, + email: Option<&str>, + phone: Option<&str>, + cashout: Option<&IbanPayto>, + internal: LibeufinId, + is_public: bool, + is_exchange: bool, + max_debt: Amount, + bonus: Amount, + tan_channels: &[TanChannel], + check_payto_idempotent: bool, + conversion_rate_class_id: Option<u64>, +) -> sqlx::Result<AccountCreationResult> { + // TODO serialized + let mut tx = db.begin().await?; + let now = Timestamp::now(); + let cashout = cashout.map(|it| it.to_string()); + let canonical = internal.canonical(); + let idempotent = sqlx::query( + " + SELECT password_hash, name=$1 + AND email IS NOT DISTINCT FROM $2 + AND phone IS NOT DISTINCT FROM $3 + AND cashout_payto IS NOT DISTINCT FROM $4 + AND tan_channels = sort_uniq($5) + AND (NOT $6 OR internal_payto=$7) + AND is_public=$8 + AND is_taler_exchange=$9 + AND max_debt=$10 + AND conversion_rate_class_id IS NOT DISTINCT FROM $11 + ,internal_payto, name + FROM customers + JOIN bank_accounts + ON customer_id=owning_customer_id + WHERE username=$12 + ", + ) + .bind(name) + .bind(email) + .bind(phone) + .bind(&cashout) + .bind(tan_channels) + .bind(check_payto_idempotent) + .bind(&canonical) + .bind(is_public) + .bind(is_exchange) + .bind(max_debt) + .bind(conversion_rate_class_id.map(|it| it as i64)) + .bind(username) + .try_map(|r: PgRow| { + Ok(( + r.try_get(1)? && pw_crypto.checkpw(password, r.try_get(0)?).unwrap().matches, + sql_bank_payto(&r, ctx, "internal_payto", "name")?, + )) + }) + .fetch_optional(&mut *tx) + .await?; + let res = if let Some((matches, payto)) = idempotent { + if matches { + AccountCreationResult::Success(payto) + } else { + AccountCreationResult::UsernameReuse + } + } else { + if let LibeufinId::IBAN(BankID { iban, .. }) = &internal { + let res = sqlx::query("INSERT INTO iban_history(iban,creation_time) VALUES ($1, $2)") + .bind(iban.as_ref()) + .bind_timestamp(&now) + .execute(&mut *tx) + .await; + if let Err(e) = &res + && e.is_unique_err() + { + tx.rollback().await?; + return sqlx::Result::Ok(AccountCreationResult::PayToReuse); + } + res?; + } + + let customer_id: i64 = sqlx::query_scalar( + " + INSERT INTO customers ( + username + ,password_hash + ,name + ,email + ,phone + ,cashout_payto + ,tan_channels + ) VALUES ($1, $2, $3, $4, $5, $6, sort_uniq($7)) + RETURNING customer_id + ", + ) + .bind(username) + .bind(pw_crypto.hashpw(password)) + .bind(name) + .bind(email) + .bind(phone) + .bind(&cashout) + .bind(tan_channels) + .fetch_one(&mut *tx) + .await?; + + let res = sqlx::query( + " + INSERT INTO bank_accounts( + internal_payto + ,owning_customer_id + ,is_public + ,is_taler_exchange + ,max_debt + ,conversion_rate_class_id + ) VALUES ($1, $2, $3, $4, $5, $6) + ", + ) + .bind(&canonical) + .bind(customer_id) + .bind(is_public) + .bind(is_exchange) + .bind(max_debt) + .bind(conversion_rate_class_id.map(|it| it as i64)) + .execute(&mut *tx) + .await; + + if let Err(e) = &res + && e.is_unique_err() + { + tx.rollback().await?; + return sqlx::Result::Ok(AccountCreationResult::PayToReuse); + } else if let Err(e) = &res + && e.is_fk_err() + { + tx.rollback().await?; + return sqlx::Result::Ok(AccountCreationResult::PayToReuse); + } + res?; + + if !bonus.is_zero() { + let insufisient = sqlx::query_scalar(" + SELECT out_balance_insufficient + FROM bank_transaction($1,'admin','bonus',$2,$3,true,NULL,NULL,NULL,NULL, NULL, NULL, NULL) + ").bind(&canonical).bind(bonus).bind(now.as_microsecond()).fetch_one(&mut *tx).await?; + if insufisient { + tx.rollback().await?; + return sqlx::Result::Ok(AccountCreationResult::BonusBalanceInsufficient); + } + } + + AccountCreationResult::Success(internal.bank(name, ctx)) + }; + tx.commit().await?; + sqlx::Result::Ok(res) +} + +pub struct BankInfo { + pub username: CompactString, + pub payto: FullBankPayto, + pub bank_account_id: u64, + pub is_exchange: bool, + pub phone: Option<CompactString>, + pub email: Option<CompactString>, + pub channels: Vec<TanChannel>, +} + +impl BankInfo { + pub fn is_admin(&self) -> bool { + self.username == "admin" + } +} + +/** Result status of customer account password check */ +pub enum CheckPasswordResult { + UnknownAccount, + PasswordMismatch, + Locked, + Success(BankInfo), +} + +pub async fn check_password( + db: &PgPool, + ctx: &BankCtx, + pw_crypto: &PwCrypto, + username: &str, + pw: &str, +) -> sqlx::Result<CheckPasswordResult> { + // Get user current password hash + let Some((info, pwh, counter)): Option<(_, CompactString, _)> = sqlx::query( + " + SELECT + password_hash, + token_creation_counter, + bank_account_id, + internal_payto, + is_taler_exchange, + name, + tan_channels, + email, + phone + FROM bank_accounts + JOIN customers ON customer_id=owning_customer_id + WHERE username=$1 AND deleted_at IS NULL + ", + ) + .bind(username) + .try_map(|r: PgRow| { + let info = BankInfo { + username: username.into(), + payto: sql_bank_payto(&r, ctx, "internal_payto", "name")?, + bank_account_id: r.try_get_u64("bank_account_id")?, + is_exchange: r.try_get("is_taler_exchange")?, + phone: r.try_get("phone")?, + email: r.try_get("email")?, + channels: r.try_get("tan_channels")?, + }; + Ok(( + info, + r.try_get("password_hash")?, + r.try_get_u16("token_creation_counter")?, + )) + }) + .fetch_optional(db) + .await? + else { + return Ok(CheckPasswordResult::UnknownAccount); + }; + + // Check locked + if counter >= MAX_TOKEN_CREATION_ATTEMPTS { + return Ok(CheckPasswordResult::Locked); + } + + // Check password + let check = pw_crypto.checkpw(pw, &pwh).unwrap(); // TODO handle this + if !check.matches { + return Ok(CheckPasswordResult::PasswordMismatch); + } + + // Rehash if outdated + if check.outdated { + let new = pw_crypto.hashpw(pw); + sqlx::query("UPDATE customers SET password_hash=$1 where username=$2 AND password_hash=$3") + .bind(new) + .bind(username) + .bind(pwh) + .execute(db) + .await?; + } + Ok(CheckPasswordResult::Success(info)) +} diff --git a/crates/libeufin-bank/src/db/token.rs b/crates/libeufin-bank/src/db/token.rs @@ -0,0 +1,244 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* LibEuFin 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. + +* LibEuFin 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 LibEuFin; see the file COPYING. If not, see +* <http://www.gnu.org/licenses/> +*/ + +use jiff::Timestamp; +use sqlx::{ + Arguments, PgPool, QueryBuilder, Row as _, + postgres::{PgArguments, PgRow}, +}; +use taler_api::{ + db::{BindHelper, TypeHelper}, + serialized, +}; +use taler_common::{api_common::ShortHashCode, api_params::Page, types::timestamp::TalerTimestamp}; + +use crate::{ + api::token::TokenInfo, + auth::TokenScope, + db::account::BankInfo, + payto::{BankCtx, sql_bank_payto}, +}; + +pub struct BearerToken { + pub scope: TokenScope, + pub is_refreshable: bool, + pub creation: Timestamp, + pub expiration: Timestamp, +} + +/** Result status of token creation */ +pub enum TokenCreationResult { + Success, + TanRequired, +} + +/** Create new token for [username] */ +pub async fn create( + db: &PgPool, + username: &str, + content: &[u8], + creation: &Timestamp, + expiration: &TalerTimestamp, + scope: &TokenScope, + is_refreshable: bool, + description: Option<&str>, + is2fa: bool, +) -> sqlx::Result<TokenCreationResult> { + serialized!( + sqlx::query( + " + SELECT out_tan_required FROM create_token( + $1,$2,$3,$4,$5,$6,$7,$8 + ) + ", + ) + .bind(username) + .bind(content) + .bind_timestamp(creation) + .bind(expiration) + .bind(scope) + .bind(is_refreshable) + .bind(description) + .bind(is2fa) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_tan_required")? { + TokenCreationResult::TanRequired + } else { + TokenCreationResult::Success + }) + }) + .fetch_one(db) + ) +} + +/** Get info for [token] */ +pub async fn access( + db: &PgPool, + token: &[u8], + access_time: &Timestamp, +) -> sqlx::Result<Option<BearerToken>> { + serialized!( + sqlx::query( + " + UPDATE bearer_tokens + SET last_access=$1 + FROM customers + WHERE bank_customer=customer_id AND content=$2 AND deleted_at IS NULL + RETURNING + creation_time, + expiration_time, + scope, + is_refreshable + ", + ) + .bind_timestamp(access_time) + .bind(token) + .try_map(|r: PgRow| { + Ok(BearerToken { + scope: r.try_get("scope")?, + is_refreshable: r.try_get("is_refreshable")?, + creation: r.try_get_timestamp("creation_time")?, + expiration: r.try_get_timestamp("expiration_time")?, + }) + }) + .fetch_optional(db) + ) +} + +/** Get info for [token] and its associated bank account*/ +pub async fn access_info( + db: &PgPool, + ctx: &BankCtx, + token: &[u8], + access_time: &Timestamp, +) -> sqlx::Result<Option<(BearerToken, BankInfo)>> { + serialized!( + sqlx::query( + " + UPDATE bearer_tokens + SET last_access=$1 + FROM customers + JOIN bank_accounts ON customer_id=owning_customer_id + WHERE bank_customer=customer_id AND content=$2 AND deleted_at IS NULL + RETURNING + creation_time, + expiration_time, + scope, + is_refreshable, + username, + is_taler_exchange, + bank_account_id, + internal_payto, + name, + tan_channels, + email, + phone + ", + ) + .bind_timestamp(access_time) + .bind(token) + .try_map(|r: PgRow| { + Ok(( + BearerToken { + scope: r.try_get("scope")?, + is_refreshable: r.try_get("is_refreshable")?, + creation: r.try_get_timestamp("creation_time")?, + expiration: r.try_get_timestamp("expiration_time")?, + }, + BankInfo { + username: r.try_get("username")?, + payto: sql_bank_payto(&r, ctx, "internal_payto", "name")?, + bank_account_id: r.try_get_u64("bank_account_id")?, + is_exchange: r.try_get("is_taler_exchange")?, + phone: r.try_get("phone")?, + email: r.try_get("email")?, + channels: r.try_get("tan_channels")?, + }, + )) + }) + .fetch_optional(db) + ) +} + +pub async fn delete(db: &PgPool, token: &[u8]) -> sqlx::Result<bool> { + let res = serialized!( + sqlx::query("DELETE FROM bearer_tokens WHERE content=$1") + .bind(token) + .execute(db) + )?; + Ok(res.rows_affected() > 0) +} + +pub async fn delete_by_id(db: &PgPool, id: u64) -> sqlx::Result<bool> { + let res = serialized!( + sqlx::query("DELETE FROM bearer_tokens WHERE bearer_token_id=$1") + .bind(id as i64) + .execute(db) + )?; + Ok(res.rows_affected() > 0) +} + +/** Get info for [token] and its associated bank account*/ +pub async fn page( + db: &PgPool, + params: &Page, + username: &str, + now: &Timestamp, +) -> sqlx::Result<Vec<TokenInfo>> { + taler_api::db::page( + db, + params, + "bearer_token_id", + || { + let mut args = PgArguments::default(); + args.add(now.as_microsecond()).unwrap(); + args.add(username).unwrap(); + QueryBuilder::with_arguments( + " + SELECT + creation_time, + expiration_time, + scope, + is_refreshable, + description, + last_access, + bearer_token_id + FROM bearer_tokens + WHERE + expiration_time > $1 AND + bank_customer=(SELECT customer_id FROM customers WHERE deleted_at IS NULL AND username = $2) + AND + ", + args + ) + }, + |r: PgRow| { + Ok(TokenInfo { + creation_time: r.try_get_timestamp("creation_time")?.into(), + expiration: r.try_get_timestamp("expiration_time")?.into(), + scope: r.try_get("scope")?, + refreshable: r.try_get("is_refreshable")?, + description: r.try_get("description")?, + last_access: r.try_get_timestamp("last_access")?.into(), + row_id: r.try_get_u64("bearer_token_id")?, + token_id: r.try_get_u64("bearer_token_id")?, + }) + }, + ).await +} diff --git a/crates/libeufin-bank/src/lib.rs b/crates/libeufin-bank/src/lib.rs @@ -0,0 +1,54 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* LibEuFin 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. + +* LibEuFin 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 LibEuFin; see the file COPYING. If not, see +* <http://www.gnu.org/licenses/> +*/ + +use taler_common::config::parser::ConfigSource; +use taler_macros::EnumMeta; + +use crate::payto::BankCtx; + +pub mod api; +pub mod auth; +pub mod config; +pub mod db; +pub mod payto; +pub mod pw; + +pub const CONFIG_SOURCE: ConfigSource = + ConfigSource::new("libeufin", "libeufin-bank", "libeufin-bank"); + +// Allowed values for cashout TAN channels. +#[derive( + sqlx::Type, + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + EnumMeta, + serde::Serialize, + serde::Deserialize, +)] +#[sqlx(type_name = "tan_enum")] +#[enum_meta(Str)] +pub enum TanChannel { + sms, + email, +} diff --git a/crates/libeufin-bank/src/payto.rs b/crates/libeufin-bank/src/payto.rs @@ -0,0 +1,156 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* LibEuFin 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. + +* LibEuFin 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 LibEuFin; see the file COPYING. If not, see +* <http://www.gnu.org/licenses/> +*/ + +use compact_str::CompactString; +use sqlx::{Row as _, postgres::PgRow}; +use taler_api::{ + db::TypeHelper as _, + error::{ApiResult, failure}, +}; +use taler_common::{ + error_code::ErrorCode, + types::{ + iban::BIC, + payto::{BankID, FullPayto, Payto, PaytoErr, PaytoImpl, PaytoURI}, + }, +}; + +const X_TALER_BANK: &str = "x-taler-bank"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct XTalerBank { + pub hostname: CompactString, + pub username: CompactString, +} + +impl PaytoImpl for XTalerBank { + fn as_payto(&self) -> PaytoURI { + PaytoURI::from_parts( + X_TALER_BANK, + format_args!("/{}/{}", self.hostname, self.username), + ) + } + + fn parse(raw: &PaytoURI) -> Result<Self, PaytoErr> { + let url = raw.as_ref(); + if url.domain() != Some(X_TALER_BANK) { + return Err(PaytoErr::UnsupportedKind( + X_TALER_BANK, + url.domain().unwrap_or_default().into(), + )); + } + let Some(mut segments) = url.path_segments() else { + return Err(PaytoErr::MissingSegment("hostname")); + }; + let Some(hostname) = segments.next() else { + return Err(PaytoErr::MissingSegment("hostname")); + }; + let Some(username) = segments.next() else { + return Err(PaytoErr::MissingSegment("username")); + }; + + Ok(Self { + hostname: hostname.into(), + username: username.into(), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LibeufinId { + IBAN(BankID), + XTalerBank(XTalerBank), +} + +pub type BankPayto = Payto<LibeufinId>; +pub type FullBankPayto = FullPayto<LibeufinId>; + +impl LibeufinId { + pub fn canonical(&self) -> String { + match self { + LibeufinId::IBAN(BankID { iban, .. }) => format!("payto://iban/{iban}"), + LibeufinId::XTalerBank(XTalerBank { username, .. }) => { + format!("payto://{X_TALER_BANK}/localhost/{username}") + } + } + } + + pub fn bank(mut self, name: &str, ctx: &BankCtx) -> FullBankPayto { + match &mut self { + LibeufinId::IBAN(bank_id) => bank_id.bic = ctx.bic.clone(), + LibeufinId::XTalerBank(xtaler_bank) => xtaler_bank.hostname = ctx.hostname.clone(), + }; + FullPayto::new(self, name) + } + + pub fn expect_iban(&self) -> ApiResult<&BankID> { + match self { + LibeufinId::IBAN(bank_id) => Ok(bank_id), + LibeufinId::XTalerBank(_) => Err(failure( + ErrorCode::GENERIC_JSON_INVALID, + format_args!("expected an IBAN payto URI got '{X_TALER_BANK}'"), + )), + } + } + + pub fn expect_xtaler_bank(&self) -> ApiResult<&XTalerBank> { + match self { + LibeufinId::IBAN(_) => Err(failure( + ErrorCode::GENERIC_JSON_INVALID, + format_args!("expected a {X_TALER_BANK} payto URI got 'iban'"), + )), + LibeufinId::XTalerBank(xtaler_bank) => Ok(xtaler_bank), + } + } +} + +impl PaytoImpl for LibeufinId { + fn as_payto(&self) -> PaytoURI { + match self { + LibeufinId::IBAN(bank_id) => bank_id.as_payto(), + LibeufinId::XTalerBank(x_taler_bank) => x_taler_bank.as_payto(), + } + } + + fn parse(uri: &PaytoURI) -> Result<Self, PaytoErr> { + let url = uri.as_ref(); + + Ok(if url.domain() == Some(X_TALER_BANK) { + Self::XTalerBank(XTalerBank::parse(uri)?) + } else { + Self::IBAN(BankID::parse(uri)?) + }) + } +} + +pub struct BankCtx { + pub bic: Option<BIC>, + pub hostname: CompactString, +} + +pub fn sql_bank_payto( + r: &PgRow, + ctx: &BankCtx, + payto_idx: &str, + name_idx: &str, +) -> sqlx::Result<FullBankPayto> { + let bank_payto: Payto<LibeufinId> = r.try_get_parse(payto_idx)?; + let name = r.try_get(name_idx)?; + Ok(bank_payto.into_inner().bank(name, ctx)) +} diff --git a/crates/libeufin-bank/src/pw.rs b/crates/libeufin-bank/src/pw.rs @@ -0,0 +1,176 @@ +use anyhow::anyhow; +use aws_lc_rs::digest::SHA256; +use taler_api::error::{ApiResult, failure}; +use taler_common::{encoding::base64, error_code::ErrorCode}; + +// NIST Password Guidelines 2024 +const PASSWORD_MIN_LEN: usize = 8; +const PASSWORD_MAX_LEN: usize = 64; + +/** Check if a string is a valid password */ +pub fn checkpw(pw: &str, check_quality: bool) -> ApiResult<()> { + if !check_quality { + return Ok(()); + } + let len = pw.len(); + + if len < PASSWORD_MIN_LEN { + Err(failure( + ErrorCode::BANK_PASSWORD_TOO_SHORT, + format_args!( + "Password is too short, expect at least {PASSWORD_MIN_LEN} characters got {len}" + ), + )) + } else if len > PASSWORD_MAX_LEN { + Err(failure( + ErrorCode::BANK_PASSWORD_TOO_LONG, + format_args!( + "Password is too long, expect at most {PASSWORD_MAX_LEN} characters got {len}", + ), + )) + } else { + Ok(()) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct PwCheck { + pub matches: bool, + pub outdated: bool, +} + +pub enum PwCrypto { + Bcrypt { cost: u32 }, + Sha256, +} + +impl PwCrypto { + /** Hash [pw] using [cfg] hashing method */ + pub fn hashpw(&self, pw: &str) -> String { + match self { + PwCrypto::Bcrypt { cost } => { + let mut salt = [0u8; 16]; + getrandom::fill(&mut salt).unwrap(); + let pwh = bcrypt::bcrypt(*cost, salt, pw.as_bytes()); + format!("bcrypt${cost}${}${}", base64::fmt(salt), base64::fmt(pwh)) + } + PwCrypto::Sha256 => { + let pwh = aws_lc_rs::digest::digest(&SHA256, pw.as_bytes()); + format!("sha256${}", base64::fmt(pwh)) + } + } + } + + /** Check whether [pw] match hashed [storedPwHash] and if it should be rehashed */ + pub fn checkpw(&self, pw: &str, stored_pw_hash: &str) -> anyhow::Result<PwCheck> { + let (alg, args) = stored_pw_hash + .split_once('$') + .ok_or(anyhow!("bad password hash format"))?; + let (matches, outdated) = match alg { + "sha256" => { + let [hash] = split_n(args, '$').ok_or(anyhow!("bad password hash format"))?; + let pwh = aws_lc_rs::digest::digest(&SHA256, pw.as_bytes()); + let pwh = base64::encode(pwh); + (pwh == hash, true) + } + "sha256-salted" => { + let [salt, hash] = split_n(args, '$').ok_or(anyhow!("bad password hash format"))?; + let pwh = aws_lc_rs::digest::digest(&SHA256, format!("{salt}|{pw}").as_bytes()); + let pwh = base64::encode(pwh); + (pwh == hash, true) + } + "bcrypt" => { + let [cost, salt, hash] = + split_n(args, '$').ok_or(anyhow!("bad password hash format"))?; + let cost: u32 = cost.parse()?; + let salt = base64::decode(salt)? + .try_into() + .map_err(|_| anyhow!("bad password hash format"))?; + let pwh = bcrypt::bcrypt(cost, salt, pw.as_bytes()); + let pwh = base64::encode(pwh); + ( + pwh == hash, + match self { + PwCrypto::Bcrypt { cost: expected } => cost != *expected, + PwCrypto::Sha256 => false, + }, + ) + } + alg => return Err(anyhow!("unsupported hash algo: {alg}")), + }; + + Ok(PwCheck { matches, outdated }) + } +} + +fn split_n<const N: usize>(input: &str, sep: char) -> Option<[&str; N]> { + let mut iter = input.split(sep); + + let mut result = [""; N]; + + for split in result.iter_mut().take(N) { + *split = iter.next()?; + } + + if iter.next().is_some() { + None + } else { + Some(result) + } +} + +#[test] +fn pwh() { + let pw = "myinsecurepw"; + let crypto = PwCrypto::Bcrypt { cost: 4 }; + // Check roundtrip + let hash = crypto.hashpw(pw); + assert_eq!( + crypto.checkpw(pw, &hash).unwrap(), + PwCheck { + matches: true, + outdated: false + } + ); + assert_eq!( + crypto.checkpw("other", &hash).unwrap(), + PwCheck { + matches: false, + outdated: false + } + ); + + // Check outdated algorithm + let outdated = PwCrypto::Sha256.hashpw(pw); + assert_eq!( + crypto.checkpw(pw, &outdated).unwrap(), + PwCheck { + matches: true, + outdated: true + } + ); + assert_eq!( + crypto.checkpw("other", &outdated).unwrap(), + PwCheck { + matches: false, + outdated: true + } + ); + + // Check outdated options + let better = PwCrypto::Bcrypt { cost: 5 }; + assert_eq!( + better.checkpw(pw, &hash).unwrap(), + PwCheck { + matches: true, + outdated: true + } + ); + assert_eq!( + better.checkpw("other", &hash).unwrap(), + PwCheck { + matches: false, + outdated: true + } + ); +} diff --git a/crates/libeufin-ebics/src/test.rs b/crates/libeufin-ebics/src/test.rs @@ -444,7 +444,7 @@ impl EbicsState { } pub fn btd_no_data_pinned(&mut self, body: &[u8]) -> EbicsRes { - self.btd_date_check(body, Some(date(2024, 06, 05))) + self.btd_date_check(body, Some(date(2024, 6, 5))) } pub fn btu_init(&mut self, body: &[u8]) -> EbicsRes { diff --git a/crates/libeufin-nexus/Cargo.toml b/crates/libeufin-nexus/Cargo.toml @@ -17,6 +17,7 @@ serde_json.workspace = true taler-common.workspace = true taler-api.workspace = true taler-build.workspace = true +taler-macros.workspace = true taler-test-utils.workspace = true clap.workspace = true aws-lc-rs.workspace = true diff --git a/crates/libeufin-nexus/src/config.rs b/crates/libeufin-nexus/src/config.rs @@ -38,6 +38,7 @@ use taler_common::{ utils::date_to_utc_ts, }, }; +use taler_macros::EnumMeta; pub fn parse_db_cfg(cfg: &Config) -> Result<DbCfg, ValueErr> { DbCfg::parse(cfg.section("libeufin-nexusdb-postgres")) @@ -97,10 +98,11 @@ impl NexusHostCfg { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, EnumMeta)] +#[enum_meta(Str)] pub enum AccountType { - Exchange, - Normal, + exchange, + normal, } pub struct NexusIngestCfg { @@ -216,11 +218,7 @@ impl NexusCfg { let s = cfg.section("nexus-ebics"); Ok(Self { currency: s.currency("currency").require()?, - account_type: map_config!(s, "account type", "ACCOUNT_TYPE", - "exchange" => { AccountType::Exchange }, - "normal" => { AccountType::Normal } - ) - .require()?, + account_type: s.parse("account type", "ACCOUNT_TYPE").require()?, cfg, keys: OnceCell::new(), host: OnceCell::new(), diff --git a/crates/libeufin-nexus/src/db.rs b/crates/libeufin-nexus/src/db.rs @@ -14,9 +14,8 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -use compact_str::CompactString; use jiff::Timestamp; -use sqlx::{PgPool, Row, postgres::PgRow, types::Json}; +use sqlx::{PgPool, Row, types::Json}; use taler_api::db::BindHelper; use taler_common::config::Config; use tokio::sync::watch::Sender; @@ -66,34 +65,6 @@ pub async fn notification_listener( ) } -/** Register a pending transaction */ -pub async fn ebics_register(db: &PgPool, id: &str) -> sqlx::Result<()> { - sqlx::query( - "INSERT INTO pending_ebics_transactions (tx_id) VALUES ($1) ON CONFLICT DO NOTHING", - ) - .bind(id) - .execute(db) - .await?; - Ok(()) -} - -/** Register a pending transaction */ -pub async fn ebics_remove(db: &PgPool, id: &str) -> sqlx::Result<()> { - sqlx::query("DELETE FROM pending_ebics_transactions WHERE tx_id = $1") - .bind(id) - .execute(db) - .await?; - Ok(()) -} - -/** Register a pending transaction */ -pub async fn ebics_first(db: &PgPool) -> sqlx::Result<Option<CompactString>> { - sqlx::query("SELECT tx_id FROM pending_ebics_transactions LIMIT 1") - .try_map(|r: PgRow| r.try_get(0)) - .fetch_optional(db) - .await -} - /** Get current value for [key] */ pub async fn get_task_status(db: &PgPool, key: &str) -> sqlx::Result<Option<TaskStatus>> { sqlx::query_scalar::<_, Json<TaskStatus>>("SELECT value FROM kv WHERE key=$1") @@ -120,15 +91,13 @@ pub async fn update_task_status( #[cfg(test)] pub mod test { + use libeufin_ebics::db::{ebics_first, ebics_register, ebics_remove}; use sqlx::{PgPool, Postgres, Row, pool::PoolConnection, postgres::PgRow}; use taler_api::db::TypeHelper; use taler_common::db::IncomingType; use taler_test_utils::routine::Status; - use crate::{ - CONFIG_SOURCE, - db::{ebics_first, ebics_register, ebics_remove}, - }; + use crate::CONFIG_SOURCE; pub async fn db_setup() -> (PoolConnection<Postgres>, PgPool) { taler_test_utils::db::db_test_setup(CONFIG_SOURCE).await @@ -200,7 +169,7 @@ pub mod test { pub async fn check_in(db: &PgPool) -> Vec<Status> { sqlx::query( " - SELECT pending_recurrent_incoming_transactions.authorization_pub IS NOT NULL, initiated_outgoing_transaction_id IS NOT NULL, debit_payto IS NULL OR subject IS NULL, type::text, metadata + SELECT pending_recurrent_incoming_transactions.authorization_pub IS NOT NULL, initiated_outgoing_transaction_id IS NOT NULL, debit_payto IS NULL OR subject IS NULL, type::text, metadata FROM incoming_transactions LEFT JOIN talerable_incoming_transactions USING (incoming_transaction_id) LEFT JOIN pending_recurrent_incoming_transactions USING (incoming_transaction_id) diff --git a/crates/libeufin-nexus/src/db/payment.rs b/crates/libeufin-nexus/src/db/payment.rs @@ -605,7 +605,7 @@ mod test { async fn in_simple() { let (_, db) = db_setup().await; - let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); + let cfg = NexusIngestCfg::simple(AccountType::exchange, &CURR); // Register let incoming = gen_in_pay("test".to_owned()); @@ -651,7 +651,7 @@ mod test { async fn in_talerable() { let (_, db) = db_setup().await; - let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); + let cfg = NexusIngestCfg::simple(AccountType::exchange, &CURR); let key = EddsaPublicKey::rand(); let subject = format!("test with {key} reserve pub"); @@ -713,7 +713,7 @@ mod test { #[tokio::test] async fn in_mapping() { let (_, db) = db_setup().await; - let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); + let cfg = NexusIngestCfg::simple(AccountType::exchange, &CURR); let first = EddsaPublicKey::rand(); let auth_pub = EddsaPublicKey::rand(); let auth_sig = EddsaSignature::rand(); @@ -838,7 +838,7 @@ mod test { #[tokio::test] async fn in_reference() { let (_, db) = db_setup().await; - let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); + let cfg = NexusIngestCfg::simple(AccountType::exchange, &CURR); let first = EddsaPublicKey::rand(); let auth_pub = EddsaPublicKey::rand(); let auth_sig = EddsaSignature::rand(); @@ -962,7 +962,7 @@ mod test { #[tokio::test] async fn in_recover_info() { let (_, db) = db_setup().await; - let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); + let cfg = NexusIngestCfg::simple(AccountType::exchange, &CURR); async fn check_content(db: &PgPool, p: &InTx) { sqlx::query( @@ -1087,7 +1087,7 @@ mod test { #[tokio::test] pub async fn in_horror() { let (_, db) = db_setup().await; - let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); + let cfg = NexusIngestCfg::simple(AccountType::exchange, &CURR); // Check we do not bounce already registered talerable transaction let key = EddsaPublicKey::rand(); diff --git a/crates/libeufin-nexus/src/fetch.rs b/crates/libeufin-nexus/src/fetch.rs @@ -495,7 +495,7 @@ pub async fn register_incoming( }; let bounce = async |cause: &str| { match cfg.account_type { - AccountType::Exchange => { + AccountType::exchange => { if payment.execution_time < cfg.ignore_bounces_before { let res = register_in(db, payment).await?; log_res(res, "", &format!("ignored bounce: {cause}")); @@ -544,7 +544,7 @@ pub async fn register_incoming( } } } - AccountType::Normal => { + AccountType::normal => { let res = register_in(db, payment).await?; log_res(res, "", ""); } diff --git a/crates/libeufin-nexus/src/test.rs b/crates/libeufin-nexus/src/test.rs @@ -121,7 +121,7 @@ pub async fn gen_initiate( .unwrap() } -const CFG: NexusIngestCfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); +const CFG: NexusIngestCfg = NexusIngestCfg::simple(AccountType::exchange, &CURR); async fn prepare(db: &PgPool) -> String { let key = EddsaPublicKey::rand();