commit 5c66545f2ce01c7eb25dbb091871334b406f2687
parent e5ba71f533773395048627e4f45c9c46dfff4407
Author: Antoine A <>
Date: Thu, 30 Apr 2026 20:36:37 +0200
bank: start implementing token & account logic
Diffstat:
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, ¤cy)?;
+ Some((currency, spec))
+ } else {
+ None
+ };
+ let zero = Amount::zero(¤cy);
+ let max = Amount::max(¤cy);
+ 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, ¤cy)?,
+ wire_transfer_fees: s.amount("wire_transfer_fees", ¤cy).default(zero)?,
+ min_amount: s
+ .amount("min_wire_transfer_amount", ¤cy)
+ .default(zero)?,
+ max_amount: s
+ .amount("max_wire_transfer_amount", ¤cy)
+ .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", ¤cy).default(zero)?,
+ registration_bonus: s.amount("registration_bonus", ¤cy).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();