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 }