commit 452b031cdd1c6d1bff266e104228181c813447f4 parent 895ca7f0a1d791292910db8f593b6dd2a5938229 Author: Antoine A <> Date: Fri, 22 May 2026 17:29:46 +0200 bank: add CLI Diffstat:
20 files changed, 761 insertions(+), 158 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock @@ -526,6 +526,18 @@ dependencies = [ ] [[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -808,6 +820,18 @@ dependencies = [ ] [[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + +[[package]] name = "diff" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -867,6 +891,12 @@ dependencies = [ ] [[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] name = "encoding_rs" version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1687,6 +1717,7 @@ dependencies = [ "bcrypt", "clap", "compact_str", + "dialoguer", "futures", "jiff", "pretty_assertions", @@ -2832,6 +2863,12 @@ dependencies = [ ] [[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Makefile b/Makefile @@ -36,10 +36,10 @@ deb: .PHONY: install-nobuild-files install-nobuild-files: - install -m 644 -D -t $(share_dir)/libeufin/config.d contrib/currencies.conf + install -m 644 -D -t $(share_dir)/libeufin/config.d contrib/currencies.conf install -m 644 -D -t $(share_dir)/libeufin/config.d contrib/bank.conf install -m 644 -D -t $(share_dir)/libeufin/config.d contrib/nexus.conf - install -m 644 -D -t $(share_dir)/libeufin/sql database-versioning/versioning.sql + install -m 644 -D -t $(share_dir)/libeufin/sql database-versioning/versioning.sql install -m 644 -D -t $(share_dir)/libeufin/sql database-versioning/libeufin-bank*.sql install -m 644 -D -t $(share_dir)/libeufin/sql database-versioning/libeufin-nexus*.sql install -m 644 -D -t $(share_dir)/libeufin/sql database-versioning/libeufin-conversion*.sql @@ -53,7 +53,7 @@ install-nobuild-files: cp contrib/spa/* $(share_dir)/libeufin/spa/ install -d $(share_dir)/libeufin-ebisync/spa cp libeufin-ebisync/src/spa/* $(share_dir)/libeufin-ebisync/spa/ - + .PHONY: install install: build install-nobuild-files # Install libeufin-bank @@ -133,9 +133,9 @@ nexus-bench-db: install-nobuild-files .PHONY: rust-install rust-install: install-nobuild-files - cargo build --release --bin libeufin-bank --bin libeufin-rust + cargo build --release --bin libeufin-bank --bin libeufin-nexus install -D -t $(bin_dir) target/release/libeufin-bank - install -D -t $(bin_dir) target/release/libeufin-rust + install -D -t $(bin_dir) target/release/libeufin-nexus .PHONY: rust-check rust-check: install-nobuild-files diff --git a/crates/libeufin-bank/Cargo.toml b/crates/libeufin-bank/Cargo.toml @@ -31,6 +31,7 @@ futures = "0.3" url = "2.5" regex = "1.12" bcrypt = "0.19.0" +dialoguer = "0.12" [dev-dependencies] pretty_assertions.workspace = true diff --git a/crates/libeufin-bank/src/api.rs b/crates/libeufin-bank/src/api.rs @@ -107,7 +107,7 @@ impl BankState { } } -#[api_config("taler-coreban")] +#[api_config("taler-corebank")] #[derive(Debug, Clone, Serialize)] pub struct Config<'a> { pub currency: Currency, diff --git a/crates/libeufin-bank/src/api/account.rs b/crates/libeufin-bank/src/api/account.rs @@ -19,6 +19,7 @@ use std::{ fmt::Debug, + str::FromStr, sync::{Arc, LazyLock}, }; @@ -66,7 +67,7 @@ use crate::{ pw::checkpw, }; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ChallengeContactData { #[serde(default)] pub email: Maybe<CompactString>, @@ -109,7 +110,8 @@ pub struct RegisterAccountRequest { pub is_public: bool, #[serde(default)] pub is_taler_exchange: bool, - pub contact_data: Option<ChallengeContactData>, + #[serde(default)] + pub contact_data: ChallengeContactData, pub cashout_payto_uri: Option<IbanPayto>, pub payto_uri: Option<Payto<LibeufinId>>, pub debit_threshold: Option<Amount>, @@ -133,9 +135,7 @@ impl RegisterAccountRequest { "you must only use either tan_channel or tan_channels", )); } - if let Some(contact_data) = &self.contact_data { - contact_data.validate()?; - } + self.contact_data.validate()?; Ok(()) } @@ -184,6 +184,18 @@ impl<T> Maybe<T> { } } +impl<T: FromStr> FromStr for Maybe<T> { + type Err = T::Err; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + if s.is_empty() { + Ok(Self::Null) + } else { + Ok(Self::Some(s.parse()?)) + } + } +} + impl<'de, T: Deserialize<'de>> Deserialize<'de> for Maybe<T> { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where @@ -217,6 +229,24 @@ impl<T: Serialize> Serialize for Maybe<T> { } } +impl<T> From<Option<T>> for Maybe<T> { + fn from(value: Option<T>) -> Self { + match value { + None => Maybe::Null, + Some(v) => Maybe::Some(v), + } + } +} + +impl<T> From<Option<Maybe<T>>> for Maybe<T> { + fn from(value: Option<Maybe<T>>) -> Self { + match value { + None => Maybe::Missing, + Some(v) => v, + } + } +} + pub trait TanInfo: Debug { fn phone(&self) -> Option<&str>; fn email(&self) -> Option<&str>; @@ -237,7 +267,8 @@ pub trait TanInfo: Debug { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AccountReconfiguration { - pub contact_data: Option<ChallengeContactData>, + #[serde(default)] + pub contact_data: ChallengeContactData, #[serde(default)] pub cashout_payto_uri: Maybe<IbanPayto>, pub name: Option<CompactString>, @@ -246,7 +277,7 @@ pub struct AccountReconfiguration { #[serde(default)] pub tan_channel: Maybe<TanChannel>, #[serde(default)] - pub tan_channels: Maybe<Vec<TanChannel>>, + pub tan_channels: Option<Vec<TanChannel>>, pub is_taler_exchange: Option<bool>, #[serde(default)] pub conversion_rate_class_id: Maybe<u64>, @@ -254,21 +285,15 @@ pub struct AccountReconfiguration { impl TanInfo for AccountReconfiguration { fn phone(&self) -> Option<&str> { - self.contact_data - .as_ref() - .and_then(|it| it.phone.opt()) - .map(|it| it.as_str()) + self.contact_data.phone.opt().map(|it| it.as_str()) } fn email(&self) -> Option<&str> { - self.contact_data - .as_ref() - .and_then(|it| it.email.opt()) - .map(|it| it.as_str()) + self.contact_data.email.opt().map(|it| it.as_str()) } fn channels(&self) -> &[TanChannel] { - if let Some(many) = self.tan_channels.opt() { + if let Some(many) = &self.tan_channels { many } else if let Some(one) = self.tan_channel.opt() { std::slice::from_ref(one) @@ -290,7 +315,7 @@ impl AccountReconfiguration { } pub fn channels(&self) -> Option<&[TanChannel]> { - if let Some(many) = self.tan_channels.opt() { + if let Some(many) = &self.tan_channels { Some(many) } else if let Some(one) = self.tan_channel.opt() { Some(std::slice::from_ref(one)) @@ -689,8 +714,8 @@ pub async fn create_account( } let info = match channel { - TanChannel::sms => req.contact_data.as_ref().map(|it| &it.phone), - TanChannel::email => req.contact_data.as_ref().map(|it| &it.email), + TanChannel::sms => req.contact_data.phone.opt(), + TanChannel::email => req.contact_data.email.opt(), }; if info.is_none() { @@ -719,14 +744,8 @@ pub async fn create_account( &req.username, &req.password, &req.name, - req.contact_data - .as_ref() - .and_then(|it| it.email.opt()) - .map(|it| it.as_str()), - req.contact_data - .as_ref() - .and_then(|it| it.phone.opt()) - .map(|it| it.as_str()), + req.contact_data.email.opt().map(|it| it.as_str()), + req.contact_data.phone.opt().map(|it| it.as_str()), req.cashout_payto_uri.as_ref(), &payto, req.is_public, @@ -880,8 +899,6 @@ pub async fn patch_account( #[cfg(test)] pub mod test { - use std::time::Duration; - use axum::http::{Method, StatusCode}; use jiff::Timestamp; use serde::Serialize; @@ -1422,10 +1439,8 @@ pub mod test { // GC { - let zero = Duration::default(); - collect(&ctx.state.db, &Timestamp::now(), &zero, &zero, &zero) - .await - .unwrap(); + let now = Timestamp::now(); + collect(&ctx.state.db, &now, &now, &now).await.unwrap(); // TODO need more operations } @@ -1829,10 +1844,8 @@ pub mod test { } // Hard delete accounts - let zero = Duration::default(); - collect(&ctx.state.db, &Timestamp::now(), &zero, &zero, &zero) - .await - .unwrap(); + let now = Timestamp::now(); + collect(&ctx.state.db, &now, &now, &now).await.unwrap(); ctx.get("/public-accounts").await.assert_no_content(); ctx.get_admin("/accounts").await.assert_ok(); diff --git a/crates/libeufin-bank/src/api/tan.rs b/crates/libeufin-bank/src/api/tan.rs @@ -469,12 +469,9 @@ pub mod test { // We are still rate limited let new = tx_challenge().await; - ctx.posta(format!( - "/accounts/merchant/challenge/{}", - new.challenge_id - )) - .await - .assert_error(ErrorCode::BANK_TAN_RATE_LIMITED); + ctx.posta(format!("/accounts/merchant/challenge/{}", new.challenge_id)) + .await + .assert_error(ErrorCode::BANK_TAN_RATE_LIMITED); } #[tokio::test] diff --git a/crates/libeufin-bank/src/auth.rs b/crates/libeufin-bank/src/auth.rs @@ -39,6 +39,7 @@ use taler_common::{ error_code::ErrorCode, types::timestamp::TalerTimestamp, }; +use taler_macros::EnumMeta; use tracing::warn; use crate::{ @@ -92,8 +93,11 @@ impl TokenLogicalScope { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize, EnumMeta, clap::ValueEnum, +)] #[sqlx(type_name = "token_scope_enum")] +#[enum_meta(Str)] #[allow(non_camel_case_types)] pub enum TokenScope { readonly, diff --git a/crates/libeufin-bank/src/config.rs b/crates/libeufin-bank/src/config.rs @@ -23,6 +23,7 @@ use compact_str::CompactString; use jiff::Span; use serde::{Deserialize, Serialize}; use taler_api::{ + Serve, config::DbCfg, error::{ApiResult, failure}, }; @@ -39,10 +40,6 @@ use uuid::Uuid; use crate::{TanChannel, payto::PaytoCtx, pw::PwCrypto}; -pub fn parse_db_cfg(cfg: &Config) -> Result<DbCfg, ValueErr> { - DbCfg::parse(cfg.section("libeufin-bankdb-postgres")) -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CurrencySpecification { pub name: CompactString, @@ -57,6 +54,7 @@ pub struct CurrencySpecification { #[allow(non_camel_case_types)] pub enum WireMethod { iban, + #[enum_meta(rename = "x-taler-bank")] x_taler_bank, } @@ -88,6 +86,8 @@ pub struct BankCfg { pub gc_delete_after: Span, pub pwd_check_quality: bool, pub basic_auth_compat: bool, + pub db_cfg: DbCfg, + pub serve: Serve, } impl BankCfg { @@ -192,6 +192,8 @@ impl BankCfg { 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()?, + serve: Serve::parse(s)?, + db_cfg: DbCfg::parse(cfg.section("libeufin-bankdb-postgres"))?, cfg, }) } diff --git a/crates/libeufin-bank/src/db.rs b/crates/libeufin-bank/src/db.rs @@ -24,7 +24,7 @@ use taler_api::notification::NotificationChannel; use tokio::join; use uuid::Uuid; -use crate::api::withdrawal::WithdrawalStatus; +use crate::{api::withdrawal::WithdrawalStatus, config::BankCfg}; pub mod account; pub mod cashout; @@ -39,6 +39,20 @@ pub mod withdrawal; const SCHEMA: &str = "libeufin_bank"; +pub async fn pool(cfg: &BankCfg) -> anyhow::Result<PgPool> { + let db_cfg = &cfg.db_cfg; + let pool = taler_common::db::pool(db_cfg.cfg.clone(), SCHEMA).await?; + Ok(pool) +} + +pub async fn dbinit(cfg: &BankCfg, reset: bool) -> anyhow::Result<PgPool> { + let db_cfg = &cfg.db_cfg; + let pool = taler_common::db::pool(db_cfg.cfg.clone(), SCHEMA).await?; + let mut db = pool.acquire().await?; + taler_common::db::dbinit(&mut db, db_cfg.sql_dir.as_ref(), "libeufin-bank", reset).await?; + Ok(pool) +} + pub async fn notification_listener( pool: PgPool, tx_channel: NotificationChannel<u64, i64>, diff --git a/crates/libeufin-bank/src/db/account.rs b/crates/libeufin-bank/src/db/account.rs @@ -371,10 +371,9 @@ pub async fn reconfig( return Ok(PatchResult::UnknownAccount); }; - let validation = match req.required_validation(&curr) { Ok(v) => v, - Err(e) => return Ok(PatchResult::MissingTanInfo(e)) + Err(e) => return Ok(PatchResult::MissingTanInfo(e)), }; // Check performed 2fa check @@ -461,10 +460,10 @@ pub async fn reconfig( .push("cashout_payto=") .push_bind_unseparated(v.map(|it| it.as_uri().to_string())); } - if let Some(v) = req.contact_data.as_ref().and_then(|it| it.phone.inner()) { + if let Some(v) = req.contact_data.phone.inner() { separated.push("phone=").push_bind_unseparated(v); } - if let Some(v) = req.contact_data.as_ref().and_then(|it| it.email.inner()) { + if let Some(v) = req.contact_data.email.inner() { separated.push("email=").push_bind_unseparated(v); } if let Some(v) = req.channels() { diff --git a/crates/libeufin-bank/src/db/gc.rs b/crates/libeufin-bank/src/db/gc.rs @@ -19,8 +19,6 @@ //! Data access logic for garbage collection -use std::time::Duration; - use jiff::Timestamp; use sqlx::PgPool; use taler_api::db::BindHelper; @@ -28,15 +26,10 @@ use taler_api::db::BindHelper; /** Run garbage collection */ pub async fn collect( db: &PgPool, - now: &Timestamp, - abort_after: &Duration, - clean_after: &Duration, - delete_after: &Duration, + abort_after: &Timestamp, + clean_after: &Timestamp, + delete_after: &Timestamp, ) -> sqlx::Result<()> { - let abort_after = *now - *abort_after; - let clean_after = *now - *clean_after; - let delete_after = *now - *delete_after; - // Abort pending operations sqlx::query( " @@ -44,7 +37,7 @@ pub async fn collect( SELECT FROM prepared_transfers JOIN taler_withdrawal_operations USING (withdrawal_id) ) " - ).bind_timestamp(&abort_after).execute(db).await?; + ).bind_timestamp(abort_after).execute(db).await?; // Clean aborted operations, expired challenges and expired tokens for stm in [ @@ -54,12 +47,12 @@ pub async fn collect( "DELETE FROM tan_challenges WHERE expiration_date < $1", "DELETE FROM bearer_tokens WHERE expiration_time < $1" ] { - sqlx::query(stm).bind_timestamp(&clean_after).execute(db).await?; + sqlx::query(stm).bind_timestamp(clean_after).execute(db).await?; } // Delete old bank transactions, linked operations are deleted by CASCADE sqlx::query("DELETE FROM bank_account_transactions WHERE transaction_date < $1") - .bind_timestamp(&delete_after) + .bind_timestamp(delete_after) .execute(db) .await?; diff --git a/crates/libeufin-bank/src/lib.rs b/crates/libeufin-bank/src/lib.rs @@ -17,11 +17,56 @@ * <http://www.gnu.org/licenses/> */ +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +use anyhow::{anyhow, bail}; +use compact_str::CompactString; +use dialoguer::Password; +use jiff::{Timestamp, Zoned}; +use rand::{RngExt as _, distr::Alphanumeric}; use serde::{Deserialize, Serialize}; -use taler_common::config::parser::ConfigSource; +use taler_api::api::TalerRouter; +use taler_build::long_version; +use taler_common::{ + CommonArgs, + cli::ConfigCmd, + config::{Config, parser::ConfigSource}, + types::{ + amount::Amount, + base32::Base32, + payto::IbanPayto, + timestamp::{RelativeTime, TalerTimestamp}, + }, +}; use taler_macros::EnumMeta; +use tracing::info; -use crate::payto::PaytoCtx; +use crate::{ + api::{ + BankState, + account::{ + AccountReconfiguration, ChallengeContactData, Maybe, RegisterAccountRequest, + create_account, create_admin_account, + }, + bank_api, + }, + auth::{TOKEN_PREFIX, TokenScope}, + config::BankCfg, + db::{ + account::{ + CreationResult, PatchAuthResult, PatchResult, bank_info, reconfig, reconfig_password, + }, + dbinit, + gc::collect, + pool, + token::{TokenCreationResult, access}, + }, + payto::{BankPayto, PaytoCtx}, + pw::{PwCrypto, checkpw}, +}; pub mod api; pub mod auth; @@ -34,9 +79,168 @@ pub mod pw; pub const CONFIG_SOURCE: ConfigSource = ConfigSource::new("libeufin", "libeufin-bank", "libeufin-bank"); +#[derive(clap::Subcommand, Debug)] +pub enum Cmd { + /// Initialize libeufin-bank database + Dbinit { + /// Reset database (DANGEROUS: All existing data is lost) + #[arg(short, long)] + reset: bool, + }, + /// Change account password + Passwd { + /// Account username + username: CompactString, + /// Account password used for authentication + password: Option<String>, + }, + /// Create authentication token for a user + CreateToken { + /// Account username + #[arg(short, long, visible_alias("user"))] + username: CompactString, + + /// Scope for the token + #[arg(short, long)] + scope: TokenScope, + + /// Custom token validity duration + #[arg(short, long, value_name("forever|MICROS"))] + duration: Option<RelativeTime>, + + /// Optional token description + #[arg(long)] + description: Option<CompactString>, + + /// Make the token refreshable into a new token + #[arg(long)] + refreshable: bool, + + /// Current token to reuse if still valid + #[arg(long)] + current_token: Option<Base32<32>>, + }, + Serve, + /// Create an account, returning the payto://-URI associated with it + CreateAccount { + /// Optional JSON payload. If provided, CLI options are ignored. + #[arg()] + json: Option<String>, + + /// Account unique username + #[arg(short, long, visible_alias("user"), required_unless_present("json"))] + username: Option<CompactString>, + + /// Account password used for authentication + #[arg(short, long)] + password: Option<CompactString>, + + /// Legal name of the account owner + #[arg(long, required_unless_present("json"))] + name: Option<CompactString>, + + /// Make this account visible to anyone + #[arg(long)] + public: bool, + + /// Make this account a taler exchange + #[arg(long)] + exchange: bool, + + /// E-Mail address used for TAN transmission + #[arg(long)] + email: Option<CompactString>, + + /// Phone number used for TAN transmission + #[arg(long)] + phone: Option<CompactString>, + + /// Payto URI of a fiat account who receive cashout amount + #[arg(long, alias("cashout_payto_uri"))] + cashout_payto_uri: Option<IbanPayto>, + + /// Payto URI of this account + #[arg(long)] + payto_uri: Option<BankPayto>, + + /// Max debit allowed for this account + #[arg(long, alias("debit_threshold"))] + debit_threshold: Option<Amount>, + + /// Enables 2FA and set the TAN channel used for challenges + #[arg(long)] + #[arg(long, alias("tan_channel"))] + tan_channel: Vec<TanChannel>, + }, + /// Edit an existing account + EditAccount { + /// Account unique username + username: CompactString, + + /// Legal name of the account owner + #[arg(long)] + name: Option<CompactString>, + + /// Make this account visible to anyone + #[arg(long)] + public: Option<bool>, + + /// Make this account a taler exchange + #[arg(long)] + exchange: Option<bool>, + + /// E-Mail address used for TAN transmission + #[arg(long)] + email: Option<Maybe<CompactString>>, + + /// Phone number used for TAN transmission + #[arg(long)] + phone: Option<Maybe<CompactString>>, + + /// Payto URI of a fiat account who receive cashout amount + #[arg(long, alias("cashout_payto_uri"))] + cashout_payto_uri: Option<Maybe<IbanPayto>>, + + /// Max debit allowed for this account + #[arg(long, alias("debit_threshold"))] + debit_threshold: Option<Amount>, + + /// Enables 2FA and set the TAN channel used for challenges + #[arg(long, alias("tan_channel"))] + tan_channel: Option<Vec<TanChannel>>, + }, + /// Run garbage collection: abort expired operations and clean expired data + Gc, + /// Benchmark password hashing algorithm and configuration + BenchPwh, + #[command(subcommand)] + Config(ConfigCmd), +} + +#[derive(clap::Parser, Debug)] +#[command(long_version = long_version(), about, long_about = None)] +pub struct Args { + #[clap(flatten)] + pub common: CommonArgs, + + #[command(subcommand)] + pub cmd: Cmd, +} + // Allowed values for cashout TAN channels. #[derive( - sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, EnumMeta, Serialize, Deserialize, + sqlx::Type, + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + EnumMeta, + Serialize, + Deserialize, + clap::ValueEnum, )] #[sqlx(type_name = "tan_enum")] #[enum_meta(Str)] @@ -45,3 +249,311 @@ pub enum TanChannel { sms, email, } + +pub async fn run(cfg: Config, cmd: Cmd) -> anyhow::Result<()> { + match cmd { + Cmd::Dbinit { reset } => { + let cfg = BankCfg::parse(cfg)?; + let db = dbinit(&cfg, reset).await?; + match create_admin_account(&db, &cfg, None).await? { + CreationResult::Success(_) => { + info!("Admin's account created") + } + CreationResult::UsernameReuse => {} + CreationResult::PayToReuse + | CreationResult::UnknownConversionClass + | CreationResult::BonusBalanceInsufficient => unreachable!(), + } + } + Cmd::Passwd { username, password } => { + let cfg = BankCfg::parse(cfg)?; + let db = pool(&cfg).await?; + + let pw = match password { + Some(p) => p, + None => Password::new() + .with_prompt("Password") + .with_confirmation("Repeat for confirmation", "Values do not match, try again") + .interact()?, + }; + checkpw(&pw, cfg.pwd_check_quality)?; + match reconfig_password(&db, &cfg.pw_crypto, &username, &pw, None, true).await? { + PatchAuthResult::Success => { + info!("Password change for '{username}' account succeeded") + } + PatchAuthResult::UnknownAccount => { + bail!("Password change for '{username}' account failed: unknown account") + } + PatchAuthResult::OldPasswordMismatch | PatchAuthResult::TanRequired => { + unreachable!() + } + } + } + Cmd::CreateToken { + username, + scope, + duration, + description, + refreshable, + current_token, + } => { + let cfg = BankCfg::parse(cfg)?; + let db = pool(&cfg).await?; + let now = Timestamp::now(); + let new = if let Some(current) = current_token + && let Some(token) = access(&db, current.as_ref(), &now).await? + && token.expiration < TalerTimestamp::Timestamp(now) + && scope.logical().is_valid_scope(token.scope, refreshable) + { + current + } else { + let expiration = + match duration.unwrap_or(RelativeTime::Duration(Duration::from_hours(24))) { + RelativeTime::Forever => TalerTimestamp::Never, + RelativeTime::Duration(duration) => TalerTimestamp::Timestamp( + now.checked_add(duration) + .map_err(|e| anyhow!("Bad token duration: {e}"))?, + ), + }; + let token = Base32::<32>::secure_rand(); + match db::token::create( + &db, + &username, + token.as_ref(), + &now, + &expiration, + &scope, + refreshable, + description.as_deref(), + true, + ) + .await? + { + TokenCreationResult::Success(_) => token, + TokenCreationResult::TanRequired => unreachable!(), + } + }; + println!("{TOKEN_PREFIX}{new}"); + } + Cmd::Serve => { + let cfg = BankCfg::parse(cfg)?; + let db = pool(&cfg).await?; + if cfg.fiat.is_some() { + info!("Ensure exchange account exists"); + let Some(info) = bank_info(&db, &cfg.ctx, "exchange").await? else { + bail!( + "Exchange account missing: an exchange account named 'exchange' is required for conversion to be enabled" + ) + }; + if !info.is_exchange { + bail!( + "Account is not an exchange: an exchange account named 'exchange' is required for conversion to be enabled" + ) + } + info!("Ensure conversion is enabled"); + match std::fs::read_to_string(format!( + "{}/libeufin-conversion-setup.sql", + cfg.db_cfg.sql_dir + )) { + Ok(sql) => { + sqlx::raw_sql(&sql).execute(&db).await?; + } + Err(e) => { + bail!( + "Coult not read libeufin-conversion-setup.sql at '{}': {}", + cfg.db_cfg.sql_dir, + e.kind() + ) + } + }; + } else { + info!("Ensure conversion is disabled"); + match std::fs::read_to_string(format!( + "{}/libeufin-conversion-drop.sql", + cfg.db_cfg.sql_dir + )) { + Ok(sql) => { + sqlx::raw_sql(&sql).execute(&db).await?; + } + Err(e) => { + bail!( + "Coult not read libeufin-conversion-setup.sql at '{}': {}", + cfg.db_cfg.sql_dir, + e.kind() + ) + } + }; + } + let state = Arc::new(BankState::start(db, cfg).await); + bank_api(state.clone()) + .serve(&state.cfg.serve, None) + .await?; + } + Cmd::CreateAccount { + json, + username, + password, + name, + public: is_public, + exchange, + email, + phone, + cashout_payto_uri, + payto_uri, + debit_threshold, + tan_channel, + } => { + let cfg = BankCfg::parse(cfg)?; + let db = pool(&cfg).await?; + let req = if let Some(json_str) = json { + serde_json::from_str::<RegisterAccountRequest>(&json_str) + .map_err(|e| anyhow!("Failed to parse JSON: {e}"))? + } else { + RegisterAccountRequest { + username: username.unwrap(), + password: match password { + Some(p) => p, + None => Password::new() + .with_prompt("Password") + .with_confirmation( + "Repeat for confirmation", + "Values do not match, try again", + ) + .interact()? + .into(), + }, + name: name.unwrap(), + is_public, + is_taler_exchange: exchange, + contact_data: ChallengeContactData { + email: email.into(), + phone: phone.into(), + }, + cashout_payto_uri, + payto_uri, + debit_threshold, + tan_channel: None, + tan_channels: Some(tan_channel), + conversion_rate_class_id: None, + } + }; + + match create_account(&db, &cfg, &req, true).await? { + CreationResult::Success(full_payto) => { + info!("Acount '{}' created", req.username); + println!("{full_payto}") + } + CreationResult::UsernameReuse => { + bail!("Account username reuse '{}'", req.username) + } + CreationResult::PayToReuse => bail!("Bank internalPayToUri reuse"), + CreationResult::UnknownConversionClass => unreachable!(), + CreationResult::BonusBalanceInsufficient => { + bail!("Insufficient admin funds to grant bonus") + } + } + } + Cmd::EditAccount { + username, + name, + public, + exchange, + email, + phone, + cashout_payto_uri, + debit_threshold, + tan_channel, + } => { + let cfg = BankCfg::parse(cfg)?; + let db = pool(&cfg).await?; + let req = AccountReconfiguration { + name, + is_taler_exchange: exchange, + is_public: public, + contact_data: ChallengeContactData { + email: email.into(), + phone: phone.into(), + }, + cashout_payto_uri: cashout_payto_uri.into(), + debit_threshold, + tan_channel: Maybe::Missing, + tan_channels: tan_channel, + conversion_rate_class_id: Maybe::Missing, + }; + match reconfig( + &db, + &cfg.regional_currency, + &username, + &req, + true, + true, + true, + true, + ) + .await? + { + PatchResult::UnknownAccount => { + bail!("Password change for '{username}' account failed: unknown account") + } + PatchResult::NonAdminName + | PatchResult::NonAdminCashout + | PatchResult::NonAdminDebtLimit + | PatchResult::NonAdminConversionRateClass + | PatchResult::UnknownConversionClass + | PatchResult::Challenges(_) => unreachable!(), + PatchResult::MissingTanInfo(e) => bail!("{e}"), + PatchResult::Success => info!("Account '{username}' edited"), + } + } + Cmd::Gc => { + let cfg = BankCfg::parse(cfg)?; + let db = pool(&cfg).await?; + let now = Zoned::now(); + collect( + &db, + &(now.clone() + cfg.gc_abort_after).timestamp(), + &(now.clone() + cfg.gc_clean_after).timestamp(), + &(now + cfg.gc_delete_after).timestamp(), + ) + .await?; + } + Cmd::BenchPwh => { + let cfg = BankCfg::parse(cfg)?; + let pwc = cfg.pw_crypto; + + match pwc { + PwCrypto::Bcrypt { cost } => { + println!("Benching bcrypt with cost={cost} for 10s"); + } + PwCrypto::Sha256 => unreachable!(), + } + + let start = Instant::now(); + let stop = start + Duration::from_secs(10); + let mut count = 0; + + loop { + let now = Instant::now(); + if now < stop { + let password: String = rand::rng() + .sample_iter(&Alphanumeric) + .take(20) + .map(char::from) + .collect(); + + pwc.hashpw(&password); + count += 1; + } else { + let elapsed = start.elapsed().as_secs_f64(); + let per_sec = count as f64 / elapsed; + let iter_time_ms = (elapsed * 1000.0) / count as f64; + + println!("hash password in {iter_time_ms:.0}ms {per_sec:.2} H/s"); + break; + } + } + } + Cmd::Config(cmd) => cmd.run(&cfg)?, + } + Ok(()) +} diff --git a/crates/libeufin-bank/src/main.rs b/crates/libeufin-bank/src/main.rs @@ -0,0 +1,27 @@ +/* +* 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 clap::Parser as _; +use libeufin_bank::{Args, CONFIG_SOURCE, run}; +use taler_common::taler_main; + +fn main() { + let args = Args::parse(); + taler_main(CONFIG_SOURCE, args.common, |cfg| run(cfg, args.cmd)) +} diff --git a/crates/libeufin-ebics/src/cli.rs b/crates/libeufin-ebics/src/cli.rs @@ -22,7 +22,6 @@ use std::path::PathBuf; #[derive(clap::Parser, Debug, Clone)] pub struct EbicsLogs { /// Log EBICS transactions steps and payload at log_dir - #[clap(long = "debug-ebics", value_name = "log_dir")] - #[arg(global = true)] + #[arg(long("debug-ebics"), value_name("log_dir"), global(true))] pub dir: Option<PathBuf>, } diff --git a/crates/libeufin-ebics/src/test.rs b/crates/libeufin-ebics/src/test.rs @@ -530,28 +530,29 @@ impl TestBank { let sequence = Arc::new(Mutex::new(Vec::new())); let server_sequence = sequence.clone(); let bank = Arc::new(Mutex::new(EbicsState::new())); - let server = axum::Router::new() - .route( - "/", - post(async move |body: Bytes| { - let sequence: Sequence = server_sequence.lock().unwrap().pop().unwrap(); - let mut bank = bank.lock().unwrap(); - let res = sequence(&mut bank, &body); - match res { - EbicsRes::Ok(xml) => xml.into_response(), - EbicsRes::BadRequest => StatusCode::BAD_REQUEST.into_response(), - EbicsRes::Failure => StatusCode::SERVICE_UNAVAILABLE.into_response(), - } - }), - ) - .serve( - Serve::Unix { - path: sock_path.clone(), - permission: Permissions::from_mode(0o660), - }, - None, - ); - tokio::spawn(server); + let serve = Serve::Unix { + path: sock_path.clone(), + permission: Permissions::from_mode(0o660), + }; + tokio::spawn(async move { + axum::Router::new() + .route( + "/", + post(async move |body: Bytes| { + let sequence: Sequence = server_sequence.lock().unwrap().pop().unwrap(); + let mut bank = bank.lock().unwrap(); + let res = sequence(&mut bank, &body); + match res { + EbicsRes::Ok(xml) => xml.into_response(), + EbicsRes::BadRequest => StatusCode::BAD_REQUEST.into_response(), + EbicsRes::Failure => StatusCode::SERVICE_UNAVAILABLE.into_response(), + } + }), + ) + .serve(&serve, None) + .await + .unwrap() + }); wait_for_unix_socket(&sock_path).await; Self { dir, diff --git a/crates/libeufin-ebics/src/ws.rs b/crates/libeufin-ebics/src/ws.rs @@ -379,37 +379,41 @@ mod test { pub async fn params() { let path = "/tmp/libeufin_nexus_wss_test.sock"; std::fs::remove_file(path).ok(); - let server = axum::Router::new() - .route( - "/", - get(async |headers: HeaderMap, ws: WebSocketUpgrade| { - assert_eq!( - headers.get(AUTHORIZATION).map(|it| it.as_bytes()), - Some(AUTH_EXAMPLE.as_bytes()) - ); - ws.on_upgrade(async |mut it| { - for ex in NOTIFICATION_EXAMPLES { - it.send(Message::Text(Utf8Bytes::from_static(ex))) - .await - .unwrap(); - } - it.send(Message::Close(Some(CloseFrame { - code: 1000, - reason: Utf8Bytes::from_static("Test done"), - }))) - .await - .unwrap(); - }) - }), - ) - .serve( - taler_api::Serve::Unix { - path: path.into(), - permission: Permissions::from_mode(0o660), - }, - None, - ); - tokio::spawn(server); + + tokio::spawn(async move { + axum::Router::new() + .route( + "/", + get(async |headers: HeaderMap, ws: WebSocketUpgrade| { + assert_eq!( + headers.get(AUTHORIZATION).map(|it| it.as_bytes()), + Some(AUTH_EXAMPLE.as_bytes()) + ); + ws.on_upgrade(async |mut it| { + for ex in NOTIFICATION_EXAMPLES { + it.send(Message::Text(Utf8Bytes::from_static(ex))) + .await + .unwrap(); + } + it.send(Message::Close(Some(CloseFrame { + code: 1000, + reason: Utf8Bytes::from_static("Test done"), + }))) + .await + .unwrap(); + }) + }), + ) + .serve( + &taler_api::Serve::Unix { + path: path.into(), + permission: Permissions::from_mode(0o660), + }, + None, + ) + .await + .unwrap() + }); wait_for_unix_socket(path).await; let client = reqwest::ClientBuilder::new() .unix_socket(path) diff --git a/crates/libeufin-nexus/src/lib.rs b/crates/libeufin-nexus/src/lib.rs @@ -86,7 +86,7 @@ pub struct EbicsArgs { logs: EbicsLogs, /// Execute once and return, ignoring the 'FREQUENCY' configuration value - #[clap(long)] + #[arg(long)] transient: bool, } @@ -95,7 +95,7 @@ pub enum Cmd { /// Initialize libeufin-nexus database Dbinit { /// Reset database (DANGEROUS: All existing data is lost) - #[clap(long, short)] + #[arg(short, long)] reset: bool, }, /// Set up the EBICS subscriber @@ -104,15 +104,15 @@ pub enum Cmd { ebics_logs: EbicsLogs, /// Resubmits all the keys to the bank - #[clap(long)] + #[arg(long)] force_keys_resubmission: bool, /// Accepts the bank keys without interactively asking the user - #[clap(long)] + #[arg(long)] auto_accept_keys: bool, /// Generates the PDF with the client public keys to send to the bank - #[clap(long)] + #[arg(long)] generate_registration_pdf: bool, }, /// Submits pending initiated payments found in the database @@ -126,30 +126,30 @@ pub enum Cmd { ebics: EbicsArgs, /// Only supported in --transient mode, this option lets specify the earliest timestamp of the downloaded documents - #[clap(long, value_name = "YYYY-MM-DD")] + #[arg(long, value_name = "YYYY-MM-DD")] pinned_start: Option<Date>, /// Only supported in --transient mode, do not consume fetched documents - #[clap(long, requires = "transient")] + #[arg(long, requires = "transient")] peek: bool, /// Only supported in --transient mode, run a checkpoint - #[clap(long, requires = "transient")] + #[arg(long, requires = "transient")] checkpoint: bool, }, Serve {}, /// Initiate an outgoing payment InitiatePayment { /// The amount to transfer, payto 'amount' parameter takes the precedence - #[clap(long)] + #[arg(long)] amount: Option<Amount>, /// The payment subject, payto 'message' parameter takes the precedence - #[clap(long)] + #[arg(long)] subject: Option<CompactString>, /// The payment end-to-end UID - #[clap(long, alias = "request-uid")] + #[arg(long, alias("request-uid"))] end_to_end_id: Option<CompactString>, /// The credited account IBAN payto UR diff --git a/crates/libeufin-nexus/src/list.rs b/crates/libeufin-nexus/src/list.rs @@ -47,7 +47,7 @@ pub enum ListCmd { /// List incoming transactions Incoming { /// Only list transactions that are incomplete - #[clap(long)] + #[arg(long)] incomplete: bool, }, /// List outgoing transactions @@ -55,7 +55,7 @@ pub enum ListCmd { /// List initiated transactions Initiated { /// Only list transactions awaiting manual acknowledgment - #[clap(long, alias = "awaiting-ack")] + #[arg(long, alias("awaiting-ack"))] ack: bool, }, } diff --git a/crates/libeufin-nexus/src/testing.rs b/crates/libeufin-nexus/src/testing.rs @@ -71,15 +71,15 @@ pub enum TestingCmd { /// Genere a fake incoming payment FakeIncoming { /// The amount to transfer, payto 'amount' parameter takes the precedence - #[clap(long)] + #[arg(long)] amount: Option<Amount>, /// The payment credit fee - #[clap(long)] + #[arg(long)] credit_fee: Option<Amount>, /// The payment subject, payto 'message' parameter takes the precedence - #[clap(long)] + #[arg(long)] subject: Option<CompactString>, /// The debited account IBAN payto URI @@ -89,29 +89,29 @@ pub enum TestingCmd { List(ListCmd), /// Perform EBICS requests EbicsBtd { - #[clap(long = "type", default_value_t = CompactString::const_new("BTD"))] + #[arg(long("type"), default_value_t = CompactString::const_new("BTD"))] ty: CompactString, - #[clap(long)] + #[arg(long)] name: CompactString, - #[clap(long)] + #[arg(long)] scope: Option<CompactString>, - #[clap(long)] + #[arg(long)] message_name: CompactString, - #[clap(long)] + #[arg(long)] message_version: Option<CompactString>, - #[clap(long)] + #[arg(long)] container: Option<CompactString>, - #[clap(long)] + #[arg(long)] option: Option<CompactString>, #[clap(flatten)] logs: EbicsLogs, /// Erliest timestamp of the downloaded documents - #[clap(long, value_name = "YYYY-MM-DD")] + #[arg(long, value_name = "YYYY-MM-DD")] pinned_start: Option<Date>, /// Do not consume fetched documents - #[clap(long)] + #[arg(long)] peek: bool, - #[clap(long)] + #[arg(long)] dry_run: bool, }, /// Check transaction semantic @@ -247,7 +247,7 @@ impl TestingCmd { let (sender, mut receiver) = tokio::sync::mpsc::channel(10); tokio::join!( listen_for_notification(&ebics, &db, &client, &bank, sender), - async move { + async { while let Some(orders) = receiver.recv().await { debug!(target: "testing", "{orders:?}") } diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/auth/mfa.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/auth/mfa.kt @@ -117,7 +117,7 @@ suspend inline fun <reified B> ApplicationCall.receiveChallenge( default: B? = null ): Pair<B, Tans?> { // Parse body - val contentLenght = request.headers[HttpHeaders.ContentLength]?.toIntOrNull() + val contentLenght = request.headers[HttpHeaders.ContentLength]?.toIntOrNull() ?: 0 val body: B = if (contentLenght == 0 && default != null) { default } else {