libeufin

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

commit 9af4fd9bcb338f20e75254f1aaad9ee7b1c0df47
parent 73c011ab2f5679bd1fb966318c53790097241540
Author: Antoine A <>
Date:   Wed,  6 Mar 2024 09:55:03 +0100

Improve password crypto and TAN documentation

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 4++--
Dbank/src/main/kotlin/tech/libeufin/bank/Tan.kt | 95-------------------------------------------------------------------------------
Abank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt | 5+++--
Mbank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt | 9+++++----
Dcommon/src/main/kotlin/CryptoUtil.kt | 317-------------------------------------------------------------------------------
Acommon/src/main/kotlin/crypto/password.kt | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/src/main/kotlin/crypto/utils.kt | 292+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/src/main/kotlin/strings.kt | 9+++++----
Mcommon/src/test/kotlin/CryptoUtilTest.kt | 5+++--
Mebics/src/main/kotlin/Ebics.kt | 2+-
Mebics/src/main/kotlin/ebics_h004/EbicsRequest.kt | 2+-
Mebics/src/main/kotlin/ebics_h004/EbicsResponse.kt | 2+-
Mebics/src/main/kotlin/ebics_h005/Ebics3Request.kt | 2+-
Mebics/src/main/kotlin/ebics_h005/Ebics3Response.kt | 2+-
Mebics/src/test/kotlin/EbicsMessagesTest.kt | 2+-
Mebics/src/test/kotlin/SignatureDataTest.kt | 2+-
Mebics/src/test/kotlin/XmlUtilTest.kt | 2+-
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt | 1+
Mnexus/src/main/kotlin/tech/libeufin/nexus/KeyFiles.kt | 2+-
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt | 1+
Mnexus/src/test/kotlin/CliTest.kt | 2+-
Mnexus/src/test/kotlin/Keys.kt | 2+-
Mnexus/src/test/kotlin/MySerializers.kt | 2+-
24 files changed, 506 insertions(+), 438 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -318,7 +318,7 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: BankConfig) { requireAdmin = !ctx.allowAccountDeletion ) { delete("/accounts/{USERNAME}") { - val challenge = call.challenge(db, Operation.account_delete) + val challenge = call.checkChallenge(db, Operation.account_delete) // Not deleting reserved names. if (RESERVED_ACCOUNTS.contains(username)) @@ -511,7 +511,7 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { } post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") { val id = call.uuidParameter("withdrawal_id") - val challenge = call.challenge(db, Operation.withdrawal) + val challenge = call.checkChallenge(db, Operation.withdrawal) when (db.withdrawal.confirm(username, id, Instant.now(), challenge != null)) { WithdrawalConfirmationResult.UnknownOperation -> throw notFound( "Withdrawal operation $id not found", diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt b/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt @@ -1,95 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2023 Stanisci and Dold. - - * 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 - -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import kotlinx.serialization.json.Json -import tech.libeufin.bank.auth.username -import tech.libeufin.bank.db.Database -import tech.libeufin.bank.db.TanDAO.Challenge -import java.security.SecureRandom -import java.text.DecimalFormat -import java.time.Instant - - -suspend inline fun <reified B> ApplicationCall.respondChallenge( - db: Database, - op: Operation, - body: B, - channel: TanChannel? = null, - info: String? = null -) { - val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), body) - val code = Tan.genCode() - val id = db.tan.new( - login = username, - op = op, - body = json, - code = code, - now = Instant.now(), - retryCounter = TAN_RETRY_COUNTER, - validityPeriod = TAN_VALIDITY_PERIOD, - channel = channel, - info = info - ) - respond( - status = HttpStatusCode.Accepted, - message = TanChallenge(id) - ) -} - -suspend inline fun <reified B> ApplicationCall.receiveChallenge( - db: Database, - op: Operation -): Pair<B, Challenge?> { - val id = request.headers["X-Challenge-Id"]?.toLongOrNull() - return if (id != null) { - val challenge = db.tan.challenge(id, username, op)!! - Pair(Json.decodeFromString(challenge.body), challenge) - } else { - Pair(this.receive(), null) - } -} - -suspend fun ApplicationCall.challenge( - db: Database, - op: Operation -): Challenge? { - val id = request.headers["X-Challenge-Id"]?.toLongOrNull() - return if (id != null) { - db.tan.challenge(id, username, op)!! - } else { - null - } -} - -object Tan { - private val CODE_FORMAT = DecimalFormat("00000000") - private val SECURE_RNG = SecureRandom() - - fun genCode(): String { - val rand = SECURE_RNG.nextInt(100000000) - val code = CODE_FORMAT.format(rand) - return code - } -} - diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/Tan.kt @@ -0,0 +1,109 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023-2024 Stanisci and Dold. + + * 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 + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import kotlinx.serialization.json.Json +import tech.libeufin.bank.auth.username +import tech.libeufin.bank.db.Database +import tech.libeufin.bank.db.TanDAO.Challenge +import java.security.SecureRandom +import java.text.DecimalFormat +import java.time.Instant + +/** + * Generate a TAN challenge for an [op] request with [body] and + * respond to the HTTP request with a TAN challenge. + * + * If [channel] and [info] are present, they will be used + * to send the TAN code, otherwise defaults will be used. + */ +suspend inline fun <reified B> ApplicationCall.respondChallenge( + db: Database, + op: Operation, + body: B, + channel: TanChannel? = null, + info: String? = null +) { + val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), body) + val code = Tan.genCode() + val id = db.tan.new( + login = username, + op = op, + body = json, + code = code, + now = Instant.now(), + retryCounter = TAN_RETRY_COUNTER, + validityPeriod = TAN_VALIDITY_PERIOD, + channel = channel, + info = info + ) + respond( + status = HttpStatusCode.Accepted, + message = TanChallenge(id) + ) +} + +/** + * Retrieve a confirmed challenge and its body for [op] from the database + * if the challenge header is defined, otherwise extract the HTTP body. + */ +suspend inline fun <reified B> ApplicationCall.receiveChallenge( + db: Database, + op: Operation +): Pair<B, Challenge?> { + val id = request.headers["X-Challenge-Id"]?.toLongOrNull() + return if (id != null) { + val challenge = db.tan.challenge(id, username, op)!! + Pair(Json.decodeFromString(challenge.body), challenge) + } else { + Pair(this.receive(), null) + } +} + +/** + * Retrieve a confirmed challenge body for [op] if the challenge header is defined + */ +suspend fun ApplicationCall.checkChallenge( + db: Database, + op: Operation +): Challenge? { + val id = request.headers["X-Challenge-Id"]?.toLongOrNull() + return if (id != null) { + db.tan.challenge(id, username, op)!! + } else { + null + } +} + +object Tan { + private val CODE_FORMAT = DecimalFormat("00000000") + private val SECURE_RNG = SecureRandom() + + /** Generate a secure random TAN code */ + fun genCode(): String { + val rand = SECURE_RNG.nextInt(100000000) + val code = CODE_FORMAT.format(rand) + return code + } +} + diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt @@ -28,6 +28,7 @@ import io.ktor.util.pipeline.* import tech.libeufin.bank.* import tech.libeufin.bank.db.Database import tech.libeufin.common.* +import tech.libeufin.common.crypto.* import java.time.Instant /** Used to store if the currently authenticated user is admin */ @@ -134,13 +135,13 @@ private suspend fun ApplicationCall.authenticateBankRequest(db: Database, requir * Returns the authenticated customer login */ private suspend fun doBasicAuth(db: Database, encoded: String): String { - val decoded = String(base64ToBytes(encoded), Charsets.UTF_8) + 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 ) val hash = db.account.passwordHash(login) ?: throw unauthorized("Unknown account") - if (!CryptoUtil.checkpw(plainPassword, hash)) throw unauthorized("Bad password") + if (!PwCrypto.checkpw(plainPassword, hash)) throw unauthorized("Bad password") return login } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -21,6 +21,7 @@ package tech.libeufin.bank.db import tech.libeufin.bank.* import tech.libeufin.common.* +import tech.libeufin.common.crypto.* import java.time.Instant /** Data access logic for accounts */ @@ -78,7 +79,7 @@ class AccountDAO(private val db: Database) { setString(9, tanChannel?.name) setString(10, login) oneOrNull { - CryptoUtil.checkpw(password, it.getString(1)) && it.getBoolean(2) + PwCrypto.checkpw(password, it.getString(1)) && it.getBoolean(2) } } @@ -118,7 +119,7 @@ class AccountDAO(private val db: Database) { """ ).run { setString(1, login) - setString(2, CryptoUtil.hashpw(password)) + setString(2, PwCrypto.hashpw(password)) setString(3, name) setString(4, email) setString(5, phone) @@ -392,13 +393,13 @@ class AccountDAO(private val db: Database) { } if (tanRequired) { AccountPatchAuthResult.TanRequired - } else if (oldPw != null && !CryptoUtil.checkpw(oldPw, currentPwh)) { + } else if (oldPw != null && !PwCrypto.checkpw(oldPw, currentPwh)) { AccountPatchAuthResult.OldPasswordMismatch } else { val stmt = conn.prepareStatement(""" UPDATE customers SET password_hash=? where login=? """) - stmt.setString(1, CryptoUtil.hashpw(newPw)) + stmt.setString(1, PwCrypto.hashpw(newPw)) stmt.setString(2, login) stmt.executeUpdateCheck() AccountPatchAuthResult.Success diff --git a/common/src/main/kotlin/CryptoUtil.kt b/common/src/main/kotlin/CryptoUtil.kt @@ -1,317 +0,0 @@ -/* - * 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.common - -import org.bouncycastle.jce.provider.BouncyCastleProvider -import java.io.ByteArrayOutputStream -import java.io.InputStream -import java.math.BigInteger -import java.security.* -import java.security.interfaces.RSAPrivateCrtKey -import java.security.interfaces.RSAPublicKey -import java.security.spec.* -import javax.crypto.* -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.PBEKeySpec -import javax.crypto.spec.PBEParameterSpec -import javax.crypto.spec.SecretKeySpec - -/** - * Helpers for dealing with cryptographic operations in EBICS / LibEuFin. - */ -object CryptoUtil { - - /** - * RSA key pair. - */ - data class RsaCrtKeyPair(val private: RSAPrivateCrtKey, val public: RSAPublicKey) - - // FIXME(dold): This abstraction needs to be improved. - class EncryptionResult( - val encryptedTransactionKey: ByteArray, - val pubKeyDigest: ByteArray, - val encryptedData: ByteArray, - /** - * This key needs to be reused between different upload phases. - */ - val plainTransactionKey: SecretKey? = null - ) - - private val bouncyCastleProvider = BouncyCastleProvider() - - /** - * Load an RSA private key from its binary PKCS#8 encoding. - */ - fun loadRsaPrivateKey(encodedPrivateKey: ByteArray): RSAPrivateCrtKey { - val spec = PKCS8EncodedKeySpec(encodedPrivateKey) - val priv = KeyFactory.getInstance("RSA").generatePrivate(spec) - if (priv !is RSAPrivateCrtKey) - throw Exception("wrong encoding") - return priv - } - - /** - * Load an RSA public key from its binary X509 encoding. - */ - fun loadRsaPublicKey(encodedPublicKey: ByteArray): RSAPublicKey { - val spec = X509EncodedKeySpec(encodedPublicKey) - val pub = KeyFactory.getInstance("RSA").generatePublic(spec) - if (pub !is RSAPublicKey) - throw Exception("wrong encoding") - return pub - } - - /** - * Load an RSA public key from its binary X509 encoding. - */ - fun getRsaPublicFromPrivate(rsaPrivateCrtKey: RSAPrivateCrtKey): RSAPublicKey { - val spec = RSAPublicKeySpec(rsaPrivateCrtKey.modulus, rsaPrivateCrtKey.publicExponent) - val pub = KeyFactory.getInstance("RSA").generatePublic(spec) - if (pub !is RSAPublicKey) - throw Exception("wrong encoding") - return pub - } - - /** - * Generate a fresh RSA key pair. - * - * @param nbits size of the modulus in bits - */ - fun generateRsaKeyPair(nbits: Int): RsaCrtKeyPair { - val gen = KeyPairGenerator.getInstance("RSA") - gen.initialize(nbits) - val pair = gen.genKeyPair() - val priv = pair.private - val pub = pair.public - if (priv !is RSAPrivateCrtKey) - throw Exception("key generation failed") - if (pub !is RSAPublicKey) - throw Exception("key generation failed") - return RsaCrtKeyPair(priv, pub) - } - - /** - * Load an RSA public key from its components. - * - * @param exponent - * @param modulus - * @return key - */ - fun loadRsaPublicKeyFromComponents(modulus: ByteArray, exponent: ByteArray): RSAPublicKey { - val modulusBigInt = BigInteger(1, modulus) - val exponentBigInt = BigInteger(1, exponent) - - val keyFactory = KeyFactory.getInstance("RSA") - val tmp = RSAPublicKeySpec(modulusBigInt, exponentBigInt) - return keyFactory.generatePublic(tmp) as RSAPublicKey - } - - /** - * Hash an RSA public key according to the EBICS standard (EBICS 2.5: 4.4.1.2.3). - */ - fun getEbicsPublicKeyHash(publicKey: RSAPublicKey): ByteArray { - val keyBytes = ByteArrayOutputStream() - keyBytes.writeBytes(publicKey.publicExponent.toUnsignedHexString().lowercase().trimStart('0').toByteArray()) - keyBytes.write(' '.code) - keyBytes.writeBytes(publicKey.modulus.toUnsignedHexString().lowercase().trimStart('0').toByteArray()) - // println("buffer before hashing: '${keyBytes.toString(Charsets.UTF_8)}'") - val digest = MessageDigest.getInstance("SHA-256") - return digest.digest(keyBytes.toByteArray()) - } - - fun encryptEbicsE002(data: ByteArray, encryptionPublicKey: RSAPublicKey): EncryptionResult { - val keygen = KeyGenerator.getInstance("AES", bouncyCastleProvider) - keygen.init(128) - val transactionKey = keygen.generateKey() - return encryptEbicsE002withTransactionKey( - data, - encryptionPublicKey, - transactionKey - ) - } - /** - * Encrypt data according to the EBICS E002 encryption process. - */ - fun encryptEbicsE002withTransactionKey( - data: ByteArray, - encryptionPublicKey: RSAPublicKey, - transactionKey: SecretKey - ): EncryptionResult { - val symmetricCipher = Cipher.getInstance( - "AES/CBC/X9.23Padding", - bouncyCastleProvider - ) - val ivParameterSpec = IvParameterSpec(ByteArray(16)) - symmetricCipher.init(Cipher.ENCRYPT_MODE, transactionKey, ivParameterSpec) - val encryptedData = symmetricCipher.doFinal(data) - val asymmetricCipher = Cipher.getInstance( - "RSA/None/PKCS1Padding", - bouncyCastleProvider - ) - asymmetricCipher.init(Cipher.ENCRYPT_MODE, encryptionPublicKey) - val encryptedTransactionKey = asymmetricCipher.doFinal(transactionKey.encoded) - val pubKeyDigest = getEbicsPublicKeyHash(encryptionPublicKey) - return EncryptionResult( - encryptedTransactionKey, - pubKeyDigest, - encryptedData, - transactionKey - ) - } - - fun decryptEbicsE002(enc: EncryptionResult, privateKey: RSAPrivateCrtKey): ByteArray { - return decryptEbicsE002( - enc.encryptedTransactionKey, - enc.encryptedData.inputStream(), - privateKey - ).readBytes() - } - - fun decryptEbicsE002( - encryptedTransactionKey: ByteArray, - encryptedData: InputStream, - privateKey: RSAPrivateCrtKey - ): CipherInputStream { - val asymmetricCipher = Cipher.getInstance( - "RSA/None/PKCS1Padding", - bouncyCastleProvider - ) - asymmetricCipher.init(Cipher.DECRYPT_MODE, privateKey) - val transactionKeyBytes = asymmetricCipher.doFinal(encryptedTransactionKey) - val secretKeySpec = SecretKeySpec(transactionKeyBytes, "AES") - val symmetricCipher = Cipher.getInstance( - "AES/CBC/X9.23Padding", - bouncyCastleProvider - ) - val ivParameterSpec = IvParameterSpec(ByteArray(16)) - symmetricCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec) - return CipherInputStream(encryptedData, symmetricCipher) - } - - /** - * Signing algorithm corresponding to the EBICS A006 signing process. - * - * Note that while [data] can be arbitrary-length data, in EBICS, the order - * data is *always* hashed *before* passing it to the signing algorithm, which again - * uses a hash internally. - */ - fun signEbicsA006(data: ByteArray, privateKey: RSAPrivateCrtKey): ByteArray { - val signature = Signature.getInstance("SHA256withRSA/PSS", bouncyCastleProvider) - signature.setParameter(PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1)) - signature.initSign(privateKey) - signature.update(data) - return signature.sign() - } - - fun verifyEbicsA006(sig: ByteArray, data: ByteArray, publicKey: RSAPublicKey): Boolean { - val signature = Signature.getInstance("SHA256withRSA/PSS", bouncyCastleProvider) - signature.setParameter(PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1)) - signature.initVerify(publicKey) - signature.update(data) - return signature.verify(sig) - } - - fun digestEbicsOrderA006(orderData: ByteArray): ByteArray { - val digest = MessageDigest.getInstance("SHA-256") - for (b in orderData) { - when (b) { - '\r'.code.toByte(), '\n'.code.toByte(), (26).toByte() -> Unit - else -> digest.update(b) - } - } - return digest.digest() - } - - fun decryptKey(data: EncryptedPrivateKeyInfo, passphrase: String): RSAPrivateCrtKey { - /* make key out of passphrase */ - val pbeKeySpec = PBEKeySpec(passphrase.toCharArray()) - val keyFactory = SecretKeyFactory.getInstance(data.algName) - val secretKey = keyFactory.generateSecret(pbeKeySpec) - /* Make a cipher */ - val cipher = Cipher.getInstance(data.algName) - cipher.init( - Cipher.DECRYPT_MODE, - secretKey, - data.algParameters // has hash count and salt - ) - /* Ready to decrypt */ - val decryptedKeySpec: PKCS8EncodedKeySpec = data.getKeySpec(cipher) - val priv = KeyFactory.getInstance("RSA").generatePrivate(decryptedKeySpec) - if (priv !is RSAPrivateCrtKey) - throw Exception("wrong encoding") - return priv - } - - fun encryptKey(data: ByteArray, passphrase: String): ByteArray { - /* Cipher parameters: salt and hash count */ - val hashIterations = 30 - val salt = ByteArray(8) - SecureRandom().nextBytes(salt) - val pbeParameterSpec = PBEParameterSpec(salt, hashIterations) - /* *Other* cipher parameters: symmetric key (from password) */ - val pbeAlgorithm = "PBEWithSHA1AndDESede" - val pbeKeySpec = PBEKeySpec(passphrase.toCharArray()) - val keyFactory = SecretKeyFactory.getInstance(pbeAlgorithm) - val secretKey = keyFactory.generateSecret(pbeKeySpec) - /* Make a cipher */ - val cipher = Cipher.getInstance(pbeAlgorithm) - cipher.init(Cipher.ENCRYPT_MODE, secretKey, pbeParameterSpec) - /* ready to encrypt now */ - val cipherText = cipher.doFinal(data) - /* Must now bundle a PKCS#8-compatible object, that contains - * algorithm, salt and hash count information */ - val bundleAlgorithmParams = AlgorithmParameters.getInstance(pbeAlgorithm) - bundleAlgorithmParams.init(pbeParameterSpec) - val bundle = EncryptedPrivateKeyInfo(bundleAlgorithmParams, cipherText) - return bundle.encoded - } - - fun hashStringSHA256(input: String): ByteArray { - return MessageDigest.getInstance("SHA-256").digest(input.toByteArray(Charsets.UTF_8)) - } - - fun hashpw(pw: String): String { - val saltBytes = ByteArray(8) - SecureRandom().nextBytes(saltBytes) - val salt = bytesToBase64(saltBytes) - val pwh = bytesToBase64(hashStringSHA256("$salt|$pw")) - return "sha256-salted\$$salt\$$pwh" - } - - fun checkpw(pw: String, storedPwHash: String): Boolean { - val components = storedPwHash.split('$') - when (val algo = components[0]) { - "sha256" -> { // Support legacy unsalted passwords - if (components.size != 2) throw Exception("bad password hash") - val hash = components[1] - val pwh = bytesToBase64(hashStringSHA256(pw)) - return pwh == hash - } - "sha256-salted" -> { - if (components.size != 3) throw Exception("bad password hash") - val salt = components[1] - val hash = components[2] - val pwh = bytesToBase64(hashStringSHA256("$salt|$pw")) - return pwh == hash - } - else -> throw Exception("unsupported hash algo: '$algo'") - } - } -} diff --git a/common/src/main/kotlin/crypto/password.kt b/common/src/main/kotlin/crypto/password.kt @@ -0,0 +1,73 @@ +/* + * 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.common.crypto + +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.math.BigInteger +import java.security.* +import java.security.interfaces.RSAPrivateCrtKey +import java.security.interfaces.RSAPublicKey +import java.security.spec.* +import javax.crypto.* +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.PBEParameterSpec +import javax.crypto.spec.SecretKeySpec +import java.util.Base64 +import tech.libeufin.common.* + +/** Cryptographic operations for secure password storage and verification */ +object PwCrypto { + // TODO Use a real password hashing method to store passwords + + private val SECURE_RNG = SecureRandom() + + /** Hash [pw] using the strongest supported hashing method */ + fun hashpw(pw: String): String { + val saltBytes = ByteArray(8) + SECURE_RNG.nextBytes(saltBytes) + val salt = saltBytes.encodeBase64() + val pwh = CryptoUtil.hashStringSHA256("$salt|$pw").encodeBase64() + return "sha256-salted\$$salt\$$pwh" + } + + /** Check whether [pw] match hashed [storedPwHash] */ + fun checkpw(pw: String, storedPwHash: String): Boolean { + val components = storedPwHash.split('$') + when (val algo = components[0]) { + "sha256" -> { // Support legacy unsalted passwords + if (components.size != 2) throw Exception("bad password hash") + val hash = components[1] + val pwh = CryptoUtil.hashStringSHA256(pw).encodeBase64() + return pwh == hash + } + "sha256-salted" -> { + if (components.size != 3) throw Exception("bad password hash") + val salt = components[1] + val hash = components[2] + val pwh = CryptoUtil.hashStringSHA256("$salt|$pw").encodeBase64() + return pwh == hash + } + else -> throw Exception("unsupported hash algo: '$algo'") + } + } +} diff --git a/common/src/main/kotlin/crypto/utils.kt b/common/src/main/kotlin/crypto/utils.kt @@ -0,0 +1,291 @@ +/* + * 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.common.crypto + +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.math.BigInteger +import java.security.* +import java.security.interfaces.RSAPrivateCrtKey +import java.security.interfaces.RSAPublicKey +import java.security.spec.* +import javax.crypto.* +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.PBEParameterSpec +import javax.crypto.spec.SecretKeySpec +import tech.libeufin.common.* + +/** + * Helpers for dealing with cryptographic operations in EBICS / LibEuFin. + */ +object CryptoUtil { + // TODO split common and ebics crypto + + /** + * RSA key pair. + */ + data class RsaCrtKeyPair(val private: RSAPrivateCrtKey, val public: RSAPublicKey) + + // FIXME(dold): This abstraction needs to be improved. + class EncryptionResult( + val encryptedTransactionKey: ByteArray, + val pubKeyDigest: ByteArray, + val encryptedData: ByteArray, + /** + * This key needs to be reused between different upload phases. + */ + val plainTransactionKey: SecretKey? = null + ) + + private val bouncyCastleProvider = BouncyCastleProvider() + + /** + * Load an RSA private key from its binary PKCS#8 encoding. + */ + fun loadRsaPrivateKey(encodedPrivateKey: ByteArray): RSAPrivateCrtKey { + val spec = PKCS8EncodedKeySpec(encodedPrivateKey) + val priv = KeyFactory.getInstance("RSA").generatePrivate(spec) + if (priv !is RSAPrivateCrtKey) + throw Exception("wrong encoding") + return priv + } + + /** + * Load an RSA public key from its binary X509 encoding. + */ + fun loadRsaPublicKey(encodedPublicKey: ByteArray): RSAPublicKey { + val spec = X509EncodedKeySpec(encodedPublicKey) + val pub = KeyFactory.getInstance("RSA").generatePublic(spec) + if (pub !is RSAPublicKey) + throw Exception("wrong encoding") + return pub + } + + /** + * Load an RSA public key from its binary X509 encoding. + */ + fun getRsaPublicFromPrivate(rsaPrivateCrtKey: RSAPrivateCrtKey): RSAPublicKey { + val spec = RSAPublicKeySpec(rsaPrivateCrtKey.modulus, rsaPrivateCrtKey.publicExponent) + val pub = KeyFactory.getInstance("RSA").generatePublic(spec) + if (pub !is RSAPublicKey) + throw Exception("wrong encoding") + return pub + } + + /** + * Generate a fresh RSA key pair. + * + * @param nbits size of the modulus in bits + */ + fun generateRsaKeyPair(nbits: Int): RsaCrtKeyPair { + val gen = KeyPairGenerator.getInstance("RSA") + gen.initialize(nbits) + val pair = gen.genKeyPair() + val priv = pair.private + val pub = pair.public + if (priv !is RSAPrivateCrtKey) + throw Exception("key generation failed") + if (pub !is RSAPublicKey) + throw Exception("key generation failed") + return RsaCrtKeyPair(priv, pub) + } + + /** + * Load an RSA public key from its components. + * + * @param exponent + * @param modulus + * @return key + */ + fun loadRsaPublicKeyFromComponents(modulus: ByteArray, exponent: ByteArray): RSAPublicKey { + val modulusBigInt = BigInteger(1, modulus) + val exponentBigInt = BigInteger(1, exponent) + + val keyFactory = KeyFactory.getInstance("RSA") + val tmp = RSAPublicKeySpec(modulusBigInt, exponentBigInt) + return keyFactory.generatePublic(tmp) as RSAPublicKey + } + + /** + * Hash an RSA public key according to the EBICS standard (EBICS 2.5: 4.4.1.2.3). + */ + fun getEbicsPublicKeyHash(publicKey: RSAPublicKey): ByteArray { + val keyBytes = ByteArrayOutputStream() + keyBytes.writeBytes(publicKey.publicExponent.toUnsignedHexString().lowercase().trimStart('0').toByteArray()) + keyBytes.write(' '.code) + keyBytes.writeBytes(publicKey.modulus.toUnsignedHexString().lowercase().trimStart('0').toByteArray()) + // println("buffer before hashing: '${keyBytes.toString(Charsets.UTF_8)}'") + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest(keyBytes.toByteArray()) + } + + fun encryptEbicsE002(data: ByteArray, encryptionPublicKey: RSAPublicKey): EncryptionResult { + val keygen = KeyGenerator.getInstance("AES", bouncyCastleProvider) + keygen.init(128) + val transactionKey = keygen.generateKey() + return encryptEbicsE002withTransactionKey( + data, + encryptionPublicKey, + transactionKey + ) + } + /** + * Encrypt data according to the EBICS E002 encryption process. + */ + fun encryptEbicsE002withTransactionKey( + data: ByteArray, + encryptionPublicKey: RSAPublicKey, + transactionKey: SecretKey + ): EncryptionResult { + val symmetricCipher = Cipher.getInstance( + "AES/CBC/X9.23Padding", + bouncyCastleProvider + ) + val ivParameterSpec = IvParameterSpec(ByteArray(16)) + symmetricCipher.init(Cipher.ENCRYPT_MODE, transactionKey, ivParameterSpec) + val encryptedData = symmetricCipher.doFinal(data) + val asymmetricCipher = Cipher.getInstance( + "RSA/None/PKCS1Padding", + bouncyCastleProvider + ) + asymmetricCipher.init(Cipher.ENCRYPT_MODE, encryptionPublicKey) + val encryptedTransactionKey = asymmetricCipher.doFinal(transactionKey.encoded) + val pubKeyDigest = getEbicsPublicKeyHash(encryptionPublicKey) + return EncryptionResult( + encryptedTransactionKey, + pubKeyDigest, + encryptedData, + transactionKey + ) + } + + fun decryptEbicsE002(enc: EncryptionResult, privateKey: RSAPrivateCrtKey): ByteArray { + return decryptEbicsE002( + enc.encryptedTransactionKey, + enc.encryptedData.inputStream(), + privateKey + ).readBytes() + } + + fun decryptEbicsE002( + encryptedTransactionKey: ByteArray, + encryptedData: InputStream, + privateKey: RSAPrivateCrtKey + ): CipherInputStream { + val asymmetricCipher = Cipher.getInstance( + "RSA/None/PKCS1Padding", + bouncyCastleProvider + ) + asymmetricCipher.init(Cipher.DECRYPT_MODE, privateKey) + val transactionKeyBytes = asymmetricCipher.doFinal(encryptedTransactionKey) + val secretKeySpec = SecretKeySpec(transactionKeyBytes, "AES") + val symmetricCipher = Cipher.getInstance( + "AES/CBC/X9.23Padding", + bouncyCastleProvider + ) + val ivParameterSpec = IvParameterSpec(ByteArray(16)) + symmetricCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec) + return CipherInputStream(encryptedData, symmetricCipher) + } + + /** + * Signing algorithm corresponding to the EBICS A006 signing process. + * + * Note that while [data] can be arbitrary-length data, in EBICS, the order + * data is *always* hashed *before* passing it to the signing algorithm, which again + * uses a hash internally. + */ + fun signEbicsA006(data: ByteArray, privateKey: RSAPrivateCrtKey): ByteArray { + val signature = Signature.getInstance("SHA256withRSA/PSS", bouncyCastleProvider) + signature.setParameter(PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1)) + signature.initSign(privateKey) + signature.update(data) + return signature.sign() + } + + fun verifyEbicsA006(sig: ByteArray, data: ByteArray, publicKey: RSAPublicKey): Boolean { + val signature = Signature.getInstance("SHA256withRSA/PSS", bouncyCastleProvider) + signature.setParameter(PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1)) + signature.initVerify(publicKey) + signature.update(data) + return signature.verify(sig) + } + + fun digestEbicsOrderA006(orderData: ByteArray): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + for (b in orderData) { + when (b) { + '\r'.code.toByte(), '\n'.code.toByte(), (26).toByte() -> Unit + else -> digest.update(b) + } + } + return digest.digest() + } + + fun decryptKey(data: EncryptedPrivateKeyInfo, passphrase: String): RSAPrivateCrtKey { + /* make key out of passphrase */ + val pbeKeySpec = PBEKeySpec(passphrase.toCharArray()) + val keyFactory = SecretKeyFactory.getInstance(data.algName) + val secretKey = keyFactory.generateSecret(pbeKeySpec) + /* Make a cipher */ + val cipher = Cipher.getInstance(data.algName) + cipher.init( + Cipher.DECRYPT_MODE, + secretKey, + data.algParameters // has hash count and salt + ) + /* Ready to decrypt */ + val decryptedKeySpec: PKCS8EncodedKeySpec = data.getKeySpec(cipher) + val priv = KeyFactory.getInstance("RSA").generatePrivate(decryptedKeySpec) + if (priv !is RSAPrivateCrtKey) + throw Exception("wrong encoding") + return priv + } + + fun encryptKey(data: ByteArray, passphrase: String): ByteArray { + /* Cipher parameters: salt and hash count */ + val hashIterations = 30 + val salt = ByteArray(8) + SecureRandom().nextBytes(salt) + val pbeParameterSpec = PBEParameterSpec(salt, hashIterations) + /* *Other* cipher parameters: symmetric key (from password) */ + val pbeAlgorithm = "PBEWithSHA1AndDESede" + val pbeKeySpec = PBEKeySpec(passphrase.toCharArray()) + val keyFactory = SecretKeyFactory.getInstance(pbeAlgorithm) + val secretKey = keyFactory.generateSecret(pbeKeySpec) + /* Make a cipher */ + val cipher = Cipher.getInstance(pbeAlgorithm) + cipher.init(Cipher.ENCRYPT_MODE, secretKey, pbeParameterSpec) + /* ready to encrypt now */ + val cipherText = cipher.doFinal(data) + /* Must now bundle a PKCS#8-compatible object, that contains + * algorithm, salt and hash count information */ + val bundleAlgorithmParams = AlgorithmParameters.getInstance(pbeAlgorithm) + bundleAlgorithmParams.init(pbeParameterSpec) + val bundle = EncryptedPrivateKeyInfo(bundleAlgorithmParams, cipherText) + return bundle.encoded + } + + fun hashStringSHA256(input: String): ByteArray { + return MessageDigest.getInstance("SHA-256").digest(input.toByteArray(Charsets.UTF_8)) + } +} +\ No newline at end of file diff --git a/common/src/main/kotlin/strings.kt b/common/src/main/kotlin/strings.kt @@ -52,12 +52,13 @@ fun decodeHexString(hexString: String): ByteArray { return bytes } -fun bytesToBase64(bytes: ByteArray): String { - return Base64.getEncoder().encodeToString(bytes) + +fun ByteArray.encodeBase64(): String { + return Base64.getEncoder().encodeToString(this) } -fun base64ToBytes(encoding: String): ByteArray { - return Base64.getDecoder().decode(encoding) +fun String.decodeBase64(): ByteArray { + return Base64.getDecoder().decode(this) } // used mostly in RSA math, never as amount. diff --git a/common/src/test/kotlin/CryptoUtilTest.kt b/common/src/test/kotlin/CryptoUtilTest.kt @@ -21,6 +21,7 @@ import org.junit.Ignore import org.junit.Test import kotlin.io.path.* import tech.libeufin.common.* +import tech.libeufin.common.crypto.* import java.security.KeyPairGenerator import java.security.interfaces.RSAPrivateCrtKey import java.util.* @@ -202,7 +203,7 @@ class CryptoUtilTest { @Test fun passwordHashing() { - val x = CryptoUtil.hashpw("myinsecurepw") - assertTrue(CryptoUtil.checkpw("myinsecurepw", x)) + val x = PwCrypto.hashpw("myinsecurepw") + assertTrue(PwCrypto.checkpw("myinsecurepw", x)) } } diff --git a/ebics/src/main/kotlin/Ebics.kt b/ebics/src/main/kotlin/Ebics.kt @@ -26,7 +26,7 @@ package tech.libeufin.ebics import io.ktor.http.* import org.w3c.dom.Document -import tech.libeufin.common.CryptoUtil +import tech.libeufin.common.crypto.CryptoUtil import tech.libeufin.ebics.ebics_h004.EbicsRequest import tech.libeufin.ebics.ebics_h004.EbicsResponse import tech.libeufin.ebics.ebics_h004.EbicsTypes diff --git a/ebics/src/main/kotlin/ebics_h004/EbicsRequest.kt b/ebics/src/main/kotlin/ebics_h004/EbicsRequest.kt @@ -1,7 +1,7 @@ package tech.libeufin.ebics.ebics_h004 import org.apache.xml.security.binding.xmldsig.SignatureType -import tech.libeufin.common.CryptoUtil +import tech.libeufin.common.crypto.CryptoUtil import java.math.BigInteger import java.security.interfaces.RSAPublicKey import java.util.* diff --git a/ebics/src/main/kotlin/ebics_h004/EbicsResponse.kt b/ebics/src/main/kotlin/ebics_h004/EbicsResponse.kt @@ -1,7 +1,7 @@ package tech.libeufin.ebics.ebics_h004 import org.apache.xml.security.binding.xmldsig.SignatureType -import tech.libeufin.common.CryptoUtil +import tech.libeufin.common.crypto.CryptoUtil import java.math.BigInteger import javax.xml.bind.annotation.* import javax.xml.bind.annotation.adapters.CollapsedStringAdapter diff --git a/ebics/src/main/kotlin/ebics_h005/Ebics3Request.kt b/ebics/src/main/kotlin/ebics_h005/Ebics3Request.kt @@ -1,7 +1,7 @@ package tech.libeufin.ebics.ebics_h005 import org.apache.xml.security.binding.xmldsig.SignatureType -import tech.libeufin.common.CryptoUtil +import tech.libeufin.common.crypto.CryptoUtil import java.math.BigInteger import java.security.interfaces.RSAPublicKey import java.util.* diff --git a/ebics/src/main/kotlin/ebics_h005/Ebics3Response.kt b/ebics/src/main/kotlin/ebics_h005/Ebics3Response.kt @@ -1,7 +1,7 @@ package tech.libeufin.ebics.ebics_h005 import org.apache.xml.security.binding.xmldsig.SignatureType -import tech.libeufin.common.CryptoUtil +import tech.libeufin.common.crypto.CryptoUtil import tech.libeufin.ebics.ebics_h004.EbicsTypes import java.math.BigInteger import javax.xml.bind.annotation.* diff --git a/ebics/src/test/kotlin/EbicsMessagesTest.kt b/ebics/src/test/kotlin/EbicsMessagesTest.kt @@ -21,7 +21,7 @@ import junit.framework.TestCase.assertEquals import org.apache.xml.security.binding.xmldsig.SignatureType import org.junit.Test import org.w3c.dom.Element -import tech.libeufin.common.CryptoUtil +import tech.libeufin.common.crypto.CryptoUtil import tech.libeufin.ebics.XMLUtil import tech.libeufin.ebics.ebics_h004.* import tech.libeufin.ebics.ebics_hev.HEVResponse diff --git a/ebics/src/test/kotlin/SignatureDataTest.kt b/ebics/src/test/kotlin/SignatureDataTest.kt @@ -19,7 +19,7 @@ import org.apache.xml.security.binding.xmldsig.SignatureType import org.junit.Test -import tech.libeufin.common.CryptoUtil +import tech.libeufin.common.crypto.CryptoUtil import tech.libeufin.ebics.XMLUtil import tech.libeufin.ebics.ebics_h004.EbicsRequest import tech.libeufin.ebics.ebics_h004.EbicsTypes diff --git a/ebics/src/test/kotlin/XmlUtilTest.kt b/ebics/src/test/kotlin/XmlUtilTest.kt @@ -20,7 +20,7 @@ import org.apache.xml.security.binding.xmldsig.SignatureType import org.junit.Assert.assertTrue import org.junit.Test -import tech.libeufin.common.CryptoUtil +import tech.libeufin.common.crypto.CryptoUtil import tech.libeufin.common.decodeBase64 import tech.libeufin.ebics.XMLUtil import tech.libeufin.ebics.XMLUtil.Companion.signEbicsResponse diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt @@ -25,6 +25,7 @@ import com.github.ajalt.clikt.parameters.options.* import io.ktor.client.* import kotlinx.coroutines.runBlocking import tech.libeufin.common.* +import tech.libeufin.common.crypto.* import tech.libeufin.ebics.* import tech.libeufin.nexus.ebics.* import java.nio.file.* diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/KeyFiles.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/KeyFiles.kt @@ -31,7 +31,7 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import tech.libeufin.common.Base32Crockford -import tech.libeufin.common.CryptoUtil +import tech.libeufin.common.crypto.CryptoUtil import java.nio.file.* import java.security.interfaces.RSAPrivateCrtKey import java.security.interfaces.RSAPublicKey diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt @@ -44,6 +44,7 @@ import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.utils.io.jvm.javaio.* import tech.libeufin.common.* +import tech.libeufin.common.crypto.* import tech.libeufin.ebics.* import tech.libeufin.ebics.ebics_h005.Ebics3Request import tech.libeufin.nexus.* diff --git a/nexus/src/test/kotlin/CliTest.kt b/nexus/src/test/kotlin/CliTest.kt @@ -19,7 +19,7 @@ import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.testing.test -import tech.libeufin.common.CryptoUtil +import tech.libeufin.common.crypto.CryptoUtil import tech.libeufin.nexus.* import java.io.ByteArrayOutputStream import java.io.PrintStream diff --git a/nexus/src/test/kotlin/Keys.kt b/nexus/src/test/kotlin/Keys.kt @@ -18,7 +18,7 @@ */ import org.junit.Test -import tech.libeufin.common.CryptoUtil +import tech.libeufin.common.crypto.CryptoUtil import tech.libeufin.nexus.* import kotlin.io.path.Path import kotlin.io.path.deleteIfExists diff --git a/nexus/src/test/kotlin/MySerializers.kt b/nexus/src/test/kotlin/MySerializers.kt @@ -19,7 +19,7 @@ import org.junit.Test import tech.libeufin.common.Base32Crockford -import tech.libeufin.common.CryptoUtil +import tech.libeufin.common.crypto.CryptoUtil import tech.libeufin.nexus.ClientPrivateKeysFile import tech.libeufin.nexus.JSON import kotlin.test.assertEquals