libeufin

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

commit 4b91ab9d9fc69f3446eb46438d48bf49a650eaec
parent 1240fa344eeb628c02d2c6607c73b699cdba2b61
Author: Antoine A <>
Date:   Fri, 26 Jul 2024 14:53:37 +0200

bank: support bcrypt for password hashing and add a password hashing bench cmd

Diffstat:
Mbank/conf/test.conf | 1+
Mbank/conf/test_bonus.conf | 1+
Mbank/conf/test_no_conversion.conf | 1+
Mbank/conf/test_restrict.conf | 1+
Mbank/conf/test_tan_err.conf | 1+
Mbank/conf/test_with_fees.conf | 1+
Mbank/conf/test_x_taler_bank.conf | 1+
Mbank/src/main/kotlin/tech/libeufin/bank/Config.kt | 9++++++++-
Mbank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 80++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt | 6+++---
Mbank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt | 19++++++++++---------
Abank/src/main/kotlin/tech/libeufin/bank/cli/BenchPwh.kt | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/cli/ChangePw.kt | 4++--
Mbank/src/main/kotlin/tech/libeufin/bank/cli/LibeufinBank.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt | 20+++++++++++---------
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 3++-
Mbank/src/test/kotlin/bench.kt | 2+-
Mbank/src/test/kotlin/helpers.kt | 9++++++---
Mcommon/src/main/kotlin/TalerConfig.kt | 13++++++++-----
Mcommon/src/main/kotlin/crypto/CryptoUtil.kt | 6++++++
Mcommon/src/main/kotlin/crypto/PwCrypto.kt | 58++++++++++++++++++++++++++++++++++++----------------------
Mcommon/src/test/kotlin/CryptoUtilTest.kt | 19++++++++++++-------
Mcontrib/bank.conf | 15+++++++++++----
25 files changed, 232 insertions(+), 109 deletions(-)

