libeufin

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

lib.rs (17532B)


      1 /*
      2 * This file is part of LibEuFin.
      3 * Copyright (C) 2026 Taler Systems S.A.
      4 
      5 * LibEuFin is free software; you can redistribute it and/or modify
      6 * it under the terms of the GNU Affero General Public License as
      7 * published by the Free Software Foundation; either version 3, or
      8 * (at your option) any later version.
      9 
     10 * LibEuFin is distributed in the hope that it will be useful, but
     11 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12 * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13 * Public License for more details.
     14 
     15 * You should have received a copy of the GNU Affero General Public
     16 * License along with LibEuFin; see the file COPYING.  If not, see
     17 * <http://www.gnu.org/licenses/>
     18 */
     19 
     20 use std::{
     21     sync::Arc,
     22     time::{Duration, Instant},
     23 };
     24 
     25 use anyhow::{anyhow, bail};
     26 use compact_str::CompactString;
     27 use dialoguer::Password;
     28 use jiff::{Timestamp, Zoned};
     29 use rand::{RngExt as _, distr::Alphanumeric};
     30 use serde::{Deserialize, Serialize};
     31 use taler_api::api::TalerRouter;
     32 use taler_build::long_version;
     33 use taler_common::{
     34     CommonArgs,
     35     cli::ConfigCmd,
     36     config::Config,
     37     types::{
     38         amount::Amount,
     39         base32::Base32,
     40         payto::IbanPayto,
     41         time::{RelativeTime, TalerTimestamp},
     42     },
     43 };
     44 use taler_macros::EnumMeta;
     45 use tracing::info;
     46 
     47 use crate::{
     48     api::{
     49         BankState,
     50         account::{
     51             AccountReconfiguration, ChallengeContactData, Maybe, RegisterAccountRequest,
     52             create_account, create_admin_account,
     53         },
     54         bank_api,
     55     },
     56     auth::{TOKEN_PREFIX, TokenScope},
     57     config::BankCfg,
     58     db::{
     59         account::{
     60             CreationResult, PatchAuthResult, PatchResult, bank_info, reconfig, reconfig_password,
     61         },
     62         dbinit,
     63         gc::collect,
     64         pool, setup_conversion,
     65         token::{TokenCreationResult, access},
     66     },
     67     payto::{BankPayto, PaytoCtx},
     68     pw::{PwCrypto, checkpw},
     69 };
     70 
     71 pub mod api;
     72 pub mod auth;
     73 #[cfg(test)]
     74 pub mod bench;
     75 pub mod config;
     76 pub mod constants;
     77 pub mod db;
     78 pub mod mfa;
     79 pub mod payto;
     80 pub mod pw;
     81 
     82 #[derive(clap::Subcommand, Debug)]
     83 pub enum Cmd {
     84     /// Initialize libeufin-bank database
     85     Dbinit {
     86         /// Reset database (DANGEROUS: All existing data is lost)
     87         #[arg(short, long)]
     88         reset: bool,
     89     },
     90     /// Change account password
     91     Passwd {
     92         /// Account username
     93         username: CompactString,
     94         /// Account password used for authentication
     95         password: Option<String>,
     96     },
     97     /// Create authentication token for a user
     98     CreateToken {
     99         /// Account username
    100         #[arg(short, long, visible_alias("user"))]
    101         username: CompactString,
    102 
    103         /// Scope for the token
    104         #[arg(short, long)]
    105         scope: TokenScope,
    106 
    107         /// Custom token validity duration
    108         #[arg(short, long, value_name("forever|MICROS"))]
    109         duration: Option<RelativeTime>,
    110 
    111         /// Optional token description
    112         #[arg(long)]
    113         description: Option<CompactString>,
    114 
    115         /// Make the token refreshable into a new token
    116         #[arg(long)]
    117         refreshable: bool,
    118 
    119         /// Current token to reuse if still valid
    120         #[arg(long)]
    121         current_token: Option<Base32<32>>,
    122     },
    123     /// Run libeufin-bank HTTP server
    124     Serve,
    125     /// Create an account, returning the payto://-URI associated with it
    126     CreateAccount {
    127         /// Optional JSON payload. If provided, CLI options are ignored.
    128         #[arg()]
    129         json: Option<String>,
    130 
    131         /// Account unique username
    132         #[arg(short, long, visible_alias("user"), required_unless_present("json"))]
    133         username: Option<CompactString>,
    134 
    135         /// Account password used for authentication
    136         #[arg(short, long)]
    137         password: Option<CompactString>,
    138 
    139         /// Legal name of the account owner
    140         #[arg(long, required_unless_present("json"))]
    141         name: Option<CompactString>,
    142 
    143         /// Make this account visible to anyone
    144         #[arg(long)]
    145         public: bool,
    146 
    147         /// Make this account a taler exchange
    148         #[arg(long)]
    149         exchange: bool,
    150 
    151         /// E-Mail address used for TAN transmission
    152         #[arg(long)]
    153         email: Option<CompactString>,
    154 
    155         /// Phone number used for TAN transmission
    156         #[arg(long)]
    157         phone: Option<CompactString>,
    158 
    159         /// Payto URI of a fiat account who receive cashout amount
    160         #[arg(long, alias("cashout_payto_uri"))]
    161         cashout_payto_uri: Option<IbanPayto>,
    162 
    163         /// Payto URI of this account
    164         #[arg(long)]
    165         payto_uri: Option<BankPayto>,
    166 
    167         /// Max debit allowed for this account
    168         #[arg(long, alias("debit_threshold"))]
    169         debit_threshold: Option<Amount>,
    170 
    171         /// Enables 2FA and set the TAN channel used for challenges
    172         #[arg(long)]
    173         #[arg(long, alias("tan_channel"))]
    174         tan_channel: Vec<TanChannel>,
    175     },
    176     /// Edit an existing account
    177     EditAccount {
    178         /// Account unique username
    179         username: CompactString,
    180 
    181         /// Legal name of the account owner
    182         #[arg(long)]
    183         name: Option<CompactString>,
    184 
    185         /// Make this account visible to anyone
    186         #[arg(long)]
    187         public: Option<bool>,
    188 
    189         /// Make this account a taler exchange
    190         #[arg(long)]
    191         exchange: Option<bool>,
    192 
    193         /// E-Mail address used for TAN transmission
    194         #[arg(long)]
    195         email: Option<Maybe<CompactString>>,
    196 
    197         /// Phone number used for TAN transmission
    198         #[arg(long)]
    199         phone: Option<Maybe<CompactString>>,
    200 
    201         /// Payto URI of a fiat account who receive cashout amount
    202         #[arg(long, alias("cashout_payto_uri"))]
    203         cashout_payto_uri: Option<Maybe<IbanPayto>>,
    204 
    205         /// Max debit allowed for this account
    206         #[arg(long, alias("debit_threshold"))]
    207         debit_threshold: Option<Amount>,
    208 
    209         /// Enables 2FA and set the TAN channel used for challenges
    210         #[arg(long, alias("tan_channel"))]
    211         tan_channel: Option<Vec<TanChannel>>,
    212     },
    213     /// Run garbage collection: abort expired operations and clean expired data
    214     Gc,
    215     /// Benchmark password hashing algorithm and configuration
    216     BenchPwh,
    217     #[command(subcommand)]
    218     Config(ConfigCmd),
    219 }
    220 
    221 #[derive(clap::Parser, Debug)]
    222 #[command(long_version = long_version(), about, long_about = None)]
    223 pub struct Args {
    224     #[clap(flatten)]
    225     pub common: CommonArgs,
    226 
    227     #[command(subcommand)]
    228     pub cmd: Cmd,
    229 }
    230 
    231 // Allowed values for cashout TAN channels.
    232 #[derive(
    233     sqlx::Type,
    234     Debug,
    235     Clone,
    236     Copy,
    237     PartialEq,
    238     Eq,
    239     PartialOrd,
    240     Ord,
    241     EnumMeta,
    242     Serialize,
    243     Deserialize,
    244     clap::ValueEnum,
    245 )]
    246 #[sqlx(type_name = "tan_enum")]
    247 #[enum_meta(Str)]
    248 #[allow(non_camel_case_types)]
    249 pub enum TanChannel {
    250     sms,
    251     email,
    252 }
    253 
    254 pub async fn run(cfg: &Config, cmd: Cmd) -> anyhow::Result<()> {
    255     match cmd {
    256         Cmd::Dbinit { reset } => {
    257             let cfg = BankCfg::parse(cfg)?;
    258             let db = dbinit(&cfg.db_cfg, reset, cfg.fiat.is_some()).await?;
    259             match create_admin_account(&db, &cfg, None).await? {
    260                 CreationResult::Success(_) => {
    261                     info!("Admin's account created")
    262                 }
    263                 CreationResult::UsernameReuse => {}
    264                 CreationResult::PayToReuse
    265                 | CreationResult::UnknownConversionClass
    266                 | CreationResult::BonusBalanceInsufficient => unreachable!(),
    267             }
    268         }
    269         Cmd::Passwd { username, password } => {
    270             let cfg = BankCfg::parse(cfg)?;
    271             let db = pool(&cfg.db_cfg).await?;
    272 
    273             let pw = match password {
    274                 Some(p) => p,
    275                 None => Password::new()
    276                     .with_prompt("Password")
    277                     .with_confirmation("Repeat for confirmation", "Values do not match, try again")
    278                     .interact()?,
    279             };
    280             checkpw(&pw, cfg.pwd_check_quality)?;
    281             match reconfig_password(&db, &cfg.pw_crypto, &username, &pw, None, true).await? {
    282                 PatchAuthResult::Success => {
    283                     info!("Password change for '{username}' account succeeded")
    284                 }
    285                 PatchAuthResult::UnknownAccount => {
    286                     bail!("Password change for '{username}' account failed: unknown account")
    287                 }
    288                 PatchAuthResult::OldPasswordMismatch | PatchAuthResult::TanRequired => {
    289                     unreachable!()
    290                 }
    291             }
    292         }
    293         Cmd::CreateToken {
    294             username,
    295             scope,
    296             duration,
    297             description,
    298             refreshable,
    299             current_token,
    300         } => {
    301             let cfg = BankCfg::parse(cfg)?;
    302             let db = pool(&cfg.db_cfg).await?;
    303             let now = Timestamp::now();
    304             let new = if let Some(current) = current_token
    305                 && let Some(token) = access(&db, current.as_ref(), &now).await?
    306                 && token.expiration > TalerTimestamp::Timestamp(now)
    307                 && scope.logical().is_valid_scope(token.scope, refreshable)
    308             {
    309                 current
    310             } else {
    311                 let expiration =
    312                     match duration.unwrap_or(RelativeTime::Duration(Duration::from_hours(24))) {
    313                         RelativeTime::Forever => TalerTimestamp::Never,
    314                         RelativeTime::Duration(duration) => TalerTimestamp::Timestamp(
    315                             now.checked_add(duration)
    316                                 .map_err(|e| anyhow!("Bad token duration: {e}"))?,
    317                         ),
    318                     };
    319                 let token = Base32::<32>::secure_rand();
    320                 match db::token::create(
    321                     &db,
    322                     &username,
    323                     token.as_ref(),
    324                     &now,
    325                     &expiration,
    326                     &scope,
    327                     refreshable,
    328                     description.as_deref(),
    329                     true,
    330                 )
    331                 .await?
    332                 {
    333                     TokenCreationResult::Success(_) => token,
    334                     TokenCreationResult::TanRequired => unreachable!(),
    335                 }
    336             };
    337             println!("{TOKEN_PREFIX}{new}");
    338         }
    339         Cmd::Serve => {
    340             let cfg = BankCfg::parse(cfg)?;
    341             let db = pool(&cfg.db_cfg).await?;
    342             if cfg.fiat.is_some() {
    343                 setup_conversion(&cfg.db_cfg, db.acquire().await?.as_mut(), true).await?;
    344                 info!("Ensure exchange account exists");
    345                 let Some(info) = bank_info(&db, &cfg.ctx, "exchange").await? else {
    346                     bail!(
    347                         "Exchange account missing: an exchange account named 'exchange' is required for conversion to be enabled"
    348                     )
    349                 };
    350                 if !info.is_exchange {
    351                     bail!(
    352                         "Account is not an exchange: an exchange account named 'exchange' is required for conversion to be enabled"
    353                     )
    354                 }
    355             } else {
    356                 setup_conversion(&cfg.db_cfg, db.acquire().await?.as_mut(), false).await?;
    357             }
    358             let state = Arc::new(BankState::start(db, cfg).await);
    359             bank_api(state.clone())
    360                 .serve(&state.cfg.serve, None)
    361                 .await?;
    362         }
    363         Cmd::CreateAccount {
    364             json,
    365             username,
    366             password,
    367             name,
    368             public: is_public,
    369             exchange,
    370             email,
    371             phone,
    372             cashout_payto_uri,
    373             payto_uri,
    374             debit_threshold,
    375             tan_channel,
    376         } => {
    377             let cfg = BankCfg::parse(cfg)?;
    378             let db = pool(&cfg.db_cfg).await?;
    379             let req = if let Some(json_str) = json {
    380                 serde_json::from_str::<RegisterAccountRequest>(&json_str)
    381                     .map_err(|e| anyhow!("Failed to parse JSON: {e}"))?
    382             } else {
    383                 RegisterAccountRequest {
    384                     username: username.unwrap(),
    385                     password: match password {
    386                         Some(p) => p,
    387                         None => Password::new()
    388                             .with_prompt("Password")
    389                             .with_confirmation(
    390                                 "Repeat for confirmation",
    391                                 "Values do not match, try again",
    392                             )
    393                             .interact()?
    394                             .into(),
    395                     },
    396                     name: name.unwrap(),
    397                     is_public,
    398                     is_taler_exchange: exchange,
    399                     contact_data: ChallengeContactData {
    400                         email: email.into(),
    401                         phone: phone.into(),
    402                     },
    403                     cashout_payto_uri,
    404                     payto_uri,
    405                     debit_threshold,
    406                     tan_channel: None,
    407                     tan_channels: Some(tan_channel),
    408                     conversion_rate_class_id: None,
    409                 }
    410             };
    411 
    412             match create_account(&db, &cfg, &req, true).await? {
    413                 CreationResult::Success(full_payto) => {
    414                     info!("Account '{}' created", req.username);
    415                     println!("{full_payto}")
    416                 }
    417                 CreationResult::UsernameReuse => {
    418                     bail!("Account username reuse '{}'", req.username)
    419                 }
    420                 CreationResult::PayToReuse => bail!("Bank internalPayToUri reuse"),
    421                 CreationResult::UnknownConversionClass => unreachable!(),
    422                 CreationResult::BonusBalanceInsufficient => {
    423                     bail!("Insufficient admin funds to grant bonus")
    424                 }
    425             }
    426         }
    427         Cmd::EditAccount {
    428             username,
    429             name,
    430             public,
    431             exchange,
    432             email,
    433             phone,
    434             cashout_payto_uri,
    435             debit_threshold,
    436             tan_channel,
    437         } => {
    438             let cfg = BankCfg::parse(cfg)?;
    439             let db = pool(&cfg.db_cfg).await?;
    440             let req = AccountReconfiguration {
    441                 name,
    442                 is_taler_exchange: exchange,
    443                 is_public: public,
    444                 contact_data: ChallengeContactData {
    445                     email: email.into(),
    446                     phone: phone.into(),
    447                 },
    448                 cashout_payto_uri: cashout_payto_uri.into(),
    449                 debit_threshold,
    450                 tan_channel: Maybe::Missing,
    451                 tan_channels: tan_channel,
    452                 conversion_rate_class_id: Maybe::Missing,
    453             };
    454             match reconfig(
    455                 &db,
    456                 &cfg.regional_currency,
    457                 &username,
    458                 &req,
    459                 true,
    460                 true,
    461                 true,
    462                 true,
    463             )
    464             .await?
    465             {
    466                 PatchResult::UnknownAccount => {
    467                     bail!("Password change for '{username}' account failed: unknown account")
    468                 }
    469                 PatchResult::NonAdminName
    470                 | PatchResult::NonAdminCashout
    471                 | PatchResult::NonAdminDebtLimit
    472                 | PatchResult::NonAdminConversionRateClass
    473                 | PatchResult::UnknownConversionClass
    474                 | PatchResult::Challenges(_) => unreachable!(),
    475                 PatchResult::MissingTanInfo(e) => bail!("{e}"),
    476                 PatchResult::Success => info!("Account '{username}' edited"),
    477             }
    478         }
    479         Cmd::Gc => {
    480             let cfg = BankCfg::parse(cfg)?;
    481             let db = pool(&cfg.db_cfg).await?;
    482             let now = Zoned::now();
    483             collect(
    484                 &db,
    485                 &(now.clone() - cfg.gc_abort_after).timestamp(),
    486                 &(now.clone() - cfg.gc_clean_after).timestamp(),
    487                 &(now - cfg.gc_delete_after).timestamp(),
    488             )
    489             .await?;
    490         }
    491         Cmd::BenchPwh => {
    492             let cfg = BankCfg::parse(cfg)?;
    493             let pwc = cfg.pw_crypto;
    494 
    495             match pwc {
    496                 PwCrypto::Bcrypt { cost } => {
    497                     println!("Benching bcrypt with cost={cost} for 10s");
    498                 }
    499                 PwCrypto::Sha256 => unreachable!(),
    500             }
    501 
    502             let start = Instant::now();
    503             let stop = start + Duration::from_secs(10);
    504             let mut count = 0;
    505 
    506             loop {
    507                 let now = Instant::now();
    508                 if now < stop {
    509                     let password: String = rand::rng()
    510                         .sample_iter(&Alphanumeric)
    511                         .take(20)
    512                         .map(char::from)
    513                         .collect();
    514 
    515                     pwc.hashpw(&password);
    516                     count += 1;
    517                 } else {
    518                     let elapsed = start.elapsed().as_secs_f64();
    519                     let per_sec = count as f64 / elapsed;
    520                     let iter_time_ms = (elapsed * 1000.0) / count as f64;
    521 
    522                     println!("hash password in {iter_time_ms:.0}ms {per_sec:.2} H/s");
    523                     break;
    524                 }
    525             }
    526         }
    527         Cmd::Config(cmd) => cmd.run(cfg)?,
    528     }
    529     Ok(())
    530 }