diff --git a/bank/conf/test.conf b/bank/conf/test.conf @@ -12,6 +12,7 @@ allow_conversion = YES FIAT_CURRENCY = EUR tan_sms = libeufin-tan-file.sh tan_email = libeufin-tan-file.sh +PWD_HASH_CONFIG = { "cost": 4 } [libeufin-bankdb-postgres] CONFIG = postgresql:///libeufincheck diff --git a/bank/conf/test_bonus.conf b/bank/conf/test_bonus.conf @@ -5,6 +5,7 @@ IBAN_PAYTO_BIC = SANDBOXX REGISTRATION_BONUS = KUDOS:100 ALLOW_REGISTRATION = yes ALLOW_ACCOUNT_DELETION = yes +PWD_HASH_CONFIG = { "cost": 4 } [libeufin-bankdb-postgres] CONFIG = postgresql:///libeufincheck \ No newline at end of file diff --git a/bank/conf/test_no_conversion.conf b/bank/conf/test_no_conversion.conf @@ -4,6 +4,7 @@ WIRE_TYPE = iban IBAN_PAYTO_BIC = SANDBOXX ALLOW_REGISTRATION = yes ALLOW_ACCOUNT_DELETION = yes +PWD_HASH_CONFIG = { "cost": 4 } [libeufin-bankdb-postgres] CONFIG = postgresql:///libeufincheck \ No newline at end of file diff --git a/bank/conf/test_restrict.conf b/bank/conf/test_restrict.conf @@ -5,6 +5,7 @@ IBAN_PAYTO_BIC = SANDBOXX DEFAULT_DEBT_LIMIT = KUDOS:100 allow_conversion = YES FIAT_CURRENCY = EUR +PWD_HASH_CONFIG = { "cost": 4 } [libeufin-bankdb-postgres] CONFIG = postgresql:///libeufincheck \ No newline at end of file diff --git a/bank/conf/test_tan_err.conf b/bank/conf/test_tan_err.conf @@ -10,6 +10,7 @@ ALLOW_REGISTRATION = yes ALLOW_ACCOUNT_DELETION = yes ALLOW_EDIT_CASHOUT_PAYTO_URI = yes tan_sms = libeufin-tan-fail.sh +PWD_HASH_CONFIG = { "cost": 4 } [libeufin-bankdb-postgres] CONFIG = postgresql:///libeufincheck diff --git a/bank/conf/test_with_fees.conf b/bank/conf/test_with_fees.conf @@ -15,6 +15,7 @@ tan_email = libeufin-tan-file.sh wire_transfer_fees = KUDOS:0.1 min_wire_transfer_amount = KUDOS:0.01 max_wire_transfer_amount = KUDOS:100 +PWD_HASH_CONFIG = { "cost": 4 } [libeufin-bankdb-postgres] CONFIG = postgresql:///libeufincheck diff --git a/bank/conf/test_x_taler_bank.conf b/bank/conf/test_x_taler_bank.conf @@ -8,6 +8,7 @@ ALLOW_REGISTRATION = yes ALLOW_ACCOUNT_DELETION = yes ALLOW_EDIT_NAME = yes ALLOW_EDIT_CASHOUT_PAYTO_URI = yes +PWD_HASH_CONFIG = { "cost": 4 } [libeufin-bankdb-postgres] CONFIG = postgresql:///libeufincheck diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt @@ -21,6 +21,7 @@ package tech.libeufin.bank import kotlinx.serialization.Serializable import tech.libeufin.bank.db.Database import tech.libeufin.common.* +import tech.libeufin.common.crypto.PwCrypto import tech.libeufin.common.db.DatabaseConfig import java.nio.file.Path import java.time.Duration @@ -49,6 +50,7 @@ data class BankConfig( val tanChannels: Map<TanChannel, Pair<Path, Map<String, String>>>, val payto: BankPaytoCtx, val wireMethod: WireMethod, + val pwCrypto: PwCrypto, val gcAbortAfter: Duration, val gcCleanAfter: Duration, val gcDeleteAfter: Duration @@ -119,6 +121,10 @@ private fun TalerConfig.loadBankConfig(): BankConfig = section("libeufin-bank"). ) } + val pwCrypto = map("pwd_hash_algorithm", "password hash algorithm", mapOf( + "bcrypt" to json<PwCrypto.Bcrypt>("pwd_hash_config", "bcrypt JSON config").require() + )).require() + val ZERO = TalerAmount.zero(regionalCurrency) val MAX = TalerAmount.max(regionalCurrency) BankConfig( @@ -144,13 +150,14 @@ private fun TalerConfig.loadBankConfig(): BankConfig = section("libeufin-bank"). tanChannels = tanChannels, payto = payto, wireMethod = method, + pwCrypto = pwCrypto, gcAbortAfter = duration("gc_abort_after").require(), gcCleanAfter = duration("gc_clean_after").require(), gcDeleteAfter = duration("gc_delete_after").require(), ) } -fun TalerConfig.currencySpecificationFor(currency: String): CurrencySpecification +private fun TalerConfig.currencySpecificationFor(currency: String): CurrencySpecification = sections.find { val section = section(it) it.startsWith("CURRENCY-") && section.boolean("enabled").require() && section.string("code").require() == currency diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt @@ -101,7 +101,7 @@ fun Routing.conversionApi(db: Database, ctx: BankConfig) = conditional(ctx.allow } } } - authAdmin(db, TokenScope.readwrite) { + authAdmin(db, ctx.pwCrypto, TokenScope.readwrite) { post("/conversion-info/conversion-rate") { val req = call.receive<ConversionRate>() for (regionalAmount in sequenceOf(req.cashin_fee, req.cashin_tiny_amount, req.cashout_min_amount)) { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -47,45 +47,45 @@ import java.util.* private val logger: Logger = LoggerFactory.getLogger("libeufin-bank-api") -fun Routing.coreBankApi(db: Database, ctx: BankConfig) { +fun Routing.coreBankApi(db: Database, cfg: BankConfig) { get("/config") { call.respond( Config( - bank_name = ctx.name, - base_url = ctx.baseUrl, - currency = ctx.regionalCurrency, - currency_specification = ctx.regionalCurrencySpec, - allow_conversion = ctx.allowConversion, - allow_registrations = ctx.allowRegistration, - allow_deletions = ctx.allowAccountDeletion, - default_debit_threshold = ctx.defaultDebtLimit, - supported_tan_channels = ctx.tanChannels.keys, - allow_edit_name = ctx.allowEditName, - allow_edit_cashout_payto_uri = ctx.allowEditCashout, - wire_type = ctx.wireMethod, - wire_transfer_fees = ctx.wireTransferFees, - min_wire_transfer_amount = ctx.minAmount, - max_wire_transfer_amount = ctx.maxAmount + bank_name = cfg.name, + base_url = cfg.baseUrl, + currency = cfg.regionalCurrency, + currency_specification = cfg.regionalCurrencySpec, + allow_conversion = cfg.allowConversion, + allow_registrations = cfg.allowRegistration, + allow_deletions = cfg.allowAccountDeletion, + default_debit_threshold = cfg.defaultDebtLimit, + supported_tan_channels = cfg.tanChannels.keys, + allow_edit_name = cfg.allowEditName, + allow_edit_cashout_payto_uri = cfg.allowEditCashout, + wire_type = cfg.wireMethod, + wire_transfer_fees = cfg.wireTransferFees, + min_wire_transfer_amount = cfg.minAmount, + max_wire_transfer_amount = cfg.maxAmount ) ) } - authAdmin(db, TokenScope.readonly) { + authAdmin(db, cfg.pwCrypto, TokenScope.readonly) { get("/monitor") { val params = MonitorParams.extract(call.request.queryParameters) call.respond(db.monitor(params)) } } - coreBankTokenApi(db) - coreBankAccountsApi(db, ctx) - coreBankTransactionsApi(db, ctx) - coreBankWithdrawalApi(db, ctx) - coreBankCashoutApi(db, ctx) - coreBankTanApi(db, ctx) + coreBankTokenApi(db, cfg) + coreBankAccountsApi(db, cfg) + coreBankTransactionsApi(db, cfg) + coreBankWithdrawalApi(db, cfg) + coreBankCashoutApi(db, cfg) + coreBankTanApi(db, cfg) } -private fun Routing.coreBankTokenApi(db: Database) { +private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) { val TOKEN_DEFAULT_DURATION: Duration = Duration.ofDays(1L) - auth(db, TokenScope.refreshable) { + auth(db, cfg.pwCrypto, TokenScope.refreshable) { post("/accounts/{USERNAME}/token") { val existingToken = call.authToken val req = call.receive<TokenRequest>() @@ -136,7 +136,7 @@ private fun Routing.coreBankTokenApi(db: Database) { ) } } - auth(db, TokenScope.readonly) { + auth(db, cfg.pwCrypto, TokenScope.readonly) { delete("/accounts/{USERNAME}/token") { val token = call.authToken ?: throw badRequest("Basic auth not supported here.") db.token.delete(token) @@ -223,7 +223,8 @@ suspend fun createAccount( tanChannel = req.tan_channel, checkPaytoIdempotent = req.payto_uri != null, ctx = cfg.payto, - minCashout = req.min_cashout + minCashout = req.min_cashout, + pwCrypto = cfg.pwCrypto ) when (cfg.wireMethod) { @@ -297,7 +298,7 @@ suspend fun patchAccount( } private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { - authAdmin(db, TokenScope.readwrite, !ctx.allowRegistration) { + authAdmin(db, ctx.pwCrypto, TokenScope.readwrite, !ctx.allowRegistration) { post("/accounts") { val req = call.receive<RegisterAccountRequest>() when (val result = createAccount(db, ctx, req, isAdmin)) { @@ -319,6 +320,7 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { } auth( db, + ctx.pwCrypto, TokenScope.readwrite, allowAdmin = true, requireAdmin = !ctx.allowAccountDeletion @@ -349,7 +351,7 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { } } } - auth(db, TokenScope.readwrite, allowAdmin = true) { + auth(db, ctx.pwCrypto, TokenScope.readwrite, allowAdmin = true) { patch("/accounts/{USERNAME}") { val (req, challenge) = call.receiveChallenge<AccountReconfiguration>(db, Operation.account_reconfig) val res = patchAccount(db, ctx, req, username, isAdmin, challenge != null, challenge?.channel, challenge?.info) @@ -390,7 +392,7 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD ) } - when (db.account.reconfigPassword(username, req.new_password, req.old_password, isAdmin || challenge != null)) { + when (db.account.reconfigPassword(username, req.new_password, req.old_password, isAdmin || challenge != null, ctx.pwCrypto)) { AccountPatchAuthResult.Success -> call.respond(HttpStatusCode.NoContent) AccountPatchAuthResult.TanRequired -> call.respondChallenge(db, Operation.account_auth_reconfig, req) AccountPatchAuthResult.UnknownAccount -> throw unknownAccount(username) @@ -410,7 +412,7 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { call.respond(PublicAccountsResponse(publicAccounts)) } } - authAdmin(db, TokenScope.readonly) { + authAdmin(db, ctx.pwCrypto, TokenScope.readonly) { get("/accounts") { val params = AccountParams.extract(call.request.queryParameters) val accounts = db.account.pageAdmin(params, ctx.payto) @@ -421,7 +423,7 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { } } } - auth(db, TokenScope.readonly, allowAdmin = true) { + auth(db, ctx.pwCrypto, TokenScope.readonly, allowAdmin = true) { get("/accounts/{USERNAME}") { val account = db.account.get(username, ctx.payto) ?: throw unknownAccount(username) call.respond(account) @@ -430,7 +432,7 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { } private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { - auth(db, TokenScope.readonly, allowAdmin = true) { + auth(db, ctx.pwCrypto, TokenScope.readonly, allowAdmin = true) { get("/accounts/{USERNAME}/transactions") { val params = HistoryParams.extract(call.request.queryParameters) val bankAccount = call.bankInfo(db, ctx.payto) @@ -452,7 +454,7 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { call.respond(tx) } } - auth(db, TokenScope.readwrite) { + auth(db, ctx.pwCrypto, TokenScope.readwrite) { post("/accounts/{USERNAME}/transactions") { val (req, challenge) = call.receiveChallenge<TransactionCreateRequest>(db, Operation.bank_transaction) @@ -506,7 +508,7 @@ private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { } private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { - auth(db, TokenScope.readwrite) { + auth(db, ctx.pwCrypto, TokenScope.readwrite) { post("/accounts/{USERNAME}/withdrawals") { val req = call.receive<BankAccountCreateWithdrawalRequest>() req.amount?.run(ctx::checkRegionalCurrency) @@ -614,7 +616,7 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { } private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) { - auth(db, TokenScope.readwrite) { + auth(db, ctx.pwCrypto, TokenScope.readwrite) { post("/accounts/{USERNAME}/cashouts") { val (req, challenge) = call.receiveChallenge<CashoutRequest>(db, Operation.cashout) @@ -663,7 +665,7 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditio } } } - auth(db, TokenScope.readonly, allowAdmin = true) { + auth(db, ctx.pwCrypto, TokenScope.readonly, allowAdmin = true) { get("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}") { val id = call.longPath("CASHOUT_ID") val cashout = db.cashout.get(id, username) ?: throw notFound( @@ -682,7 +684,7 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditio } } } - authAdmin(db, TokenScope.readonly) { + authAdmin(db, ctx.pwCrypto, TokenScope.readonly) { get("/cashouts") { val params = PageParams.extract(call.request.queryParameters) val cashouts = db.cashout.pageAll(params) @@ -696,7 +698,7 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditio } private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) { - auth(db, TokenScope.readwrite) { + auth(db, ctx.pwCrypto, TokenScope.readwrite) { post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}") { val id = call.longPath("CHALLENGE_ID") val res = db.tan.send( diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt @@ -32,7 +32,7 @@ import tech.libeufin.common.RevenueConfig import tech.libeufin.common.RevenueIncomingHistory fun Routing.revenueApi(db: Database, ctx: BankConfig) { - auth(db, TokenScope.revenue) { + auth(db, ctx.pwCrypto, TokenScope.revenue) { get("/accounts/{USERNAME}/taler-revenue/config") { call.respond(RevenueConfig( currency = ctx.regionalCurrency diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt @@ -40,7 +40,7 @@ import java.time.Instant fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { - auth(db, TokenScope.readwrite) { + auth(db, ctx.pwCrypto, TokenScope.readwrite) { get("/accounts/{USERNAME}/taler-wire-gateway/config") { call.respond(WireGatewayConfig( currency = ctx.regionalCurrency @@ -82,7 +82,7 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { } } } - auth(db, TokenScope.readonly) { + auth(db, ctx.pwCrypto, TokenScope.readonly) { suspend fun <T> PipelineContext<Unit, ApplicationCall>.historyEndpoint( reduce: (List<T>, String) -> Any, dbLambda: suspend ExchangeDAO.(HistoryParams, Long, BankPaytoCtx) -> List<T> @@ -111,7 +111,7 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { historyEndpoint(::OutgoingHistory, ExchangeDAO::outgoingHistory) } } - authAdmin(db, TokenScope.readwrite) { + authAdmin(db, ctx.pwCrypto, TokenScope.readwrite) { post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") { val req = call.receive<AddIncomingRequest>() ctx.checkRegionalCurrency(req.amount) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt @@ -30,6 +30,7 @@ import tech.libeufin.bank.TokenScope import tech.libeufin.bank.db.AccountDAO.CheckPasswordResult import tech.libeufin.bank.db.Database import tech.libeufin.common.* +import tech.libeufin.common.crypto.PwCrypto import tech.libeufin.common.api.intercept import java.time.Instant @@ -61,17 +62,17 @@ val ApplicationCall.authToken: ByteArray? get() = attributes.getOrNull(AUTH_TOKE * * You can check is the currently authenticated user is admin using [isAdmin]. **/ -fun Route.authAdmin(db: Database, scope: TokenScope, enforce: Boolean = true, callback: Route.() -> Unit): Route = +fun Route.authAdmin(db: Database, pwCrypto: PwCrypto, scope: TokenScope, enforce: Boolean = true, callback: Route.() -> Unit): Route = intercept(callback) { if (enforce) { - val login = context.authenticateBankRequest(db, scope) + val login = context.authenticateBankRequest(db, pwCrypto, scope) if (login != "admin") { throw unauthorized("Only administrator allowed") } context.attributes.put(AUTH_IS_ADMIN, true) } else { val login = try { - context.authenticateBankRequest(db, scope) + context.authenticateBankRequest(db, pwCrypto, scope) } catch (e: Exception) { null } @@ -88,9 +89,9 @@ fun Route.authAdmin(db: Database, scope: TokenScope, enforce: Boolean = true, ca * * You can check is the currently authenticated user is admin using [isAdmin]. **/ -fun Route.auth(db: Database, scope: TokenScope, allowAdmin: Boolean = false, requireAdmin: Boolean = false, callback: Route.() -> Unit): Route = +fun Route.auth(db: Database, pwCrypto: PwCrypto ,scope: TokenScope, allowAdmin: Boolean = false, requireAdmin: Boolean = false, callback: Route.() -> Unit): Route = intercept(callback) { - val authLogin = context.authenticateBankRequest(db, scope) + val authLogin = context.authenticateBankRequest(db, pwCrypto, scope) if (requireAdmin && authLogin != "admin") { throw unauthorized("Only administrator allowed") } else { @@ -109,7 +110,7 @@ fun Route.auth(db: Database, scope: TokenScope, allowAdmin: Boolean = false, req * * Returns the authenticated customer login. */ -private suspend fun ApplicationCall.authenticateBankRequest(db: Database, requiredScope: TokenScope): String { +private suspend fun ApplicationCall.authenticateBankRequest(db: Database, pwCrypto: PwCrypto, requiredScope: TokenScope): String { val header = request.headers[HttpHeaders.Authorization] // Basic auth challenge @@ -127,7 +128,7 @@ private suspend fun ApplicationCall.authenticateBankRequest(db: Database, requir TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED ) return when (scheme) { - "Basic" -> doBasicAuth(db, content) + "Basic" -> doBasicAuth(db, content, pwCrypto) "Bearer" -> doTokenAuth(db, content, requiredScope) else -> throw unauthorized("Authorization method wrong or not supported") } @@ -138,13 +139,13 @@ private suspend fun ApplicationCall.authenticateBankRequest(db: Database, requir * * Returns the authenticated customer login */ -private suspend fun doBasicAuth(db: Database, encoded: String): String { +private suspend fun doBasicAuth(db: Database, encoded: String, pwCrypto: PwCrypto): String { val decoded = String(encoded.decodeBase64(), Charsets.UTF_8) val (login, plainPassword) = decoded.splitOnce(":") ?: throw badRequest( "Malformed Basic auth credentials found in the Authorization header", TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED ) - return when (db.account.checkPassword(login, plainPassword)) { + return when (db.account.checkPassword(login, plainPassword, pwCrypto)) { CheckPasswordResult.UnknownAccount -> throw unauthorized("Unknown account") CheckPasswordResult.PasswordMismatch -> throw unauthorized("Bad password") CheckPasswordResult.Success -> login diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/BenchPwh.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/BenchPwh.kt @@ -0,0 +1,64 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 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/> + */ + +package tech.libeufin.bank.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import tech.libeufin.bank.bankConfig +import tech.libeufin.bank.db.AccountDAO.AccountPatchAuthResult +import tech.libeufin.bank.logger +import tech.libeufin.bank.withDb +import tech.libeufin.common.CommonOption +import tech.libeufin.common.cliCmd +import tech.libeufin.common.crypto.PwCrypto + +class BenchPwh : CliktCommand("Benchmark password hashin algorithm and configuration", name = "bench-pwh") { + private val common by CommonOption() + + override fun run() = cliCmd(logger, common.log) { + val pwCrypto = bankConfig(common.config).pwCrypto + + when (pwCrypto) { + is PwCrypto.Bcrypt -> println("Benching bcrypt with cost=${pwCrypto.cost} for 10s") + } + + val start = System.currentTimeMillis() + val stop = start + 10000 // 10s + var count = 0 + + while (true) { + val now = System.currentTimeMillis() + if (now < stop) { + val password = (0..10).map { + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#".random() + }.joinToString("") + pwCrypto.hashpw(password) + count ++ + } else { + val elapsed = (now-start).toDouble() + val perSec = count.toDouble() / (elapsed / 1000.0) + val iterTime = elapsed / count.toDouble() + println("hash password in ${String.format("%.0f", iterTime)}ms ${String.format("%.2f", perSec)} H/s") + break + } + } + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/ChangePw.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/ChangePw.kt @@ -38,8 +38,8 @@ class ChangePw : CliktCommand("Change account password", name = "passwd") { ) override fun run() = cliCmd(logger, common.log) { - bankConfig(common.config).withDb { db, _ -> - val res = db.account.reconfigPassword(username, password, null, true) + bankConfig(common.config).withDb { db, cfg -> + val res = db.account.reconfigPassword(username, password, null, true, cfg.pwCrypto) when (res) { AccountPatchAuthResult.UnknownAccount -> throw Exception("Password change for '$username' account failed: unknown account") diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/LibeufinBank.kt b/bank/src/main/kotlin/tech/libeufin/bank/cli/LibeufinBank.kt @@ -29,7 +29,7 @@ import tech.libeufin.common.getVersion class LibeufinBank : CliktCommand() { init { versionOption(getVersion()) - subcommands(Serve(), DbInit(), CreateAccount(), EditAccount(), ChangePw(), GC(), CliConfigCmd(BANK_CONFIG_SOURCE)) + subcommands(Serve(), DbInit(), CreateAccount(), EditAccount(), ChangePw(), BenchPwh(), GC(), CliConfigCmd(BANK_CONFIG_SOURCE)) } override fun run() = Unit diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -52,7 +52,8 @@ class AccountDAO(private val db: Database) { tanChannel: TanChannel?, // Whether to check [internalPaytoUri] for idempotency checkPaytoIdempotent: Boolean, - ctx: BankPaytoCtx + ctx: BankPaytoCtx, + pwCrypto: PwCrypto ): AccountCreationResult = db.serializableTransaction { conn -> val timestamp = Instant.now().micros() val idempotent = conn.withStatement(""" @@ -83,7 +84,7 @@ class AccountDAO(private val db: Database) { setString(10, login) oneOrNull { Pair( - PwCrypto.checkpw(password, it.getString(1)).match && it.getBoolean(2), + pwCrypto.checkpw(password, it.getString(1)).match && it.getBoolean(2), it.getBankPayto("internal_payto_uri", "name", ctx) ) } @@ -125,7 +126,7 @@ class AccountDAO(private val db: Database) { """ ) { setString(1, login) - setString(2, PwCrypto.hashpw(password)) + setString(2, pwCrypto.hashpw(password)) setString(3, name) setString(4, email) setString(5, phone) @@ -411,7 +412,8 @@ class AccountDAO(private val db: Database) { login: String, newPw: String, oldPw: String?, - is2fa: Boolean + is2fa: Boolean, + pwCrypto: PwCrypto ): AccountPatchAuthResult = db.serializableTransaction { conn -> val (customerId, currentPwh, tanRequired) = conn.withStatement(""" SELECT customer_id, password_hash, (NOT ? AND tan_channel IS NOT NULL) @@ -425,10 +427,10 @@ class AccountDAO(private val db: Database) { } if (tanRequired) { AccountPatchAuthResult.TanRequired - } else if (oldPw != null && !PwCrypto.checkpw(oldPw, currentPwh).match) { + } else if (oldPw != null && !pwCrypto.checkpw(oldPw, currentPwh).match) { AccountPatchAuthResult.OldPasswordMismatch } else { - val newPwh = PwCrypto.hashpw(newPw) + val newPwh = pwCrypto.hashpw(newPw) conn.withStatement("UPDATE customers SET password_hash=? WHERE customer_id=?") { setString(1, newPwh) setLong(2, customerId) @@ -447,7 +449,7 @@ class AccountDAO(private val db: Database) { } /** Check password of account [login] against [pw], rehashing it if outdated */ - suspend fun checkPassword(login: String, pw: String): CheckPasswordResult { + suspend fun checkPassword(login: String, pw: String, pwCrypto: PwCrypto): CheckPasswordResult { // Get user current password hash val info = db.serializable( "SELECT customer_id, password_hash FROM customers WHERE login=? AND deleted_at IS NULL" @@ -461,12 +463,12 @@ class AccountDAO(private val db: Database) { val (customerId, currentPwh) = info // Check password - val check = PwCrypto.checkpw(pw, currentPwh) + val check = pwCrypto.checkpw(pw, currentPwh) if (!check.match) return CheckPasswordResult.PasswordMismatch // Reshah if outdated if (check.outdated) { - val newPwh = PwCrypto.hashpw(pw) + val newPwh = pwCrypto.hashpw(pw) db.serializable( "UPDATE customers SET password_hash=? where customer_id=? AND password_hash=?" ) { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -128,7 +128,8 @@ suspend fun createAdminAccount(db: Database, cfg: BankConfig, pw: String? = null cashoutPayto = null, tanChannel = null, minCashout = null, - ctx = cfg.payto + ctx = cfg.payto, + pwCrypto = cfg.pwCrypto ) } diff --git a/bank/src/test/kotlin/bench.kt b/bank/src/test/kotlin/bench.kt @@ -50,7 +50,7 @@ class Bench { val mid = amount / 2 val copyManager = conn.getCopyAPI() - val password = PwCrypto.hashpw("password") + val password = PwCrypto.Bcrypt(cost = 4).hashpw("password") fun gen(table: String, lambda: (Int) -> String) { println("Gen rows for $table") val full = buildString(150*amount) { diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -95,7 +95,8 @@ fun bankSetup( cashoutPayto = null, tanChannel = null, minCashout = null, - ctx = cfg.payto + ctx = cfg.payto, + pwCrypto = cfg.pwCrypto )) assertIs<AccountCreationResult.Success>(db.account.create( login = "exchange", @@ -112,7 +113,8 @@ fun bankSetup( cashoutPayto = null, tanChannel = null, minCashout = null, - ctx = cfg.payto + ctx = cfg.payto, + pwCrypto = cfg.pwCrypto )) assertIs<AccountCreationResult.Success>(db.account.create( login = "customer", @@ -129,7 +131,8 @@ fun bankSetup( cashoutPayto = null, tanChannel = null, minCashout = null, - ctx = cfg.payto + ctx = cfg.payto, + pwCrypto = cfg.pwCrypto )) // Create admin account assertIs<AccountCreationResult.Success>(createAdminAccount(db, cfg, "admin-password")) diff --git a/common/src/main/kotlin/TalerConfig.kt b/common/src/main/kotlin/TalerConfig.kt @@ -51,7 +51,7 @@ class TalerConfigError private constructor (m: String, cause: Throwable? = null) } /** Configuration error when converting option value */ -private class ValueError(val msg: String): Exception(msg) +class ValueError(val msg: String): Exception(msg) /** Information about how the configuration is loaded */ data class ConfigSource( @@ -447,7 +447,7 @@ class TalerConfigSection internal constructor( val section: String ) { /** Setup an accessor/converted for a [type] at [option] using [transform] */ - private fun <T> option(option: String, type: String, transform: TalerConfigSection.(String) -> T): TalerConfigOption<T> { + fun <T> option(option: String, type: String, transform: TalerConfigSection.(String) -> T): TalerConfigOption<T> { val canonOption = option.uppercase() var raw = entries?.get(canonOption) if (raw == "") raw = null @@ -511,15 +511,18 @@ class TalerConfigSection internal constructor( } } - /** Access [option] as Map<String, String> */ - fun jsonMap(option: String) = option(option, "json key/value map") { + /** Access [option] as JSON object [T] */ + inline fun <reified T> json(option: String, type: String) = option(option, type) { try { - Json.decodeFromString<Map<String, String>>(it) + Json.decodeFromString<T>(it) } catch (e: Exception) { throw ValueError("'$it' is malformed") } } + /** Access [option] as Map<String, String> */ + fun jsonMap(option: String) = json<Map<String, String>>(option, "json key/value map") + /** Access [option] as TalerAmount */ fun amount(option: String, currency: String) = option(option, "amount") { val amount = try { diff --git a/common/src/main/kotlin/crypto/CryptoUtil.kt b/common/src/main/kotlin/crypto/CryptoUtil.kt @@ -26,6 +26,7 @@ import org.bouncycastle.asn1.x509.KeyUsage import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder import org.bouncycastle.crypto.generators.Argon2BytesGenerator +import org.bouncycastle.crypto.generators.BCrypt import org.bouncycastle.crypto.params.Argon2Parameters import org.bouncycastle.jce.provider.BouncyCastleProvider import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder @@ -261,6 +262,11 @@ object CryptoUtil { fun hashStringSHA256(input: String): ByteArray = MessageDigest.getInstance("SHA-256").digest(input.toByteArray(Charsets.UTF_8)) + + fun bcrypt(password: String, salt: ByteArray, cost: Int): ByteArray { + val pwBytes = BCrypt.passwordToByteArray(password.toCharArray()) + return BCrypt.generate(pwBytes, salt, cost) + } fun hashArgon2id(password: String, salt: ByteArray): ByteArray { // OSWAP recommended config https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id diff --git a/common/src/main/kotlin/crypto/PwCrypto.kt b/common/src/main/kotlin/crypto/PwCrypto.kt @@ -19,6 +19,8 @@ package tech.libeufin.common.crypto +import kotlinx.serialization.Serializable +import tech.libeufin.common.decodeBase64 import tech.libeufin.common.encodeBase64 import tech.libeufin.common.rand import java.security.SecureRandom @@ -29,24 +31,32 @@ data class PasswordHashCheck( ) /** Cryptographic operations for secure password storage and verification */ -object PwCrypto { - private val SECURE_RNG = SecureRandom() +sealed interface PwCrypto { + @Serializable + data class Bcrypt(val cost: Int = 8): PwCrypto - /** Hash [pw] using the strongest supported hashing method */ + /** Hash [pw] using [cfg] hashing method */ fun hashpw(pw: String): String { - val salt = ByteArray(16).rand(SECURE_RNG) - /* TODO Argon2id - val pwh = CryptoUtil.hashArgon2id(pw, salt) - return "argon2id\$${salt.encodeBase64()}\$${pwh.encodeBase64()}" - */ - val saltEncoded = salt.encodeBase64() - val pwh = CryptoUtil.hashStringSHA256("$saltEncoded|$pw").encodeBase64() - return "sha256-salted\$$saltEncoded\$$pwh" + when (this) { + is Bcrypt -> { + val salt = ByteArray(16).rand(SECURE_RNG) + val pwh = CryptoUtil.bcrypt(pw, salt, cost) + return "bcrypt\$$cost\$${salt.encodeBase64()}\$${pwh.encodeBase64()}" + } + /* TODO Argon2id + "argon2id" -> { + require(components.size == 3) { "bad password hash format" } + val salt = components[1].decodeBase64() + val hash = components[2] + val pwh = CryptoUtil.hashArgon2id(pw, salt).encodeBase64() + PasswordHashCheck(pwh == hash, false) + } */ + } } /** Check whether [pw] match hashed [storedPwHash] and if it should be rehashed */ fun checkpw(pw: String, storedPwHash: String): PasswordHashCheck { - val components = storedPwHash.split('$', limit = 4) + val components = storedPwHash.split('$', limit = 5) return when (val algo = components[0]) { "sha256" -> { require(components.size == 2) { "bad password hash format" } @@ -59,17 +69,20 @@ object PwCrypto { val salt = components[1] val hash = components[2] val pwh = CryptoUtil.hashStringSHA256("$salt|$pw").encodeBase64() - PasswordHashCheck(pwh == hash, false) + PasswordHashCheck(pwh == hash, true) + } + "bcrypt" -> { + require(components.size == 4) { "bad password hash format" } + val cost = components[1].toInt() + val salt = components[2].decodeBase64() + val hash = components[3] + val pwh = CryptoUtil.bcrypt(pw, salt, cost).encodeBase64() + PasswordHashCheck(pwh == hash, !(this is Bcrypt && this.cost == cost)) } - /* TODO Argon2id - "argon2id" -> { - require(components.size == 3) { "bad password hash format" } - val salt = components[1].decodeBase64() - val hash = components[2] - val pwh = CryptoUtil.hashArgon2id(pw, salt).encodeBase64() - PasswordHashCheck(pwh == hash, false) - } */ else -> throw Exception("unsupported hash algo: '$algo'") } } -} + companion object { + private val SECURE_RNG = SecureRandom() + } +} +\ No newline at end of file diff --git a/common/src/test/kotlin/CryptoUtilTest.kt b/common/src/test/kotlin/CryptoUtilTest.kt @@ -110,16 +110,21 @@ class CryptoUtilTest { @Test fun passwordHashing() { val password = "myinsecurepw" - + val pwCrypto = PwCrypto.Bcrypt(cost = 4) // Check roundtrip - val hash = PwCrypto.hashpw(password) - assertEquals(PwCrypto.checkpw(password, hash), PasswordHashCheck(true, false)) - assertEquals(PwCrypto.checkpw("other", hash), PasswordHashCheck(false, false)) + val hash = pwCrypto.hashpw(password) + assertEquals(pwCrypto.checkpw(password, hash), PasswordHashCheck(true, false)) + assertEquals(pwCrypto.checkpw("other", hash), PasswordHashCheck(false, false)) - // Check outdated + // Check outdated algorithm val pwh = CryptoUtil.hashStringSHA256(password).encodeBase64() val outdatedHash = "sha256\$$pwh" - assertEquals(PwCrypto.checkpw(password, outdatedHash), PasswordHashCheck(true, true)) - assertEquals(PwCrypto.checkpw("other", outdatedHash), PasswordHashCheck(false, true)) + assertEquals(pwCrypto.checkpw(password, outdatedHash), PasswordHashCheck(true, true)) + assertEquals(pwCrypto.checkpw("other", outdatedHash), PasswordHashCheck(false, true)) + + // Check outdated options + val betterCrypto = pwCrypto.copy(cost = 5) + assertEquals(betterCrypto.checkpw(password, hash), PasswordHashCheck(true, true)) + assertEquals(betterCrypto.checkpw("other", hash), PasswordHashCheck(false, true)) } } diff --git a/contrib/bank.conf b/contrib/bank.conf @@ -57,11 +57,11 @@ WIRE_TYPE = # Path to TAN challenge transmission script via email. If not specified, this TAN channel will not be supported. # TAN_EMAIL = libeufin-tan-email.sh -# Environment variables for the sms TAN script. -# TAN_SMS_ENV = AUTH_TOKEN=secret-token +# Environment variables for the sms TAN script as a single line JSON object +# TAN_SMS_ENV = { "AUTH_TOKEN": "secret-token" } -# Environment variables for the email TAN script. -# TAN_EMAIL_ENV = AUTH_TOKEN=secret-token +# Environment variables for the email TAN script as a single line JSON object +# TAN_EMAIL_ENV = { "AUTH_TOKEN": "secret-token" } # How "libeufin-bank serve" serves its API, this can either be tcp or unix SERVE = tcp @@ -84,6 +84,13 @@ SPA = $DATADIR/spa/ # Exchange that is suggested to wallets when withdrawing. # SUGGESTED_WITHDRAWAL_EXCHANGE = https://exchange.demo.taler.net/ +# Password hash algorithm, this can only be bcrypt +PWD_HASH_ALGORITHM = bcrypt + +# Password hash algorithm configuration as a single line JSON object +# When PWD_HASH_ALGORITHM = bcrypt you can configure cost +PWD_HASH_CONFIG = { "cost": 8 } + # Time after which pending operations are aborted during garbage collection GC_ABORT_AFTER = 15m