diff options
Diffstat (limited to 'wallet/src')
64 files changed, 7710 insertions, 0 deletions
diff --git a/wallet/src/androidMain/AndroidManifest.xml b/wallet/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..77fbf4a --- /dev/null +++ b/wallet/src/androidMain/AndroidManifest.xml @@ -0,0 +1,23 @@ +<!-- + ~ This file is part of GNU Taler + ~ (C) 2020 Taler Systems S.A. + ~ + ~ GNU Taler is free software; you can redistribute it and/or modify it under the + ~ terms of the GNU General Public License as published by the Free Software + ~ Foundation; either version 3, or (at your option) any later version. + ~ + ~ GNU Taler 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 General Public License for more details. + ~ + ~ You should have received a copy of the GNU General Public License along with + ~ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + --> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="net.taler.wallet.kotlin"> + + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> + <uses-permission android:name="android.permission.INTERNET" /> + +</manifest> diff --git a/wallet/src/androidMain/kotlin/net/taler/wallet/kotlin/Db.kt b/wallet/src/androidMain/kotlin/net/taler/wallet/kotlin/Db.kt new file mode 100644 index 0000000..45cbfc3 --- /dev/null +++ b/wallet/src/androidMain/kotlin/net/taler/wallet/kotlin/Db.kt @@ -0,0 +1,23 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +internal actual class DbFactory { + actual fun openDb(): Db { + return FakeDb() + } +} diff --git a/wallet/src/androidMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoFactory.kt b/wallet/src/androidMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoFactory.kt new file mode 100644 index 0000000..502b8a0 --- /dev/null +++ b/wallet/src/androidMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoFactory.kt @@ -0,0 +1,132 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import com.goterl.lazycode.lazysodium.LazySodiumJava +import com.goterl.lazycode.lazysodium.SodiumJava +import com.goterl.lazycode.lazysodium.interfaces.Hash +import com.goterl.lazycode.lazysodium.interfaces.Hash.State512 +import com.goterl.lazycode.lazysodium.interfaces.KeyExchange +import com.goterl.lazycode.lazysodium.interfaces.Sign +import com.goterl.lazycode.lazysodium.utils.Key + +internal actual object CryptoFactory { + internal actual fun getCrypto(): Crypto = CryptoJvmImpl +} + +internal object CryptoJvmImpl : CryptoImpl() { + + private val sodium = LazySodiumJava(SodiumJava()) + + override fun sha256(input: ByteArray): ByteArray { + val output = ByteArray(Hash.SHA256_BYTES) + sodium.cryptoHashSha256(output, input, input.size.toLong()) + return output + } + + override fun sha512(input: ByteArray): ByteArray { + val output = ByteArray(Hash.SHA512_BYTES) + sodium.cryptoHashSha512(output, input, input.size.toLong()) + return output + } + + override fun getHashSha512State(): HashSha512State { + return JvmHashSha512State() + } + + override fun getRandomBytes(num: Int): ByteArray { + return sodium.randomBytesBuf(num) + } + + override fun eddsaGetPublic(eddsaPrivateKey: ByteArray): ByteArray { + return sodium.cryptoSignSeedKeypair(eddsaPrivateKey).publicKey.asBytes + } + + override fun ecdheGetPublic(ecdhePrivateKey: ByteArray): ByteArray { + return sodium.cryptoScalarMultBase(Key.fromBytes(ecdhePrivateKey)).asBytes + } + + override fun createEddsaKeyPair(): EddsaKeyPair { + val privateKey = sodium.randomBytesBuf(KeyExchange.SEEDBYTES) + val publicKey = eddsaGetPublic(privateKey) + return EddsaKeyPair(privateKey, publicKey) + } + + override fun createEcdheKeyPair(): EcdheKeyPair { + val privateKey = sodium.randomBytesBuf(KeyExchange.SEEDBYTES) + val publicKey = ecdheGetPublic(privateKey) + return EcdheKeyPair(privateKey, publicKey) + } + + override fun eddsaSign(msg: ByteArray, eddsaPrivateKey: ByteArray): ByteArray { + val privateKey = sodium.cryptoSignSeedKeypair(eddsaPrivateKey).secretKey.asBytes + val signatureBytes = ByteArray(Sign.BYTES) + sodium.cryptoSignDetached(signatureBytes, msg, msg.size.toLong(), privateKey) + return signatureBytes + } + + override fun eddsaVerify(msg: ByteArray, sig: ByteArray, eddsaPub: ByteArray): Boolean { + return sodium.cryptoSignVerifyDetached(sig, msg, msg.size, eddsaPub) + } + + override fun keyExchangeEddsaEcdhe(eddsaPrivateKey: ByteArray, ecdhePublicKey: ByteArray): ByteArray { + val ph = sha512(eddsaPrivateKey) + val a = ph.copyOfRange(0, 32) + val x = sodium.cryptoScalarMult(Key.fromBytes(a), Key.fromBytes(ecdhePublicKey)).asBytes + return sha512(x) + } + + override fun keyExchangeEcdheEddsa(ecdhePrivateKey: ByteArray, eddsaPublicKey: ByteArray): ByteArray { + val curve25519Pub = ByteArray(KeyExchange.PUBLICKEYBYTES) + sodium.convertPublicKeyEd25519ToCurve25519(curve25519Pub, eddsaPublicKey) + val x = sodium.cryptoScalarMult(Key.fromBytes(ecdhePrivateKey), Key.fromBytes(curve25519Pub)).asBytes + return sha512(x) + } + + override fun rsaBlind(hm: ByteArray, bks: ByteArray, rsaPubEnc: ByteArray): ByteArray { + return RsaBlinding.rsaBlind(hm, bks, rsaPubEnc) + } + + override fun rsaUnblind(sig: ByteArray, rsaPubEnc: ByteArray, bks: ByteArray): ByteArray { + return RsaBlinding.rsaUnblind(sig, rsaPubEnc, bks) + } + + override fun rsaVerify(hm: ByteArray, rsaSig: ByteArray, rsaPubEnc: ByteArray): Boolean { + return RsaBlinding.rsaVerify(hm, rsaSig, rsaPubEnc) + } + + private class JvmHashSha512State : HashSha512State { + private val state = State512() + + init { + check(sodium.cryptoHashSha512Init(state)) { "Error doing cryptoHashSha512Init" } + } + + override fun update(data: ByteArray): HashSha512State { + sodium.cryptoHashSha512Update(state, data, data.size.toLong()) + return this + } + + override fun final(): ByteArray { + val output = ByteArray(Hash.SHA512_BYTES) + sodium.cryptoHashSha512Final(state, output) + return output + } + + } + +} diff --git a/wallet/src/androidMain/kotlin/net/taler/wallet/kotlin/crypto/RsaBlinding.kt b/wallet/src/androidMain/kotlin/net/taler/wallet/kotlin/crypto/RsaBlinding.kt new file mode 100644 index 0000000..458a089 --- /dev/null +++ b/wallet/src/androidMain/kotlin/net/taler/wallet/kotlin/crypto/RsaBlinding.kt @@ -0,0 +1,136 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import java.math.BigInteger +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor + +internal object RsaBlinding { + + fun rsaBlind(hm: ByteArray, bks: ByteArray, rsaPubEnc: ByteArray): ByteArray { + val rsaPub = rsaPubDecode(rsaPubEnc) + val data = rsaFullDomainHash(hm, rsaPub) + val r = rsaBlindingKeyDerive(rsaPub, bks) + val rE = r.modPow(rsaPub.e, rsaPub.n) + val bm = rE.multiply(data).mod(rsaPub.n) + return bm.toByteArrayWithoutSign() + } + + fun rsaUnblind(sig: ByteArray, rsaPubEnc: ByteArray, bks: ByteArray): ByteArray { + val rsaPub = rsaPubDecode(rsaPubEnc) + val blindedSig = BigInteger(1, sig) + val r = rsaBlindingKeyDerive(rsaPub, bks) + val rInv = r.modInverse(rsaPub.n) + val s = blindedSig.multiply(rInv).mod(rsaPub.n) + return s.toByteArrayWithoutSign() + } + + fun rsaVerify(hm: ByteArray, rsaSig: ByteArray, rsaPubEnc: ByteArray): Boolean { + val rsaPub = rsaPubDecode(rsaPubEnc) + val d = rsaFullDomainHash(hm, rsaPub) + val sig = BigInteger(1, rsaSig) + val sigE = sig.modPow(rsaPub.e, rsaPub.n) + return sigE == d + } + + private fun rsaBlindingKeyDerive(rsaPub: RsaPublicKey, bks: ByteArray): BigInteger { + val salt = "Blinding KDF extrator HMAC key".encodeToByteArray() + val info = "Blinding KDF".encodeToByteArray() + return kdfMod(rsaPub.n, bks, salt, info) + } + + private fun rsaPubDecode(publicKey: ByteArray): RsaPublicKey { + val modulusLength = abs((publicKey[0].toInt() shl 8) or publicKey[1].toInt()) + val exponentLength = abs((publicKey[2].toInt() shl 8) or publicKey[3].toInt()) + if (4 + exponentLength + modulusLength != publicKey.size) { + throw Error("invalid RSA public key (format wrong)") + } + val modulus = publicKey.copyOfRange(4, 4 + modulusLength) + val exponent = publicKey.copyOfRange( + 4 + modulusLength, + 4 + modulusLength + exponentLength + ) + return RsaPublicKey(BigInteger(1, modulus), BigInteger(1, exponent)) + } + + private fun rsaFullDomainHash(hm: ByteArray, rsaPublicKey: RsaPublicKey): BigInteger { + val info = "RSA-FDA FTpsW!".encodeToByteArray() + val salt = rsaPubEncode(rsaPublicKey) + val r = kdfMod(rsaPublicKey.n, hm, salt, info) + rsaGcdValidate(r, rsaPublicKey.n) + return r + } + + private fun rsaPubEncode(rsaPublicKey: RsaPublicKey): ByteArray { + val mb = rsaPublicKey.n.toByteArrayWithoutSign() + val eb = rsaPublicKey.e.toByteArrayWithoutSign() + val out = ByteArray(4 + mb.size + eb.size) + out[0] = ((mb.size ushr 8) and 0xff).toByte() + out[1] = (mb.size and 0xff).toByte() + out[2] = ((eb.size ushr 8) and 0xff).toByte() + out[3] = (eb.size and 0xff).toByte() + mb.copyInto(out, destinationOffset = 4) + eb.copyInto(out, destinationOffset = 4 + mb.size) + return out + } + + private fun kdfMod(n: BigInteger, ikm: ByteArray, salt: ByteArray, info: ByteArray): BigInteger { + val nBits = n.bitLength() + val bufLen = floor((nBits.toDouble() - 1) / 8 + 1).toInt() + val mask = (1 shl (8 - (bufLen * 8 - nBits))) - 1 + var counter = 0 + while (true) { + val ctx = ByteArray(info.size + 2) + info.copyInto(ctx) + ctx[ctx.size - 2] = ((counter ushr 8) and 0xff).toByte() + ctx[ctx.size - 1] = (counter and 0xff).toByte() + val buf = CryptoJvmImpl.kdf(bufLen, ikm, salt, ctx) + val arr = buf.copyOf() + arr[0] = (arr[0].toInt() and mask).toByte() + val r = BigInteger(1, arr) + if (r < n) return r + counter++ + } + } + + /** + * Test for malicious RSA key. + * + * Assuming n is an RSA modulous and r is generated using a call to + * GNUNET_CRYPTO_kdf_mod_mpi, if gcd(r,n) != 1 then n must be a + * malicious RSA key designed to deanomize the user. + * + * @param r KDF result + * @param n RSA modulus of the public key + */ + private fun rsaGcdValidate(r: BigInteger, n: BigInteger) { + if (r.gcd(n) != BigInteger.ONE) throw Error("malicious RSA public key") + } + + // TODO check that this strips *only* the sign correctly + private fun BigInteger.toByteArrayWithoutSign(): ByteArray = this.toByteArray().let { + val byteLength = ceil(this.bitLength().toDouble() / 8).toInt() + val signBitPosition = ceil((this.bitLength() + 1).toDouble() / 8).toInt() + val start = signBitPosition - byteLength + it.copyOfRange(start, it.size) // stripping least significant byte (sign) + } + +} + +internal class RsaPublicKey(val n: BigInteger, val e: BigInteger) diff --git a/wallet/src/androidTest/kotlin/net/taler/wallet/kotlin/TestUtilsAndroid.kt b/wallet/src/androidTest/kotlin/net/taler/wallet/kotlin/TestUtilsAndroid.kt new file mode 100644 index 0000000..a362874 --- /dev/null +++ b/wallet/src/androidTest/kotlin/net/taler/wallet/kotlin/TestUtilsAndroid.kt @@ -0,0 +1,24 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking + +actual fun runCoroutine(block: suspend (scope : CoroutineScope) -> Unit) = runBlocking { block(this) } + +actual fun getPlatformTarget(): PlatformTarget = PlatformTarget.ANDROID diff --git a/wallet/src/androidTest/kotlin/net/taler/wallet/kotlin/crypto/PlanchetTest.kt b/wallet/src/androidTest/kotlin/net/taler/wallet/kotlin/crypto/PlanchetTest.kt new file mode 100644 index 0000000..d7f1dae --- /dev/null +++ b/wallet/src/androidTest/kotlin/net/taler/wallet/kotlin/crypto/PlanchetTest.kt @@ -0,0 +1,125 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.Base32Crockford +import net.taler.wallet.kotlin.crypto.Planchet.CreationRequest +import net.taler.wallet.kotlin.crypto.Planchet.CreationResult +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +// TODO move to commonTest once RsaBlinding is implemented everywhere +class PlanchetTest { + + private val crypto = CryptoFactory.getCrypto() + private val planchet = Planchet(crypto) + + private class PlanchetVector(val request: CreationRequest, val eddsaKeyPair: EddsaKeyPair, val blindingFactor: String, val result: CreationResult) + + @Test + fun testCreate() { + val vectors = listOf( + PlanchetVector( + CreationRequest( + denomPub = "040000XVGVWCHVQVTQ06Q5V0XRAVQKPPZQZ68GYVXSC5RAG37VDCG0CEQHS4876BX6DDABB2WFY7TRJ7MFKTMMDF7A7ZW9PKQ8S3RQ15TVTKWBFGGKBKYSP6CVHNG9AY738NCPC8AFWYGP8J2VJE9HRR7M1GQK19E2M7Q2Y54KCSZ583BTNX275DW6EYYE1KBV4FK009Z621EHF5R87S6VQDSBCKSK15JCH1JYC2VPRHHAEGRA2WYX1HD9KFET0C9G1CZJB1MHZ5Z7Y803YZJH441P3PJJTRB9WCTA03H6M43CJ9MB33BEJ3KR22R8CS0D6QC2E7ZQS5MGBWCF51FK97SHCJW93SAT7VHB3YX5VVDNTW9N3SDW56HNWT11D306H9VN7BTP84T404VF482Y09K4SHEF5704002", + feeWithdraw = Amount.fromJSONString("KOADwTb3:9329564218.42023"), + reservePriv = "9TM70AKDTS57AWY9JK2J4TMBTMW6K62WHHGZWYDG0VM5ABPZKD40", + reservePub = "8GSJZ649T2PXMKZC01Y4ANNBE7MF14QVK9SQEC4E46ZHKCVG8AS0", + value = Amount.fromJSONString("KOADwTb3:3932974019564218.48282023") + ), + EddsaKeyPair( + Base32Crockford.decode("GX5DGW3RJ4HMXS53W29TK2667NWA3Z4WB41X7GPAX3WX4036VQGG"), + Base32Crockford.decode("Z6DXSXGEQ8C4G50FF6TG6ZKBH11APJ2HSNS21BAR72VY3KV7CC90") + ), + "H58V73J73HXTA9CPT8ZZ5G7VVKCWZFAE1TMCCV176QBQTPBB2H40", + CreationResult( + blindingKey = "H58V73J73HXTA9CPT8ZZ5G7VVKCWZFAE1TMCCV176QBQTPBB2H40", + coinEv = "AK23AD09K8462T621RPER66WQRNE845JAMBT4Y1AA39M22M5K0DZFPH2P5V9E8RD0VC1Z915WB432D2C1BHKHZGP62X9A424JZRPVTFJGYCFYHH52BG0VTJ58ZK70S52KYC2DW4Z0XHKBW0BW3F8NAGGTTGE6NF6EJ3SXYBVYTN0TDJE7HED3ZEGM34N73656TADK0VNZN04BQQNZYW7WWDGT5A06CZTCS4HTSD74CVNJ70CQQQ1C9D14AA75NJ902K6FC7ANBHFGENZXAYNAC0WQQQ6J7XW0TCC3N39TYSCC7TJVH7FZQEXKE8RDGT873QX4C7XDVV6TNWEBBCPM9AABW9PXBEQSM55DT79GVZ7156MZWJKGZAGNVX1FASAY0J3CW507672300R603MW1RRRC", + coinPriv = "GX5DGW3RJ4HMXS53W29TK2667NWA3Z4WB41X7GPAX3WX4036VQGG", + coinPub = "Z6DXSXGEQ8C4G50FF6TG6ZKBH11APJ2HSNS21BAR72VY3KV7CC90", + coinValue = Amount.fromJSONString("KOADwTb3:3932974019564218.48282023"), + denomPub = "040000XVGVWCHVQVTQ06Q5V0XRAVQKPPZQZ68GYVXSC5RAG37VDCG0CEQHS4876BX6DDABB2WFY7TRJ7MFKTMMDF7A7ZW9PKQ8S3RQ15TVTKWBFGGKBKYSP6CVHNG9AY738NCPC8AFWYGP8J2VJE9HRR7M1GQK19E2M7Q2Y54KCSZ583BTNX275DW6EYYE1KBV4FK009Z621EHF5R87S6VQDSBCKSK15JCH1JYC2VPRHHAEGRA2WYX1HD9KFET0C9G1CZJB1MHZ5Z7Y803YZJH441P3PJJTRB9WCTA03H6M43CJ9MB33BEJ3KR22R8CS0D6QC2E7ZQS5MGBWCF51FK97SHCJW93SAT7VHB3YX5VVDNTW9N3SDW56HNWT11D306H9VN7BTP84T404VF482Y09K4SHEF5704002", + denomPubHash = "XB6T8NRGSRPWBM2YGS3R0AQYGEMK7PAM3CQRX6XM04B4N48PWRVZ5DG5JTT0NNQAGHN5HTGSCPR06R6B5NJBZ2DT5VZSQRD8FTNFPEG", + reservePub = "8GSJZ649T2PXMKZC01Y4ANNBE7MF14QVK9SQEC4E46ZHKCVG8AS0", + withdrawSig = "SNTZ4DWRVJBK89YGAZ60EDV0T7BM80MD6J6P88BRKDQFP331CXPSGM45CMCVBB7GR6X2FWQC5EJGR0J8KBR459PSGT18DA5PMQZKG08", + coinEvHash = "FW36XSCBJCBQMSTT798CYG363481MASXGH5W73G24D2F9C7J76YZ2644PGQ6346XBYDXW7Z61JJZN2C2Y8152NNKW3NB0DHTMKHZ5BR" + + ) + ), + PlanchetVector( + CreationRequest( + denomPub = "040000YE5QYTJTCYF7YDWN2ECYAMBNENHQT7YT740XNC88V5F1K4YC2QD94WABBVHZY597B2BTGBD2NJJV028JKJTD1KBPKXF4D87B7ZJYZVQSA4ZB5H1FVPE7X7YQVG668YZ2YY229X29NM4B6QR0G3TH821QBT1J5EDPKS0RP8E6X4654DTAAYBEN14H96E8D1JFVE40773FVVPXXMX7ZXT7TCVC2EZFMZR1HQ2DDXD8KJZ9AEGS1YH4D629Y08T9X2533MS6R4X58VVKHN1YQVKJT2044A0S8B4AKCW2GJHMQM10XC3K7C3D1C841A6R96GRXPC02QVBQSA1D5VY5VG2T4HVC6NKVK5WAXDEYZNKYVPD9AV4MNCYGK23AZWGHX5E16BQTNG47C9DEETP2D87XFC9D04002", + feeWithdraw = Amount.fromJSONString("cRai6j:32749022734.44771"), + reservePriv = "H58V73J73HXTA9CPT8ZZ5G7VVKCWZFAE1TMCCV176QBQTPBB2H40", + reservePub = "G3R433316Z9PW1H8XRSATJWZJNMKPZ3EE20Z386X7CYM29JDFE0G", + value = Amount.fromJSONString("cRai6j:166032749022734.69444771") + ), + EddsaKeyPair( + Base32Crockford.decode("5QNA3FX8NA7SETDNEEFJK5W3MNP8AJ8WSBY8FYDVZEYQ1BD21EW0"), + Base32Crockford.decode("54NDT04NA3TRA38T0D8TMR52PH1EWQP2S4J279GMQWQHKN4W9850") + ), + "7EKNT64GV5MX0KHZZNB1NREPWCZ7KF9K815M8CQN3B8AKJYF1JV0", + CreationResult( + blindingKey = "7EKNT64GV5MX0KHZZNB1NREPWCZ7KF9K815M8CQN3B8AKJYF1JV0", + coinEv = "AATPF1TXN84PV5P7HE7274B7KT525MFRSPT62MDNYXJXJ2TDGKTMNGPJRH6CMWBD3QQENAEFNS7CZ7P27CBFN6W3EFCFNAS12EWGM6GTTV643RH3A5YJA2R93G0PZPXW9HZP3KZZYFG6MGCRHMHEXTA7T5WKVH6KWE9SM64X9SVKV856VY7TPPWZ0MKZV24KF6TDJ9QC74D2X2FEBDSK7CEA870JENBXC7PZZWJDN8CVN1ZDY4Q0SV8Y4B0YX6CZZ6KVX10PXW56FQ4SSP34EBZCPXCHRZPCQCQRAJ78H4GBP8Y8394QQV1TRH35JQ20R98JSH0WFNAMPQZ246QY8MRFTAT816EY7FEX74ENNKX8494K476BN9VM6CJ5CD0FZYRFSR7DRC5RG9V84SK71EXEDR", + coinPriv = "5QNA3FX8NA7SETDNEEFJK5W3MNP8AJ8WSBY8FYDVZEYQ1BD21EW0", + coinPub = "54NDT04NA3TRA38T0D8TMR52PH1EWQP2S4J279GMQWQHKN4W9850", + coinValue = Amount.fromJSONString("cRai6j:166032749022734.69444771"), + denomPub = "040000YE5QYTJTCYF7YDWN2ECYAMBNENHQT7YT740XNC88V5F1K4YC2QD94WABBVHZY597B2BTGBD2NJJV028JKJTD1KBPKXF4D87B7ZJYZVQSA4ZB5H1FVPE7X7YQVG668YZ2YY229X29NM4B6QR0G3TH821QBT1J5EDPKS0RP8E6X4654DTAAYBEN14H96E8D1JFVE40773FVVPXXMX7ZXT7TCVC2EZFMZR1HQ2DDXD8KJZ9AEGS1YH4D629Y08T9X2533MS6R4X58VVKHN1YQVKJT2044A0S8B4AKCW2GJHMQM10XC3K7C3D1C841A6R96GRXPC02QVBQSA1D5VY5VG2T4HVC6NKVK5WAXDEYZNKYVPD9AV4MNCYGK23AZWGHX5E16BQTNG47C9DEETP2D87XFC9D04002", + denomPubHash = "RJKMJ93AJ0NYC7X514FPVJ82ST4GW6WZKGK64R69880XBMMGE7H7R8QW71FGWCTKD3KZPW4D3QM854M4YHMYSZ5K3YEA2S7B2GJ9XTR", + reservePub = "G3R433316Z9PW1H8XRSATJWZJNMKPZ3EE20Z386X7CYM29JDFE0G", + withdrawSig = "X2015X2KE7Z0Q407QEKQ01TKBVV62QT07V9GJGP8GYH04K09TATB9KJG5K4VZG72Y79M1SM1EETVPARSETMN0J7Q057RB6V2F2B2P1G", + coinEvHash = "DZ0TEHNTRCXQB3YDZNQYGA0S4RRNKD96Y0PKMG9QQX1KD534RPNRW526CQ5FWESKDT8AJ8R79A9TD20V3JJG3ZQ5JJCMPK9DTF3A8B0" + ) + ) + ) + for (v in vectors) testPlanchetVector(v) + } + + private fun testPlanchetVector(v: PlanchetVector) { + // test vector should match expected result + val blindingFactor = Base32Crockford.decode(v.blindingFactor) + assertEquals(v.result, planchet.create(v.request, v.eddsaKeyPair, blindingFactor)) + + // different value should produce different signature + val diffValue = v.request.value - Amount.min(v.request.value.currency) + val requestDiffValue = v.request.copy(value = diffValue) + val requestDiffResult = v.result.copy(coinValue = diffValue) + val result = planchet.create(requestDiffValue, v.eddsaKeyPair, blindingFactor) + assertNotEquals(v.result.withdrawSig, result.withdrawSig) + assertNotEquals(requestDiffResult, result) + + // different fee should produce different signature + val diffFee = v.request.feeWithdraw - Amount.min(v.request.feeWithdraw.currency) + val requestDiffFee = v.request.copy(feeWithdraw = diffFee) + val resultDiffFee = planchet.create(requestDiffFee, v.eddsaKeyPair, blindingFactor) + assertNotEquals(v.result.withdrawSig, resultDiffFee.withdrawSig) + assertNotEquals(v.result, resultDiffFee) + + // different blinding factor should change result + val diffBlindingFactor = Random.nextBytes(32) + assertNotEquals(v.result, planchet.create(v.request, v.eddsaKeyPair, diffBlindingFactor)) + + // different coin keys should change result + val diffEddsaKeyPair = crypto.createEddsaKeyPair() + assertNotEquals(v.result, planchet.create(v.request, diffEddsaKeyPair, blindingFactor)) + } + +} diff --git a/wallet/src/androidTest/kotlin/net/taler/wallet/kotlin/crypto/RefreshTest.kt b/wallet/src/androidTest/kotlin/net/taler/wallet/kotlin/crypto/RefreshTest.kt new file mode 100644 index 0000000..6cdad75 --- /dev/null +++ b/wallet/src/androidTest/kotlin/net/taler/wallet/kotlin/crypto/RefreshTest.kt @@ -0,0 +1,481 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.Base32Crockford +import net.taler.wallet.kotlin.CoinRecord +import net.taler.wallet.kotlin.CoinSourceType.WITHDRAW +import net.taler.wallet.kotlin.CoinStatus.DORMANT +import net.taler.wallet.kotlin.Timestamp +import net.taler.wallet.kotlin.crypto.Refresh.RefreshPlanchetRecord +import net.taler.wallet.kotlin.crypto.Refresh.RefreshSessionRecord +import net.taler.wallet.kotlin.exchange.DenominationRecord +import net.taler.wallet.kotlin.exchange.DenominationSelectionInfo +import net.taler.wallet.kotlin.exchange.DenominationStatus.VerifiedGood +import net.taler.wallet.kotlin.exchange.SelectedDenomination +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// TODO move to commonTest once RsaBlinding is implemented everywhere +class RefreshTest { + + private val crypto = CryptoFactory.getCrypto() + private val refresh = Refresh(crypto) + + private class RefreshVector( + val kappa: Int, + val meltCoin: CoinRecord, + val newCoinDenominations: DenominationSelectionInfo, + val meltFee: Amount, + val refreshSessionRecord: RefreshSessionRecord, + val kappaKeys: List<EcdheKeyPair> + ) + + @Test + fun testCreateRefreshSession() { + val vectors = listOf( + RefreshVector( + kappa = 3, + meltCoin = CoinRecord( + blindingKey = "N2A8K8XVTMPKNRCEJ1B8GMWJM6TXWRGAN5HWPFGPXS3WY76EKB60", + coinPriv = "884XH9DBQT9MA34YKCR7WPEP2HH3J1R9C3G7MSB8C3NHB0XEVZW0", + coinPub = "3GSHK9JCTMEAZY7BMC1QQD28ABEJEJJJXHPVF2YXTET97WB81GNG", + currentAmount = Amount("TESTKUDOS", 1, 96000000), + denomPub = "020000YDCXEZRKA0RR18PBW1HGXAH9HM9PN5TPWSVB8TG9QP4KDNHDVS0BRM9ATZV5AW5238WSNJWXA7V6WHE5QGMKRTSZYZZ6DWNHMZGAKCBC3T1HWKVPBHTRJZK68CGRQQ7A6VBRPCRS26QP8Z89TFRVN9NAQC6ZR0CD6P269XNQXYQAARNVBB74QSHJJXPMB5HE6TQ1HHK6AZRNWG2001", + denomPubHash = "9ZSE6E0271SRAZTHS08BYF8TGAY2Y1Q8Y7TYWM3ZM25THE3ZJYNKDCXR72YKVQSES1GXWGQVSF13YZ0Z2RQE5VRBMF79KJQP85R71X8", + denomSig = "73XS5TEM2WNQWJJBBMYC77DCV98NKX949KAFNJX9KXW2H9QDE4FM7SEJ7RB6RHX99QJGVT6SD1N24GXQCJY1CZA57RKDP3DK2C9WJ8HZ09Y33MYV33TD7C325GQQJFSSZ1EC5JX06BYNGKB0VEHXAR7KHXVT0Y5MZXSD8X65GRNPFV8B3HKC6BBNGVVXXM28WA7VTA7EWJKM4", + exchangeBaseUrl = "example.org", status = DORMANT, coinSource = WITHDRAW, suspended = false + ), + newCoinDenominations = DenominationSelectionInfo( + totalCoinValue = Amount("TESTKUDOS", 1, 81000000), + totalWithdrawCost = Amount("TESTKUDOS", 1, 92000000), + selectedDenominations = listOf( + SelectedDenomination( + count = 1, + denominationRecord = DenominationRecord( + denomPub = "020000YDW0GZQGY9GSKTEESAD1ZS803D576X1HTVJM8CWEBPVGQ4GHMDD5SBQHJ462NPW9FJD437HYW69MJR5N4YABJWYZB6P7CZ7CZR0YD7KY1M7C291BQX4T18DGKCTDEDBMVH2CF4K4NWVA1FFX4AYDB5GRWJ0JBJRRAVQ1ZY8D9PX9TJT9HEVBXAMQQF2ESKG1JX5CXDX7AMY2TG2001", + denomPubHash = "ZVZMM6GEFETH1S71BRKFKB03BTSTA8JZH26Q1PZED78NZ50EVVT0DMDBPDN5EBY90SK4AWT0J8CNPD7NB2TSCFMSKAS5V6DK26GEKT0", + exchangeBaseUrl = "example.org", + feeDeposit = Amount("TESTKUDOS", fraction = 2000000, value = 0), + feeRefresh = Amount("TESTKUDOS", fraction = 3000000, value = 0), + feeRefund = Amount("TESTKUDOS", fraction = 1000000, value = 0), + feeWithdraw = Amount("TESTKUDOS", fraction = 2000000, value = 0), + isOffered = true, + isRevoked = false, + masterSig = "1XDNXJXA9CY30Q5BTJW5YN8XSYCCFKKSVGR9TW5CFPD4YSTYY52YBHDEPEAX3AY1HWR0XHYQHRJCX156NZMAPQ4BPZ00EXTW0MA1018", + stampExpireDeposit = Timestamp(1656768984000), + stampExpireLegal = Timestamp(1688304984000), + stampExpireWithdraw = Timestamp(1594301784000), + stampStart = Timestamp(1593696984000), + status = VerifiedGood, + value = Amount("TESTKUDOS", fraction = 0, value = 1) + ) + ), + SelectedDenomination( + count = 8, + denominationRecord = DenominationRecord( + denomPub = "020000WTGW5YBES6D2CDGZWESS40NQMSS19R07QGQZTARCZ108VQXQKWQW97C6HM2937V05P4NJ7PNYJJS8DARNGDD2ENQ6ANEHK1PMJEJA1R2FBQKQNA463VX53HSW94RRJP631M9PGE7P32ZNM9DA0F1DJFHSSPZ6GPY3K7BRKW0PP3AZKMX475WP00B02XMHNDWGXR0J2NSWV81KG2001", + denomPubHash = "4VEW03Q1JRQTKDGDKV73DX4RYHPDMPYQ48BZWAFVFPB03XK9HNFKWM1KZCR0BHXGS0034W12CE1VG1J2YEN4G7C1400MGX35GDS263R", + exchangeBaseUrl = "example.org", + feeDeposit = Amount("TESTKUDOS", fraction = 1000000, value = 0), + feeRefresh = Amount("TESTKUDOS", fraction = 3000000, value = 0), + feeRefund = Amount("TESTKUDOS", fraction = 1000000, value = 0), + feeWithdraw = Amount("TESTKUDOS", fraction = 1000000, value = 0), + isOffered = true, + isRevoked = false, + masterSig = "X4WYKHZVAQFRXKCAQ0QCA8VFMC32ESJP8M3BTTYW20SBR46Q1HRF9Y309SQVVH8TY3R8PJ81A4QFAABYEQR2D9RXTSVCBMD667Q723R", + stampExpireDeposit = Timestamp(1656768984000), + stampExpireLegal = Timestamp(1688304984000), + stampExpireWithdraw = Timestamp(1594301784000), + stampStart = Timestamp(1593696984000), + status = VerifiedGood, + value = Amount("TESTKUDOS", fraction = 10000000, value = 0) + ) + ), + SelectedDenomination( + count = 1, + denominationRecord = DenominationRecord( + denomPub = "020000Z46ESSPKMQ5875ZZMX9EZ4ZD7MQ3MPM4R2QJ7K7ADMCK0CJDM7W6GEZF0BQGWHVCMA4CY83NBYKBZYB9AYG293JD7VW7BSQTFH51HK02Q96JRTXBAY2DS8ZEXQFXRM6SD43C6BJCBDRCSXXEXA1WG2FFG5CX65TZW94274CMHJNVAY6VR8Y8XKV928P7AHBK106R6TCTS164CG2001", + denomPubHash = "0F44ZC9M6XB828XW09DY2YZNM07DA0CZV72D69F31RXH1CQHF3GDPVGZT5135BJEHVX0RGN44SCWS8CR8E39TDSRW10GZ6AEB2R40E8", + exchangeBaseUrl = "example.org", + feeDeposit = Amount("TESTKUDOS", fraction = 1000000, value = 0), + feeRefresh = Amount("TESTKUDOS", fraction = 1000000, value = 0), + feeRefund = Amount("TESTKUDOS", fraction = 1000000, value = 0), + feeWithdraw = Amount("TESTKUDOS", fraction = 1000000, value = 0), + isOffered = true, + isRevoked = false, + masterSig = "8DGF50VWCB2JHM613NRJM0NQYAVWFXCSBAHKTG3ZHKQ7N59WXK7MA4V794A97YDG0AVP350B2NT1QQ66VS1H2QG3P29PM5QDE6ZH60R", + stampExpireDeposit = Timestamp(1656768984000), + stampExpireLegal = Timestamp(1688304984000), + stampExpireWithdraw = Timestamp(1594301784000), + stampStart = Timestamp(1593696984000), + status = VerifiedGood, + value = Amount("TESTKUDOS", fraction = 1000000, value = 0) + ) + ) + ) + ), + meltFee = Amount("TESTKUDOS", fraction = 3000000, value = 0), + refreshSessionRecord = RefreshSessionRecord( + confirmSig = "97N1DQ1FGXT6SFR4J8H9WXCNG741BE986E3YW59T6R4DZ1FPC3XRFQ9D6BNRHGTAANZQXH4T1KXH61DP8BGHY6SYHZJWVGSQBV0A00G", + exchangeBaseUrl = "example.org", + hash = "XCSSAYNJ6964PVG393PDEGX0CGGZBNY6YMRDNZYBCTBDJF8ASA0BEA5TK435GG1DCFTTK26SS3V0EQ0DB9XKC9M9PWHTE4SZK9JZPVG", + meltCoinPub = "3GSHK9JCTMEAZY7BMC1QQD28ABEJEJJJXHPVF2YXTET97WB81GNG", + newDenominationHashes = listOf( + "ZVZMM6GEFETH1S71BRKFKB03BTSTA8JZH26Q1PZED78NZ50EVVT0DMDBPDN5EBY90SK4AWT0J8CNPD7NB2TSCFMSKAS5V6DK26GEKT0", + "4VEW03Q1JRQTKDGDKV73DX4RYHPDMPYQ48BZWAFVFPB03XK9HNFKWM1KZCR0BHXGS0034W12CE1VG1J2YEN4G7C1400MGX35GDS263R", + "4VEW03Q1JRQTKDGDKV73DX4RYHPDMPYQ48BZWAFVFPB03XK9HNFKWM1KZCR0BHXGS0034W12CE1VG1J2YEN4G7C1400MGX35GDS263R", + "4VEW03Q1JRQTKDGDKV73DX4RYHPDMPYQ48BZWAFVFPB03XK9HNFKWM1KZCR0BHXGS0034W12CE1VG1J2YEN4G7C1400MGX35GDS263R", + "4VEW03Q1JRQTKDGDKV73DX4RYHPDMPYQ48BZWAFVFPB03XK9HNFKWM1KZCR0BHXGS0034W12CE1VG1J2YEN4G7C1400MGX35GDS263R", + "4VEW03Q1JRQTKDGDKV73DX4RYHPDMPYQ48BZWAFVFPB03XK9HNFKWM1KZCR0BHXGS0034W12CE1VG1J2YEN4G7C1400MGX35GDS263R", + "4VEW03Q1JRQTKDGDKV73DX4RYHPDMPYQ48BZWAFVFPB03XK9HNFKWM1KZCR0BHXGS0034W12CE1VG1J2YEN4G7C1400MGX35GDS263R", + "4VEW03Q1JRQTKDGDKV73DX4RYHPDMPYQ48BZWAFVFPB03XK9HNFKWM1KZCR0BHXGS0034W12CE1VG1J2YEN4G7C1400MGX35GDS263R", + "4VEW03Q1JRQTKDGDKV73DX4RYHPDMPYQ48BZWAFVFPB03XK9HNFKWM1KZCR0BHXGS0034W12CE1VG1J2YEN4G7C1400MGX35GDS263R", + "0F44ZC9M6XB828XW09DY2YZNM07DA0CZV72D69F31RXH1CQHF3GDPVGZT5135BJEHVX0RGN44SCWS8CR8E39TDSRW10GZ6AEB2R40E8" + ), + newDenominations = listOf( + "020000YDW0GZQGY9GSKTEESAD1ZS803D576X1HTVJM8CWEBPVGQ4GHMDD5SBQHJ462NPW9FJD437HYW69MJR5N4YABJWYZB6P7CZ7CZR0YD7KY1M7C291BQX4T18DGKCTDEDBMVH2CF4K4NWVA1FFX4AYDB5GRWJ0JBJRRAVQ1ZY8D9PX9TJT9HEVBXAMQQF2ESKG1JX5CXDX7AMY2TG2001", + "020000WTGW5YBES6D2CDGZWESS40NQMSS19R07QGQZTARCZ108VQXQKWQW97C6HM2937V05P4NJ7PNYJJS8DARNGDD2ENQ6ANEHK1PMJEJA1R2FBQKQNA463VX53HSW94RRJP631M9PGE7P32ZNM9DA0F1DJFHSSPZ6GPY3K7BRKW0PP3AZKMX475WP00B02XMHNDWGXR0J2NSWV81KG2001", + "020000WTGW5YBES6D2CDGZWESS40NQMSS19R07QGQZTARCZ108VQXQKWQW97C6HM2937V05P4NJ7PNYJJS8DARNGDD2ENQ6ANEHK1PMJEJA1R2FBQKQNA463VX53HSW94RRJP631M9PGE7P32ZNM9DA0F1DJFHSSPZ6GPY3K7BRKW0PP3AZKMX475WP00B02XMHNDWGXR0J2NSWV81KG2001", + "020000WTGW5YBES6D2CDGZWESS40NQMSS19R07QGQZTARCZ108VQXQKWQW97C6HM2937V05P4NJ7PNYJJS8DARNGDD2ENQ6ANEHK1PMJEJA1R2FBQKQNA463VX53HSW94RRJP631M9PGE7P32ZNM9DA0F1DJFHSSPZ6GPY3K7BRKW0PP3AZKMX475WP00B02XMHNDWGXR0J2NSWV81KG2001", + "020000WTGW5YBES6D2CDGZWESS40NQMSS19R07QGQZTARCZ108VQXQKWQW97C6HM2937V05P4NJ7PNYJJS8DARNGDD2ENQ6ANEHK1PMJEJA1R2FBQKQNA463VX53HSW94RRJP631M9PGE7P32ZNM9DA0F1DJFHSSPZ6GPY3K7BRKW0PP3AZKMX475WP00B02XMHNDWGXR0J2NSWV81KG2001", + "020000WTGW5YBES6D2CDGZWESS40NQMSS19R07QGQZTARCZ108VQXQKWQW97C6HM2937V05P4NJ7PNYJJS8DARNGDD2ENQ6ANEHK1PMJEJA1R2FBQKQNA463VX53HSW94RRJP631M9PGE7P32ZNM9DA0F1DJFHSSPZ6GPY3K7BRKW0PP3AZKMX475WP00B02XMHNDWGXR0J2NSWV81KG2001", + "020000WTGW5YBES6D2CDGZWESS40NQMSS19R07QGQZTARCZ108VQXQKWQW97C6HM2937V05P4NJ7PNYJJS8DARNGDD2ENQ6ANEHK1PMJEJA1R2FBQKQNA463VX53HSW94RRJP631M9PGE7P32ZNM9DA0F1DJFHSSPZ6GPY3K7BRKW0PP3AZKMX475WP00B02XMHNDWGXR0J2NSWV81KG2001", + "020000WTGW5YBES6D2CDGZWESS40NQMSS19R07QGQZTARCZ108VQXQKWQW97C6HM2937V05P4NJ7PNYJJS8DARNGDD2ENQ6ANEHK1PMJEJA1R2FBQKQNA463VX53HSW94RRJP631M9PGE7P32ZNM9DA0F1DJFHSSPZ6GPY3K7BRKW0PP3AZKMX475WP00B02XMHNDWGXR0J2NSWV81KG2001", + "020000WTGW5YBES6D2CDGZWESS40NQMSS19R07QGQZTARCZ108VQXQKWQW97C6HM2937V05P4NJ7PNYJJS8DARNGDD2ENQ6ANEHK1PMJEJA1R2FBQKQNA463VX53HSW94RRJP631M9PGE7P32ZNM9DA0F1DJFHSSPZ6GPY3K7BRKW0PP3AZKMX475WP00B02XMHNDWGXR0J2NSWV81KG2001", + "020000Z46ESSPKMQ5875ZZMX9EZ4ZD7MQ3MPM4R2QJ7K7ADMCK0CJDM7W6GEZF0BQGWHVCMA4CY83NBYKBZYB9AYG293JD7VW7BSQTFH51HK02Q96JRTXBAY2DS8ZEXQFXRM6SD43C6BJCBDRCSXXEXA1WG2FFG5CX65TZW94274CMHJNVAY6VR8Y8XKV928P7AHBK106R6TCTS164CG2001" + ), + noRevealIndex = null, + planchetsForGammas = listOf( + listOf( + RefreshPlanchetRecord( + blindingKey = "G17FNHNV8BA7BFZRG75PPKJSK7FC47GCXF8YKJZ491D115FQEGE0", + coinEv = + "85BB17VN2XV7TTNKV5MS64JVA7X39TMCSQQQKWFT30SZFJDA4A9F06SGP89GF7ZZBAVB8X3VTT94SKRQ192ENJS4K12715NKHVH11XWVKND063AK16ANGV2P3XCP32Y4GRJGZMZ068EQS623C4CW75VB2KPF45TKEJ3VX4M4GXWMB3H07F8NQDQGMPFCBDS8SG2HXESC8DXVE", + privateKey = "972E3RE0GFMMGXHZTYSFD0H3YDYGGBNH80F5GMHPCN7C8NQCH460", + publicKey = "BH1Y1VBKNH86RT9FD191MP82W7PAQCA0SN9GPBN23N02M9NBYS50" + ), + RefreshPlanchetRecord( + blindingKey = "QR15AD5SFG20S9KW70P1MFAGPQC9WK7MSD0VFQXHKPDZTVZ6BZV0", + coinEv = + "DBKVPSHR1N8F04N1FQ04EPVBBWGETX5835M8Z9P7WF5X6BHBJW8KKNHZVR1JM9TRVZ9J0G5VDK7BQ9ESFKDCZD4PPB05K0MHW0HAJRKCCJ5PTNFXSRVB4V25D48XT031P0M53WDZRTPEK5G1XQ5KMQHZT06A70GE9N5C7SR4RDVA20AZDBBD1MKPR6NB2FMPAP2DR3NJENPWA", + privateKey = "AZXBY4QCFSH759DWTDCK1Q5D5JRNH3663JP2RTC3TSSTH3ARV0D0", + publicKey = "1C88APBTDQ8A2Q55N1CB3PD91PHYKJHY8QM2CYK0PGRJ0CTK4VPG" + ), + RefreshPlanchetRecord( + blindingKey = "EP0XT6PDDJFKGFVG739QKNZCWRN05497TCBQERFRBJBB7W7Y2JV0", + coinEv = + "J8E5HCMEJZ78BP2VDCNA9FVFBDG9QT4A1FDBSAH6ZZJ2HNJNKYKS57ZVRPZ47B1B8J6WV77PXCDP7EZTX5YTJFPGGAT6W6QWYNGRTW1NPE454J0VPJ3DKT19EA10N27P6MTM35HZZ8GJ8CGW06HGRXSZ5HJA3SQFDRZS2XN68PYM28918ERVV9BDVKFBMRK9NDMWMM79EK198", + privateKey = "3V1SMYW447SGBHHZMZN8KDB21FYGNT0P2WK7A1XAR4AJYMXZNSGG", + publicKey = "SVM02H0CDHXQX2FPR2B31ZZKMT0SQWE2S3VEWSAZDEDVSDDBXW00" + ), + RefreshPlanchetRecord( + blindingKey = "XSW3C40PHH03SX6G74QJ9CJCS6RFA2Q1V79G4ZS0J1F055ETW5TG", + coinEv = + "58FBPPVPMZEPMQB3RV4NJV7GA8GH8XYJ2NZ8N0NYG4M95434E1WT5GSGGM54X5X3FVTKKM5HRG28TCSD5ZCMC1V0KV6YTN1VEGQWVEN92M3TV761KWQDEJEZGHFKE7BMWP75KS37PV9SX4VABSEPS5VZCZB4C3RPG36QH2GH366G0AGQNX2F5T80SBS51Q3XA4FG44KJ14D32", + privateKey = "8SFAB3CA3JQ0KB18MREYVJYJYQ0BNYS1NCVPKHW7WS3YB4QN898G", + publicKey = "4QYR2AQT98J5QNMB44P3ZVFA0WVHJJTWG7WTXRKYJ11X1FYNYDGG" + ), + RefreshPlanchetRecord( + blindingKey = "Y94ST132M2FTD579X8V9E3SK349CZQ5Z70RZ29VSP8S1PM08ZV30", + coinEv = + "HF7CMN9F3Q6EG85C10SQ313336MFQTWJ9ZQ85J4EKWX6Y2546ZNQCPMKDRM2E0KZWQFNR57SKE7EHJ4BWVT14582MT1YG0ZA7RC2DX5VGZSWVT3N568TNSN9TPKYD74KMAH2A0SSXY8QNXF9C3TQD0HPQ409FNV0QR4R65PJ0G4AJAJW3EH6W4CMEXRVXSD8DYT81H6KFPR12", + privateKey = "CTNN080C67WSNA96SY3ECCGYM017T3SYZXTJ3CTDEC23E1ZCXVSG", + publicKey = "R4G1DHWQCB8MS0E5ET8YTC9M9425R6Z146HKM1ADGQJ0Z2QA1YQ0" + ), + RefreshPlanchetRecord( + blindingKey = "2P1C6Z5RJNWPP2YW4SXGED45AM9H4EM7M063V8HKVSRHA4HGC5A0", + coinEv = + "3N07NHNBBSZMQP04EPSN3SC8DZXW0DHWGZ6QD7A20EWD8DDJ00PB2CH11KZP404ZXN752X4TMA6D09C459WXYQDBYQJ70J6920MJ63P9XRMKYHEKWRSJEEC3FX487VFMJJ8ZYYBR3C4YRVPADRXFPZ8T7YJ4667TJKCSRQJGS7V2DS953HQ509M9ZHHWRH19XBFPTET3R71ZT", + privateKey = "DWNV6AD7WKZMMPG342D1KB9PMH6D8RW2ZE5T0MB07KNA88NR2RF0", + publicKey = "21STR11NETSM4VVBZY65NEDNJHXMV27GM1NP2P47DJN4C8AQS21G" + ), + RefreshPlanchetRecord( + blindingKey = "XTRQX3ZCR6MAKY2AJR2KF9C4GJZ0W5HKK71Y0EHG683WBG6VFHQ0", + coinEv = + "867475JHJNY8BWHQH23CTCF3GEZZ2R1JBFKER7FTFQFNA9RCG4JR8Y89165HCW6GZQFBCWN8DQ91V8G3JH9JG4RPNJ6YZ6K1R2JNN4H99AR6DR96SDQ5N8YZSKFBFKD2QE8E9KAD8K05AVXPPQ4R4NDN45R7YTA346S1PB24GR0SCG7TPS1W59596KN3V1F4G17RGAKQA66T0", + privateKey = "T90M7DVZ78WVVDHBGTHW0Y38M88AWM48Q8MEP6WXXK4WGQQBF0C0", + publicKey = "K1MHGCPZSQ8HY8T8WJSAXE5DXH5SMXAGTZ8YZYFWE1ZYBYRHPG6G" + ), + RefreshPlanchetRecord( + blindingKey = "BM2NA21HG2A50GFXZQCQKK5Z23J0Y63SNM966M2FHDPM2XBYJND0", + coinEv = + "GAZ5VA6AD060FE466W5DHJK8E5TBY4MY6TEFD0TZ9BX45CT4S9V5NGV47G26ESW0Q31787GX1QB756J55BNQJY1SASN2942EARZTYCWC9MEA0MAGDFR6S5N6QRNFEACCHE37S4DB2DN4HWKEJP8JM8RADC73KHCBKBVKAEZGYAFD4P99BSER0S9S2ZH7GAWQYZ45YZY7W6C64", + privateKey = "GY4ZPR1BJB5YY0273H46GJTS4HXTWEFPZA4KT5E3Y9H12R8TQCN0", + publicKey = "9ZXVR8Y0TDT2H57M4XZ0HGG1R78RBSN3KZK2WAH678G09XP2WM6G" + ), + RefreshPlanchetRecord( + blindingKey = "X24KD0GD1GC8JWDBB72R94XJMD8FKR7T2GK1MX8DFJN0PY6EKH20", + coinEv = + "5GTW52KR7ACEGQ96SB92KMYBCC8XNPCGV9Q8ZS7MT1N27BH1NYM3BRTR8XJWZZ5FQ5KXEMT3FA374BQTZVPS9GTYHX9J3NMRSBJ6XJA6AGZDMW5VDXS3QWK49271WH1CSHMG2ZJ7XM0NJF86EZN7MPXKTV3WPD25AZ0EA4P980XB7DZ8GA04CVET8K0VFFP4KB13N4088KEB6", + privateKey = "K1W5XB7SESBYTAPP4PCS9DNYY4Z3HQ0464BW4T15VDZ64GFCXBZG", + publicKey = "W88CW2E3Y4Z0VZC8KE9Z4G6H2B8E4BCRGNDPVVD6MF8PNGRHGMH0" + ), + RefreshPlanchetRecord( + blindingKey = "CF2AE3C5HVQJSP0R7CBBTAYQK1NDTG5DTXGW6D6KKWG3053QMHX0", + coinEv = + "FWA6FJY0YM5HWA96QQNH2CGTTD0NR6DTN5YNRDZCBHDTE4A3ZJ5793175J6G0QRPTHNKEMPJFSQEVB4QH7MAHTPSXRJC28WEQ9Q9C7H3R5CDAE9SNMB61RXWV1D1JH195E13690RYZP62KC9349Q25JHADQ4Q4JQJE4MNJK19H3T1WHES285NHCP8BEE3MSE4TH567P1CBK58", + privateKey = "7ANNBTWEQPXP211W3TEHWTNRFG5ZFSCW393W0HG6N24PJN6XK6PG", + publicKey = "2DJ9HFE9HBQ0N8XNEH6BQD7R1H81BJD40Z94FVD016HEHT8JPCRG" + ) + ), + listOf( + RefreshPlanchetRecord( + blindingKey = "J5979NKRJE2PS2NSVS4T4D6D1CSVJF7TBHKXA2FQYEZF6332PV2G", + coinEv = + "8S4CVYXJNHAQ93Z2ENGKJXNGYEXXMKE89KXH1TVRGVEY9PJ1NGFGENCKWZR1ZFC8W0TFXDGPYEAPHVPJAY0PT99G6GNJKDYV0YC89XGZK4F5HGN92BY7HT04AF2HY1RED8B0KKRKX9RV727XSV5QG9TZFBSAP4Y4HXZKEN1DEBRR2WJFYSZ2MYV50XPK5T7TPPMM30TX4PX88", + privateKey = "CJ6QMHX606JYZY9DS5VGPTFKDYYASAD60MFRBZTVEDQQRYX2KX2G", + publicKey = "WYYT5DM2QASGBCTSQKVXT5M7MREXV39MDF11HM5JX4NKMNXWY41G" + ), + RefreshPlanchetRecord( + blindingKey = "BJ9N8QGX1VYYRP1RR4WZGE2NMKY1EG3K6636HPY624MHT1FNK1T0", + coinEv = + "J5XPY46YX3S314FSBJD1MTZXCT87Y9PYKY17TJP5PCJNK2RF5JGMWBJVJQRXZCJCCFPY3FH4R7TD9A3FYB6JF4Y5QNM8Z46M90599H3HP2BSFZM4HW68R5DKPRE11GADR6BAW7ECFYAREDBZJNC5C18BMZWHP5XZ35CZZRWZ4GG1910KXX6A3789KW1028VBC46PHC3M61396", + privateKey = "FE0T9EZM72EZA5PX3TQPVNM9RM5NWJX4M9CEVFXW2BF2HF28AJ7G", + publicKey = "2Z07PKFFWXX53NXPX76HBF5RYRRM6E41A5KD5484VJ2DB7P64AA0" + ), + RefreshPlanchetRecord( + blindingKey = "4B9D3MG4TEHDCETMKBAC3XGYW7A4FFWQ8MEHT7V5SXC0BY0AF3AG", + coinEv = + "CX96G0RTN9N3QFYNKFFWG165XDKYW7FJEMB5K9SKKYB6A9DCK33T34VDDAJBH9VGG6ADXAM2DBVXZYPVKST9VK2KN943A9MRK0HNP8JVEJK84H9CVA4D1Y9N1351V05H2NC8TEDG43CS6RHSD37HX08N3Q03EW887GF2HF97MW23WH9CH2HBBNS9683DH7K5PY6H3MSQZE0S4", + privateKey = "ZS5031P7N3T3B52GNYGF7AEQ44HVCV63Y5Z0Q8E9XME6JC4VGQVG", + publicKey = "QB9HPVS5HXP0TVCACT7YB8B2Z7T9NZD47NA0XR49ZBCKJ03M7NXG" + ), + RefreshPlanchetRecord( + blindingKey = "VTT1W5TK7JTTC6NJZJBQQGD6D005JKXXRGCWRG7AQ6Y86D0JPJHG", + coinEv = + "AGZ8ZKH7HJ2E4QBQVKH0SH94SPZFSQYG1YG0NFSE7T8HB7P6JKPF66ERSFGDH1CYRBJXA0WA1GFPX7WG528NBWXVEAN9HERZZ45XV2F6H4GPTT59F4TK27VK01NRXR93G2NYSS2V3FVVTNNTVB1BE57S5J18J2TD67RMYWWFCG0S1JPXMN196QBKBMB2KQNFP05G9WBPGH4ZT", + privateKey = "MXXTAHN3VWEPD3327H55S8F4JMZHBFFDDV9FBK4SQH8SR0TRRWG0", + publicKey = "XZ4VZMRZMZ32Q3DPPSPFHST8D41MX5YFT6BSF51C0D7M30M16NW0" + ), + RefreshPlanchetRecord( + blindingKey = "047V0KJ23HSGECQRR4MF8DRN3XT3HTHZ0QV585PBJ4HG75CYCPK0", + coinEv = + "DR1K7Y41WADGP405ZZTCPAWBS6QS9WZEW6VX3JGK1KHYNWZKREKA375TXZM73X558449PR805JCECYQNPCQHZ7HG7FNBT8DEDRRNP1JKJX0HPVTD155K1ZNVWWP2YY9THNG8729MKY3Q77CD7NN8MENJYH9E5RA4FXQYYEGQDDPG2YS0TM595HNA2KX1TD7KYYJ86AGT7FX7M", + privateKey = "C5M9VGNMQ2RM92DA92417HA5WGBTP2R2DPZWB6E5MA61S2TNX7W0", + publicKey = "JR43Q0EGAH85B2F1VQDGHZ33318FGB44158TC0ZB4YP8XF1N3KYG" + ), + RefreshPlanchetRecord( + blindingKey = "B4DSQ4BZP3B1QPG21ME2R3BFE0DPX4GRSAVHF3S0V1YEG0JJX620", + coinEv = + "GAH89K67PPPNXZMC8Q7KEA9VHXDV1WB6WHSJXWB0852B4G5FECGAG9W36NV13AYMX7N37Y5X0NZ3WWKJHFKY8R328EEG528KPFV7E7NCJYZZB83PA6DDP5ZVAQF0N79WDANNEXRZEFT1H8CW0ZFK7533TR7FRTCDT9EWRVBDRJ23NX45C6CQVA6CK1JN9P0ZXP69FPJJAYHHE", + privateKey = "A0WS0Z3C9RGJNX9TX5QV4KYYVCTVNMW4N3FD10W97X6AF28KVB10", + publicKey = "B5MBRBBTF6NR53QPFJE5X8WX8GG9Z4TEDG2PCPPVNNB6XHHBNRF0" + ), + RefreshPlanchetRecord( + blindingKey = "ZTQHYH1RPX4XEJV6HG50EQZF6H6M74AY75N3DN4G81ENRS38AM40", + coinEv = + "FKH1C38XNYGYXK0RMMCVJHJAM60JA0M7T0XBVNV0K580M4VEW6SCR528BV65C7J3XYEZHCGMC1822J4AQXT5KS492KNB8G6MCJYJ95FMRD6R47PGWXSBH35JS95WSKE72FSB5W3Y12D6K50D5H0B7SKDQWAK4VZXNHC1S1X01KHV6KVZCTJW763K68ZABAD9R2TQYB686GE8W", + privateKey = "AQZAJRZV4R2FK1ASYEXNJ9E1K3QV0PHSBZNNPZGK2TR2QSBW4XPG", + publicKey = "N53YEANM052FMJZQDJGWGWERVB9X67VDRMA67X31Q7HKXFMJTJVG" + ), + RefreshPlanchetRecord( + blindingKey = "GHJ6XF1XWYY623QDYNDXKTZ0PRSHAN41H8SHA4RGD1HCTV9PV570", + coinEv = + "3ZP6QZ4X6RH1R7C60T51P2HQEYZ4RMFG45MN47J6NQ6YC1M9M2BAFAQBM87JD8T1M69002E5QD2QZDV8HDJ492CT107TWZCE4TQ367GDSTPT6AAFS0JRSDJPBNME5KD6ZH8Q42KNMQ4PCM7GWA2ZQK7V35Z7BDJGWRFF65QGEPBC3F8PKVC92KJ8AN89ANY9H4YM55JFDCRSE", + privateKey = "T7BME1SM7Q6YEKNS6CCEGA7Q54SB1D451H1TJYYNGV6393R2QKGG", + publicKey = "QBTQNQKCGS6CH2W6AQSDSA7M1EFF0DC5Q1AE4MK2BQM0EBG3RJQ0" + ), + RefreshPlanchetRecord( + blindingKey = "1KFVREMW5KJZ3YS65MKCM40GNTCYBREPN7KM26YTVDW0GRPSDYTG", + coinEv = + "K08B7KDNK8C179M042GZ6BYGRNEK2M3YKTR25FZQ1H9AF9EG9GYFEP6Z2KZ3FH1SEPWYWHNQM1N3507PG99B8XWMR1ZG8BR1CFFM2EQFB5W8SVF145NS7SYXSDPMJ7A64NJZEQMZH0VS2XY262M3BDMVWP7FDEG3W1XN4V0S6GXA14GWRNKFG5MYX8RBPFC1ZHPNFHRGD1FT2", + privateKey = "0Q8W768BQYAPSQCD46R3GMH7QAVT6ZJ0T5YA2A3WXXGRGX7X2WGG", + publicKey = "AK1YSV2A9Y61E1XV313KXR1CJY81RZDKZW4TK9CZ82KK7YFJQDY0" + ), + RefreshPlanchetRecord( + blindingKey = "M2FEDXEYNBXQMMWYR36QY9TSVFYW910947RKFBN4AQ3GNZW7565G", + coinEv = + "3R65GCTT7EB685EGVXCJE928KPX1HH8YNHKD3JYYRJ26PD066GG21ZEE5HXSFX9D22NH31KMK4SPKB9QKNAWK539BYSP58ZZ9W011FYTGMKMDB8CMA354JMH03B814Y4MM64EE5BA1BDJ5BDZ0X6CT5BZ51QMD8NBZTKR1SE6YF1YZ6RJDQAG12E312F2VRZNYKXHGXNDSR1J", + privateKey = "V6XNMA56RBR8VRK0GG6Z76STRA6QMNSJZYB15SFS7XX1QPFNN6KG", + publicKey = "KM59YNG5ZZH9RHCN2XF4MY9YEGXC4A7HHN907FGG9C4W6ACFNY20" + ) + ), + listOf( + RefreshPlanchetRecord( + blindingKey = "X84BZ3AZ0KMG3BYVRT3YT04ESCB7R3YEWVBZN5ZDDZ6WN00AEPR0", + coinEv = + "3B5ZHJ3NESVQ31QEBJXAGF6B5SJBEMXX5Q2FDTHRW807FDVQ3Y7T7KVQ5JSMB1DA3EQCQDNJYVZDQQQDHHHDR5TDEZZDJHX9S94KEKTRMNJDPFXSYABCCMWR7R4H1R7W6TM5Q4B5H2FYZXAVCAJJMC13ZK53N83SRRF8EVAJ2ZB51D6X7WPWQ1MQX3DPTXVZV4EGGPMYDP720", + privateKey = "FFZ1E4KGF8Z6HHYGQC1QR6RJFDQ3QRPCDM58NMK6YTFTSK6GTA9G", + publicKey = "Y7F33NG04MEFAK0QCBAHG325705B1326HCJ9WFXQCZVKMJJ8ZE30" + ), + RefreshPlanchetRecord( + blindingKey = "E3QTYEC6NNZBQ6QTSX7TJ50CQGM3QCBABVPDSZANHFJNX8CW2Q90", + coinEv = + "6NE9SY9SZXTCJP4SW01T5R3YYZFCEG7AR5VJ4JNVQ9WPB7A1DGVXDZQ68233KSG5GEBV4W4T74CK5AC2MDKST7RCJVFSVP1B4EETSDVNWSJ8VF2V7BY8W9S85Z06WJE223PW692CEE4JA0Y83Z40Z69P28NS430BX9VMWRXMZMCH9FKB92TCVZGTXM3CJ2ZG5RRPNZB60VKKT", + privateKey = "ABAF6S5WZMZZ1ZWXJG7JVEQTMJB5ZBRR4T554Q0SNDZDRFG9MNMG", + publicKey = "HFVN1JANX4A4DSB4K458255GC5S879QTSBF2DDEM2K0HPZQPHF0G" + ), + RefreshPlanchetRecord( + blindingKey = "VRWF9B40EHKB4NZ6FXQG1P26XESD7FZXSEA9X53DZ1WVXS22GZAG", + coinEv = + "EWSVHPMDEBPW256A7K20TSW2T9EBQNKCHQWRJ1HJ85NW9K058H9C5C8QZ1R2ZEKDRYSEMPM920HXSE863BG8VKJHDR1ADQFREWH6TA2QA2ZANSMNJDHCA1EMG78A9PSMZCYE8Y82SY70MJSKZG9ZF0F5EG3KJ14X21RC6HY41P79NVETG4R11GF7MADSTF11G5CNB8EX26RN8", + privateKey = "DKHBJ9SRVEGAD7Q6K7PMH8DMA8WSRCK31PEAD6NWA4T6QRCMBW60", + publicKey = "DMVWT6WPYWHHX4G0H124HDQ6QDA1T447WQQW9NVR1ABJZ31E3YA0" + ), + RefreshPlanchetRecord( + blindingKey = "7HJN1EJK12M3JF7H6J6A82X9VB0VWCAC5MW4DA0W7524ZA620F3G", + coinEv = + "5YFPXNJ0SKNBJ6ATMD4JAPCMDFQD25RWYYQZXM7X6W8V9VBVBRT4M94MTMSE6W5H56XMCT5MF51MJVM9W090223R5VWTDQ9KEAHT45VPASHXNJ817R1A2S50F7TZTDHKE3ZKPJ1ZSTC9XDS57N0JGZ4AK3606PZ5211G8BDWSAGY8N0JYNASZRZ9JABN4HNTKRXRYZKN0V240", + privateKey = "AXZTP48HT58DB4BFKREZ34ARBVREC1BECK0KEYBWFWVP7K17ZQG0", + publicKey = "HP8PY0NPT0NV32RNMEYZ63YSDYB2T9J64KXAYV76M1N9Z4GPF8YG" + ), + RefreshPlanchetRecord( + blindingKey = "KK0VT0E3KMHG39VT0B5NNTEXC9HMT9HJ6554THEVWHPHS96H4FS0", + coinEv = + "EMNAXG036TG4JNZ2TWBE688THQ1BZW8H5PCYMD4Z71K5FHSB6N8QE54V03RK0GJQVCDYPH699PPR5HDSWCBV13YG6WX9DX097VPMKTHSD7CYGH611RGDJ3KJXJQXQCDYXC869JZ9ZTBNGYGVZDVP16141Q32ARRF59T0MDKR6C4BJQ1V32Y2ZWHRD5QVRBAYS90KPDW6G00D6", + privateKey = "4NFWW24DP7V17ZN4D12NW6TENEMF54XZMNB4XVXM1Q31AMC8DKHG", + publicKey = "A7F9SY8413B3EQSVCES7AFDYRJH768J8AA8DVDR4BR06SHXT0W1G" + ), + RefreshPlanchetRecord( + blindingKey = "1KB9TCM2GDT61ARG99NA5TCG35H3P4K6ZGXEQA33P3XNQPSX33G0", + coinEv = + "1MRTJBESYQ1MSJC117JHRT90GS7WPCF5ACA198RGV4X0E464W2KMPFS4K43Z94NJ8QKP40YZCRX22ZR23MMCFWGNS5KYDV57JH4VZM6YSDVCRDGCBQDB87BN9TS2PXK8GXK3JA3G84X5HBGY7CX870B3MWBHW0V6FHMQNDXVFP1PCYZT0T0GAPPQ02MZ6Q8JGMSXT0QFXF9P4", + privateKey = "289K9AW8M3N45RZZNGEYBFHZEGCTKRY9TB15F5SNVAQ09BHKHEWG", + publicKey = "BZFET2N4MFYPQSMDASD7DJHA0EC8KGSB8RS3G34G8B5D6S9W1B90" + ), + RefreshPlanchetRecord( + blindingKey = "B28XC87F006EE1B0GRMBEW3P88J324C7BYPS3SCVHAB3KRA7JBM0", + coinEv = + "8S771KDCPD9XFRXDT7938T8HX2XNGM7FH3WJGTYBD343GY5E427ZPD0QA4XAJYWN9038K0BH6Y0R2G42T8PCAM4W0YP31FPCABPZXPCXZP52ZDB6QR3ZG01KS9GBWAJ4PPKJG24MREBVTX37R90WA44G7Y8G5FCDBEQR1TZ3PZ0Q4C3K0Z3QP1HNNEH3WEP2QPFJD82YFF1G2", + privateKey = "592P3HWA2D245M1ZHA4AMSRK6Y5A9YFVP70VAKBAF6GSVVZE7MW0", + publicKey = "VS7N76T06X9GPEPGF1K7CD3ZTCJ4R8DJW715NRE30HT92JXQ076G" + ), + RefreshPlanchetRecord( + blindingKey = "9VQATS7SQ9QRM19602PN3QF16JEF9FM8GT1BZC8WRM9Y6HRV9HMG", + coinEv = + "CMZK656TCB6BRDRXVZ625SYZTGVKX56J77NE9B6T0EHRM8DRD7MR5WDRWQV8Z5WWDTAGKPCPA3XHCTGW5T156KK9RM340N53VPYJTDXH22CKY68PY8J4J40XG40YZW3F6TREE5TRFF51VJT7QV8CHW9PM67ZK2ZZWGVXV3KJF2HVDYFS2X8BHC19B266H5BK5EX6NJ65E9ADG", + privateKey = "4QVMP2V1FJQKTCTVXFBDCCR29T3BG40RCFZCJDPG6WPAS89EWWF0", + publicKey = "MV5RPV33Z2EFXSA6XPV0GVP2WBAP580JXAB2WD0G86FM29VM2X9G" + ), + RefreshPlanchetRecord( + blindingKey = "ZTKWFCA58WC7GPYDYBMAXCH12KPTRGTXR2WMYSX8HPSAZ4M47480", + coinEv = + "7Z9T151XYF58P4T8B7M3DS8VYFS7MXQPYG40V1JZ0SXXFNVQ1E6GX6C64F36FQADNPDB7S4YDNR80TGCACPXD5ZEP7JQ514Z1ZS43D739BBGPWH5QHGGDN91V2KDSDD8AS4V2M56CF26G0RBVKV8EZTM6ATQCQBVJE6Y4S98VP31WECYSW8RAYKHMHWKN5DHF1NMZMY86KA5E", + privateKey = "M87WJ5ECSYVVGK0PDTWPJCQS5ZE27D79RAZVGNDB8HWGC4R0X1G0", + publicKey = "7ANMTX5E74K8074Z50E36D8QX5J9TQ6B7WPN7XK3DPV4YFS1DX7G" + ), + RefreshPlanchetRecord( + blindingKey = "2522XN7JAFCVXSFMG031CSA20DFMY2MF5ZTZT6W3HP59RPW5S34G", + coinEv = + "5R0DF70BTV34K5X8DQG8Q1XY55RXCRQ8KF2WK12QP90DBY2QRWF5CJDE20X5BFWD5YKWVMKHV8QWE6SFGSHDBSJADYKA7WZXGBJ2EJPAYXEHMTTJ89KH60KZME1SDBBYZA239M98JV56ZAMZPNMG928Y4P27F5BPBSGQK3E3A700BDNW1TTJ5VYPXN8SNJANW74YFPJZN2HWY", + privateKey = "ZAX0KQPGATJT5EMWP7D6G4TB8GH6ZS6C17PJ91FS4F4VWX16H26G", + publicKey = "DNYGRGC1R41AH28FY063N71RDYPFYMDB20N491R8297R33G72FE0" + ) + ) + ), + transferPrivateKeys = listOf( + "KXR71TDT7A5GBZ6D92PWYC7PY9Q18KMVC4F4VRNYZ4NQ2V6DDY50", + "DWRWHG4NN81SWFYVJ1B1EKN9RSXRBJYV06Z8SQWWWTN6W6TRPCEG", + "3ZSZTTF3S1BG4MDW1GF8PE5EX2A2WD6WBTR8SYSD6GZBS4D4Z780" + ), + transferPublicKeys = listOf( + "2KQHHDF90PETJ852EX67W441PGZ5M47ZRD8XT0DY0HGY5Y1XWXSG", + "ANNVKXH143SB5EXDMS9XN2NXV0ETVA7KFJPS94Y6356H16K0JH5G", + "JB60PMEWBJ0Z51QGBHNHVQ51V8FT0C97QX64DFSK8SXEQNSYV1EG" + ), + amountRefreshOutput = Amount("TESTKUDOS", value = 1, fraction = 81000000), + amountRefreshInput = Amount("TESTKUDOS", value = 1, fraction = 95000000), + timestampCreated = Timestamp(1593697014952), + finishedTimestamp = null + ), + kappaKeys = listOf( + EcdheKeyPair( + publicKey = Base32Crockford.decode("2KQHHDF90PETJ852EX67W441PGZ5M47ZRD8XT0DY0HGY5Y1XWXSG"), + privateKey = Base32Crockford.decode("KXR71TDT7A5GBZ6D92PWYC7PY9Q18KMVC4F4VRNYZ4NQ2V6DDY50") + ), + EcdheKeyPair( + publicKey = Base32Crockford.decode("ANNVKXH143SB5EXDMS9XN2NXV0ETVA7KFJPS94Y6356H16K0JH5G"), + privateKey = Base32Crockford.decode("DWRWHG4NN81SWFYVJ1B1EKN9RSXRBJYV06Z8SQWWWTN6W6TRPCEG") + ), + EcdheKeyPair( + publicKey = Base32Crockford.decode("JB60PMEWBJ0Z51QGBHNHVQ51V8FT0C97QX64DFSK8SXEQNSYV1EG"), + privateKey = Base32Crockford.decode("3ZSZTTF3S1BG4MDW1GF8PE5EX2A2WD6WBTR8SYSD6GZBS4D4Z780") + ) + ) + ) + ) + for (v in vectors) testRefreshVector(v) + } + + private fun testRefreshVector(v: RefreshVector) { + val record = + refresh.createRefreshSession("example.org", v.meltCoin, v.meltFee, v.newCoinDenominations, v.kappa) { + v.kappaKeys[it] + } + // use expected timestamp, so we don't need to mock system clock + assertTrue(v.refreshSessionRecord.timestampCreated.ms < record.timestampCreated.ms) + assertEquals(v.refreshSessionRecord, record.copy(timestampCreated = v.refreshSessionRecord.timestampCreated)) + } + + private class SignCoinLinkVector( + val oldCoinPrivateKey: String, + val newDenomHash: String, + val oldCoinPublicKey: String, + val transferPublicKey: String, + val coinEv: String, + val signature: String + ) + + @Test + fun testSignCoinLink() { + val vectors = listOf( + SignCoinLinkVector( + oldCoinPrivateKey = "884XH9DBQT9MA34YKCR7WPEP2HH3J1R9C3G7MSB8C3NHB0XEVZW0", + newDenomHash = "ZVZMM6GEFETH1S71BRKFKB03BTSTA8JZH26Q1PZED78NZ50EVVT0DMDBPDN5EBY90SK4AWT0J8CNPD7NB2TSCFMSKAS5V6DK26GEKT0", + oldCoinPublicKey = "3GSHK9JCTMEAZY7BMC1QQD28ABEJEJJJXHPVF2YXTET97WB81GNG", + transferPublicKey = "ANNVKXH143SB5EXDMS9XN2NXV0ETVA7KFJPS94Y6356H16K0JH5G", + coinEv = "8S4CVYXJNHAQ93Z2ENGKJXNGYEXXMKE89KXH1TVRGVEY9PJ1NGFGENCKWZR1ZFC8W0TFXDGPYEAPHVPJAY0PT99G6GNJKDYV0YC89XGZK4F5HGN92BY7HT04AF2HY1RED8B0KKRKX9RV727XSV5QG9TZFBSAP4Y4HXZKEN1DEBRR2WJFYSZ2MYV50XPK5T7TPPMM30TX4PX88", + signature = "H1V8D30JTVW8F7KERCWRDHPVVV6BZXNE0QG4V7ANEPTVFT2NKEWCCRM8NSG6D6MBA80ZSSYG1KYZXR8CWG0HV6N7QA48C4YEWK4M030" + ), + SignCoinLinkVector( + oldCoinPrivateKey = "884XH9DBQT9MA34YKCR7WPEP2HH3J1R9C3G7MSB8C3NHB0XEVZW0", + newDenomHash = "4VEW03Q1JRQTKDGDKV73DX4RYHPDMPYQ48BZWAFVFPB03XK9HNFKWM1KZCR0BHXGS0034W12CE1VG1J2YEN4G7C1400MGX35GDS263R", + oldCoinPublicKey = "3GSHK9JCTMEAZY7BMC1QQD28ABEJEJJJXHPVF2YXTET97WB81GNG", + transferPublicKey = "ANNVKXH143SB5EXDMS9XN2NXV0ETVA7KFJPS94Y6356H16K0JH5G", + coinEv = "CX96G0RTN9N3QFYNKFFWG165XDKYW7FJEMB5K9SKKYB6A9DCK33T34VDDAJBH9VGG6ADXAM2DBVXZYPVKST9VK2KN943A9MRK0HNP8JVEJK84H9CVA4D1Y9N1351V05H2NC8TEDG43CS6RHSD37HX08N3Q03EW887GF2HF97MW23WH9CH2HBBNS9683DH7K5PY6H3MSQZE0S4", + signature = "90NEPFDDK99524R9RZ3H4NEK6S5N71CQN6DAXJ9S87YASHB0JYG3VJKSV7BVJVCRCHWDYYS61CXQ4XWW9DMS6HAV0PAPWEY7FPKB430" + ), + SignCoinLinkVector( + oldCoinPrivateKey = "8G7F1X5EQV9P7JK1YVY5MFYX18Z85FRQFCKFQ9J5HX8B03QEAR80", + newDenomHash = "HDYHXFJ65YSW7BEVCP1G282E4QHQ9KSF8KBQNM4JN4T3KD30YVN2G6G1HFWEMSHJGAW1VM6N8HDEYRXVYJ3A16C58WM6QATMENGJ5CR", + oldCoinPublicKey = "4670V0PA3JG928CJFQHGJAC3DKCPJR3HMRRYHKAHTEEGQDK78HAG", + transferPublicKey = "BH4PSJF3T0S9QNFARDXTBWXWT46AX5CH9JENP5D1HQVTNK9YXN7G", + coinEv = "PDGXW6PDZTFEGMYFCVW972V4WR9WXTYW6JBDCW52DCEPCECBK5AX8R276Q5JZ38ABQ0X0E8NSSV28H56CX3MR3YTXFAJKHY9M93EECWDCK6AA4259M6DYSS1D4AMQ2PQJH3Q0BFFXDS9X5Z6VKFM0EH05KM91SBB6679NC2SF0B4A1G2N5KTQEXCQADNJKHGGZJ704PBPYCGR", + signature = "7STWC1GZ6WWYBYWBNA1BDKFYAX8TCRGFP36A3W0TH72K51R9DXMVRA2H6N21CSA40Z3SQJ0NCFBNTR0781PX3027QX7KZ9KC7Q3SW20" + ), + SignCoinLinkVector( + oldCoinPrivateKey = "8G7F1X5EQV9P7JK1YVY5MFYX18Z85FRQFCKFQ9J5HX8B03QEAR80", + newDenomHash = "Y7N6C15W0ADR06Q2QKK15VWKV09FMHC0BHT0ZZ95FP8T7G7T2GCYWVMYRE605QCP7NQ02QYH3Y9V834G37K8KT7V3AY75N715AYX170", + oldCoinPublicKey = "4670V0PA3JG928CJFQHGJAC3DKCPJR3HMRRYHKAHTEEGQDK78HAG", + transferPublicKey = "BH4PSJF3T0S9QNFARDXTBWXWT46AX5CH9JENP5D1HQVTNK9YXN7G", + coinEv = "63X0T7DWHJHMKR7GX4D9CTNHVMGRKBN4C2VHBSNY6W8JMJYJNDD40XZXG9RB7V4W7HHJ605XK6R9VSBMJ8WCF5AQ3A0631F6MFTSEKTKJDMMP8006DGZ0XMW43JD0PD2XXX42HPAD6DY0ST941NYKNMA050ZMTV1JKNYW9495RVXQCMKPJ759KCC4BA6066E17Q7AVNS6EJD8", + signature = "5NVY7F9ZBSHY0JP1Q8JFTJYCY63QRT2JNV3R25050451ACYDXZ4YT0CE3TQYAECMVZ3F092GN7TQFNJF3G24JZN6T7XHDDG4MT0M82R" + ) + ) + for (v in vectors) testSignCoinLinkVector(v) + } + + private fun testSignCoinLinkVector(v: SignCoinLinkVector) { + val signature = + refresh.signCoinLink(v.oldCoinPrivateKey, v.newDenomHash, v.oldCoinPublicKey, v.transferPublicKey, v.coinEv) + assertEquals(v.signature, signature) + } + +} diff --git a/wallet/src/androidTest/kotlin/net/taler/wallet/kotlin/crypto/RsaBlindingTest.kt b/wallet/src/androidTest/kotlin/net/taler/wallet/kotlin/crypto/RsaBlindingTest.kt new file mode 100644 index 0000000..0ced114 --- /dev/null +++ b/wallet/src/androidTest/kotlin/net/taler/wallet/kotlin/crypto/RsaBlindingTest.kt @@ -0,0 +1,114 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.Base32Crockford +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// TODO move to commonTest once RsaBlinding is implemented everywhere +class RsaBlindingTest { + + private val crypto = CryptoFactory.getCrypto() + + private val vectors = listOf( + TestVector( + "TT1R28D79EJEJ9PC35AQS35CCG85DSXSZ508MV2HS2FN4ME6AHESZX5WP485R8A75KG53FN6F1YNW95008663TKAPWB81420VG17BY8", + "040000Y62RSDDKZXTE7GDVA302ZZR0DY224RSDT6WDWR1XGT8E3YG80XV6TMT3ZCNP8XC84W0N6MSZ0EF8S3YB1JJ2AXY9JQZW3MCA0CG38ER4YE2RY4Q2666DEZSNKT29V6CKZVCDHXSAKY8W6RPEKEQ5YSBYQK23MRK3CQTNNJXQFDKEMRHEC5Y6RDHAC5RJCV8JJ8BF18VPKZ2Q7BB14YN1HJ22H8EZGW0RDGG9YPEWA9183BHEQ651PP81J514TJ9K8DH23AJ50SZFNS429HQ390VRP5E4MQ7RK7ZJXXTSZAQSRTC0QF28P23PD37C17QFQB0BBC54MB8MDH7RW104STG6VN0J22P39JP4EXPVGK5D9AX5W869MDQ6SRD42ZYK5H20227Q8CCWSQ6C3132WP0F0H04002", + "7QD31RPJH0W306RJWBRG646Z2FTA1F89BKSXPDAG7YM0N5Z0B610", + "GA8PC6YH9VF5MW6P2DKTV0W0ZTQ24DZ9EAN5QH3SQXRH7SCZHFMM21ZY05F0BS7MFW8TSEP4SEB280BYP5ACHNQWGE10PCXDDMK7ECXJDPHJ224JBCV4KYNWG6NBR3SC9HK8FXVFX55GFBJFNQHNZGEB8DB0KN9MSVYFDXN45KPMSNY03FVX0JZ0R3YG9XQ8XVGB5SYZCF0QSHWH61MT0Q10CZD2V114BT64D3GD86EJ5S9WBMYG51SDN5CSKEJ734YAJ4HCEWW0RDN8GXA9ZMA18SKVW8T3TTBCPJRF2Y77JGQ08GF35SYGA2HWFV1HGVS8RCTER6GB9SZHRG4T7919H9C1KFAP50G2KSV6X42D6KNJANNSGKQH649TJ00YJQXPHPNFBSS198RY2C243D4B4W", + "5VW0MS5PRBA3W8TPATSTDA2YRFQM1Z7F2DWKQ8ATMZYYY768Q3STZ3HGNVYQ6JB5NKP80G5HGE58616FPA70SX9PTW7EN8EJ23E26FASBWZBP8E2RWQQ5E0F72B2PWRP5ZCA2J3AB3F6P86XK4PZYT64RF94MDGHY0GSDSSBH5YSFB3VM0KVXA52H2Y2G9S85AVCSD3BTMHQRF5BJJ8JE00T4GK70PSTVCGMRKRNA7DGW7GD2F35W55AXF7R2YJC8PAGNSJYWKC3PC75A5N8H69K299AK5PM3CDDHNS4BMRNGF7K49CR4ZBFRXDAWMB3X6T05Q4NKSG0F1KP5JA0XBMF2YJK7KEPRD1EWCHJE44T9YXBTK4W9CV77X7Z9P407ZC6YB3M2ARANZXHJKSM3XC33M", + "PFT6WQJGCM9DE6264DJS6RMG4XDMCDBJKZGSXAF3BEXWZ979Q13NETKK05S1YV91CX3Y034FSS86SSHZTTE8097RRESQP52EKFGTWJXKHZJEQJ49YHMBNQDHW4CFBJECNJSV2PMHWVGXV7HB84R6P0S3ES559HWQX01Q9MYDEGRNHKW87QR2BNSG951D5NQGAKEJ2SSJBE18S6WYAC24FAP8TT8ANECH5371J0DJY0YR0VWAFWVJDV8XQSFXWMJ80N3A80SPSHPYJY3WZZXW63WQ46WHYY56ZSNE5G1RZ5CR0XYV2ECKPM8R0FS58EV16WTRAM1ABBFVNAT3CAEFAZCWP3XHPVBQY5NZVTD5QS2Q8SKJQ2XB30E11CWDN9KTV5CBK4DN72EVG73F3W3BATAKHG" + ), + TestVector( + "0FDX7T3AXJ3AGTGMVSE8ZCDJGKH9DDBB0NHVF42S8D1Z4E2T1KAYF03DP6AA0GA6YS7Y64JR0YHFNWDGFSVH2VCTX57F71P92F6PCB0", + "040000YE5QYTJTCYF7YDWN2ECYAMBNENHQT7YT740XNC88V5F1K4YC2QD94WABBVHZY597B2BTGBD2NJJV028JKJTD1KBPKXF4D87B7ZJYZVQSA4ZB5H1FVPE7X7YQVG668YZ2YY229X29NM4B6QR0G3TH821QBT1J5EDPKS0RP8E6X4654DTAAYBEN14H96E8D1JFVE40773FVVPXXMX7ZXT7TCVC2EZFMZR1HQ2DDXD8KJZ9AEGS1YH4D629Y08T9X2533MS6R4X58VVKHN1YQVKJT2044A0S8B4AKCW2GJHMQM10XC3K7C3D1C841A6R96GRXPC02QVBQSA1D5VY5VG2T4HVC6NKVK5WAXDEYZNKYVPD9AV4MNCYGK23AZWGHX5E16BQTNG47C9DEETP2D87XFC9D04002", + "GJTG5KGE8XTSMVCMZBQGPFVBQ6RYX8RR2ST8JKNJKZ3KJN1SKJPG", + "2VN8NCT3JFWKDYJTR048YCKWHZ2DHKRSFA0Q42R8FB33YH2GMNH9M89AHRWSKCVYJ0000PE1PRWVT0DM95VQ7R883JZ2DEFQ38TVGMTKMSRBM6JK56A40S89HA6E3ZV40VZ9RCXGNY095DBB13SV68SYNGJ0MCDKW7PRA04YE8TRJYJV527Y1XJ4B5Y0FF8TSEE5YCNM4MW1M3P1AP6V8W6KKCAJ213N3P01K9WTKP45PGEJ0RWC0K5789V4BHMZAM2EFHPNEX24KWC7G59M160XF60ZNYCP9V0GV0V80K4XKD7CAXTS5YNZBPXQSTK90DEF750SHMVKATGS3GW1TGESAQ24AA4ZG5E9F58KWHBMD1WKP0VDBVTGPECQVE5F79DFD2WBB86M3NQ04RE7YGRWQ4", + "JFHDDJXQPAT0F1JF0GPVGC1CEQVQ9PXMY88KWKS16MEGPYBAMD5HAR2JHX8DAE8ER30ZYX1GBBYYKYN2M7Z0F29G6YHDF9SHZ7RK2XJW4J4HZPTS1ZV4TERC37Q3B1380XXP1SNM5DFZ0FW9W2PKSWEA0NHFYMCF8FR514WJ795W8ER9EKNGY51YSAF70ZG2DE3H0PPH69BWHJ4188R4AN7JT0T7WVVK0AQP9NAB36TAGMSRNKWEDAPMS0GP4M3BCY6WRXF8JQ52QSVWWMT03WZKRVGRRN03AY57X6TZD29AQB7QNKB4CGGPEW95Z2W2K7K3CV8EG1Z8Q3S7Y0M9VZ8D29CQQV4D75NQXRDFHMVZJ9X8EBPF1XHPM0FTK5GB7CDPRZ0SX5NF1HQKMHZMP35KSG", + "J99Q5G54YP6Y29HCR0X4385TQASSZDPMM0H3KZPD1744KRHJ895K4B41N4W03M9AN4RN5WR54NDRV6GAQ2R83K605NZCECD5BMV62Q7TWS4XVAK6ZG7MTHBNW9Y8Z5EJRR4BAJQ6QPKEPD3N0CXSHGPJM1KGEZQSYS4ZZWG7Z5YEYRAZ1S4VP03KEBJ1GR2PDRE61XBPCKEXXWWSWKWDP65WZKYYQNWVCRNZ4AQGR1N2H4EN3STBQC8126AFYCM73K9VHCB7P171E8AP0QZSKP5SEK8XCA3F16KYBZ0MD7EMY1NB0KFF3K51C227Z2WMCW8DWSJM39CDP7B5E7ZZATVQ17W4FN2HT00B26QH4HPCJ0Q66JNF8T3086ZEAKKTK835RN7SMGZATD8NN2G7QGW344" + ), + TestVector( + "E1ECK6HSEYXRDPGC9ESVVBFR5516FWGEXW0K3K7PDGPHFE32RB6F5G0EM5VHBVGW4TJPTMM314M2YTZQ1RJDNJGBTC2QP8TQTB04AYR", + "040000ZVQC32P71MYD6A4FTT4P2DABQ100S7AEQ2BB4CW1YY8RMXK24SE7RG15VQT88KY0JP8JGTDTEA2ZE8MBHNGR9TQJXHAAZQ67D54826S5ZVNBNR4ZTC6DKF2KTQEGFBV80ZNXWA97SV2G8DXVSER8J1ANWADDMCE2C3QKX7VJVP042V48DKHV36XCS2W5Y579CKY7E4MXPK7CCP1B3FPXE86JRZ3FBVHW0WBXSJ45HQYFNTFA8GEWNN81JDVX9D36XCX70C5CCFDMY53J6ZYYM6MSVTXFM4X33TMCX21EZARHCS6Q9SJV201SPQPXZ876EJ0CPYHC9PYRZTWJDHECJYMS97CFHEWCVR7C3G47VZPHJS50YXS2CP8RT8CSPG63GNAFBMM7Y67BMTED0HND0BDX4Q04002", + "XN5QNPBBPD69EM597VT7W1RCJT8N1DC4PS65CTV3V9KQW6K27VB0", + "7YC9YVSJAPJ5SMQ68W761K61BZM1J6NMQ5VW57SJCVRQ7G1KPCZ39F79BTY96AP134SES5QM3AQSMNA7FR9C65B1NA2BD7EKM12SNNSG25FRQRD902692Z535N2ZC6R7BQHHQJVGSXDA19CH2C7YWSWGVR04DBA3RPAB2FWRV8D022Q2W4RMFKV8ZETWF1QZ74Q1YJFJW7SBGFAM40FRW881WFQGX1N0HT63FJHH5B9PSGQARXXSRHJDHKAM1QW1PA2M5DCGP0875HE1ZG90RPMVXC33BP7251SP3XJRX6RNFGVTFEXP2GDKP223NHCKQ09E0P4KT5C2TJZ0TAVRDKDJFERBVDQJWSQWNXHMHJK210N2ERZAAMAFSDBGEMAR07T2Y9YJY1DX9QKWXHSTG8AGVG", + "RK6Q1F01FGNFY1EA6M3T9879HD8E84CP30G8PQQBED4VWF3AZMK0GFZR7A2Y2VGSSVJP4TCB6FVSV9E810NN2Z5SP72MYY9EHEGEQGRD9840R81A3HD3Y4BSJD94J20S18TNDFVDXRQMMJ8KJGWZRRWPCRHSYPFYDJT6MW4DAMMNQ1437BS1YP6AFR9RDW8AFRV0M1E54EAB8BY3S9W7E80RJZT19W4XFBC777X3VKFPP8HNVTFM4970V8GYRFZBAWQNPD25V72H40GK9CZK818XHJWZNC0Q86XCNFZ05GEW1A4AQ8921A7MTKSBKKNQ8ZRH9PRJ7TZTZ5AF9RHGY1EXKC0KG4PS74ACXCRQV316K64WEXP00FF8455Y1WB5J8V4894Q93T1A8W8TT8KRKA6RG", + "9AWAWCGCEN0PPGJ5NRPYVF4H1R845DGX5H26JQ8TAAJBYQNGPMWQR227M3T7RQJHTEXSSQZR95X5PDKNFQ57X72JJ39Z0WQ738ZJY1ZTVWNKBNW6S3CM9WMVWSEZD84W8EXVPD0A3TC6ACM7BNBV96N33F2SD6BC8C9QGW7Z5RDR2KXDZHSD16C5ZEX6P3F9Z0K6DVVAX247CZZ8Y5VVQQANGJ99TN0G7ZYMJBH3WQAFPGN40FGXC56ZBB8WZ0Q9Q1T1AK2VJ45VTJTEG78GPA2PVSK636MPY3F1W496BCVMVD6H4GHG0RWJR707EPTE72C6JJV7VT8NXH0G6K6KF6THNTEMK7HDZYR1S896S36H9SV3XWQ55NXQCE7NQYFV2WRA2MJ02V6KQ5D78ZJ2PJ5WSR" + ), + TestVector( + "7953QJ86Z03P607D6PZH39569VYQ9TVZ2NNT7CQJGA5P15JVBSRDC633YSSE5M4W6XWG1X4MBFEPYN7S1T9BECBGEGE1ZZK6789JQ48", + "040000Y7X1S35KW1MTPR19MD8F59QVY42WV37WB8YFVA9Z7SQ1RFEPF4HF3NN1XSSW1XMJS8DYJK57RH8KKQC5EHR7DS7PKWCJT6RJRZGAX0GN60990HB3XKM34PSKW4ZTAZDGS1KSND3Y9TS50A5G7HDF4K27SC8FXNV2VGGW9YQ6CC0F9E3K6D3BD1S39VC1WTPCYP62BVT2B5H9E5G49TVPDYXRTPDSK7FSFYGMTTYBVVJXQVSC2QKS3ZBY2HF1Z29AEV2247KVP8PP8DAB9J6G6WEN1HCXCCSNP5WK8QPJQAA9QWW1G4Z0P7Z7BGHFJFPJHY1AHQ5GGSSDPHET2SMP6GXJK2SZ42AV4CR85YEPHKVRMAHN8ATSK89BWAFNZKWBZ0P31NPY4CW9456MEDDZT1SDZB04002", + "KRVDHJ0V57V8JFYRG1YRDB5Z5A5YM10QA4ZG2SH68XNJ2V71PF10", + "B62RQP191DY7BZEEWVPDPE5S5CQMNFH3PBBY927F70W6JF5S8SJ3NGM6JV612SPJWRX8XM8PSW691R69QFGSQ4W54KT828TG6AHG1XS3T74HB4S4XA1SVB7WB8W7TR7ANKRHZ3EWKC52FKYRS05SYRYB9QDZ072D5S4WHNG3YATSF9AD9YA0S5482S5VY0C0JFET733CVAMR0Q9SC4BWBPDJSNH8WK3ZDFYM4DZJNFPHRKM253TW3QF1WCHDQVJAPBDT3J8PHEQGAKVJWJS3WAPRFW2RY87WF0ME2YY75H91ZCP0Z0KYYWF1GQC7T5BP6E1FWJH73P6QNFA8H9AX86EBGK7WMVMM0T9PDPAVHW0TJB7FSX5JN37R5EW3FCR9DGYE3NY40ESDYP4NMTEAW2YR04", + "79DRCP9VWHYMG4B110MNKZTC5EJM2HG5DRY1WVTZHRV0T2P9YW48ZX3HRSSW3CSC9HH3J8AN3XD06KTYWY29HFV61VFRC2CST20H8RGJ6QNWMTSYB6636C52PENV318QM9C2FQXVTGS8RFX9A3AG4DCRW3SZPPWPAG3GHV8VC3AJMT5ZA39E0B8QDRXSMAN1CA9ZF7NVRKZ7ZBY91XDYGSYGZXA3YFF94EGF62HPJWX2QBN6A8M542EDS011VF9ESQ2H4WEPXTQPBEGF00MRSV0T44YXR3R9QHY607EJKJF7VFDXP0TAZDD5Q3YK2J5GFR1CH6Z2QQ6RN8T2X8GP0PDF722PBYNNCS70J9VRK6VW2W1M0MX8VF28NMVWQ17F45HC4YDZSK0S444T505VMW2BPG", + "EQZKF4GGRF7M2RAS80XPP2KEXQT0SC55NEQ16X5MYJT0QDM8ERGVAGNT8F5NARG944VTMJYDD5WQ3QPFPMFB25SJ8VQ41MS3X3C7SQJP060JHDCK2297XKPQ1YN4PXQG55XZCT2GQHBX6Z1A10H24ZNPACRB0YPAE1RTVDGST0SQ5C8KS1R6SHC2NR6YGPS41ZZYMP48FBQ5KP0N5ANGKTBTMXM1G6CH8J9ARS7283WF54Q04XK9Y79HYW58RRFJSTQNTGHSCSV0KHS245VASSVG752F2XYVK96KH4715G7F4PHAWVW2F85XTZM15FPNDR3R34NX15GYVW2ZT2SXNNAVA095H60BY1G5J977XE5TPTZ4M7T9MJZJG179Z53EPDSMH0VRHKTVK0T44T5BXF1H6M" + ), + TestVector( + "8H0BJPDY2PFSKW9VQE4HQ8W5E62M4YK3169DE7JFNRC28P9G76HDFZMS16CMTWDKRNC4JVHXAH1J03992XDW77N4EGBR937AA5VCJ1R", + "040000X1ETZJWD59MT15XTV37NB28BM76G9G5KCHWRE9ZAR1XJW1RBPCTGNB9TE36FAXQV8FJ82J390PHZPMR45X3SRC9CRC2CK7WQSPXW79826SDX8CASC5J2Z92D8ZNN38SEVK3F0PE130TDQ28B9K45FEXF7GEF0Q2BJFCAJ8B6QVGD5NEDGKVCH6DVP2K8PAXCHMSBHMGH9MPACAHZX502VPEHZ6E1ESBNS1RX5XRJ1GAVH5VD2TTFYGRBD6VGSR8G8B9147Y1YB6FJKGTFNVCACC29NFEW98EDBVRRNXEX35DXTAV1HV5NFSA4EN7M7AE8XMT07TEF0KHGA9S0H8NHHE90E0TCAPSYKFTBAMR5YES6KXNNSJXN3SPS9BDC9FTS8K5W3FDGPWVDN6PNS4VN4DYEK04002", + "DKQYT4AT58TD2WY6AF7X8TG30ZF0V6WZ4BZ92Q68ZJQBKVJ3JDEG", + "HKSKCY5F1PSPCHMDWS41B7RF1ACS4ZJ882Z73GTC8TT7P2M8QP68CAC8SWJ972FN3XHH4JYZDTW565062Q48ZTR9ZG68RHWAMZ689RFZ9Z4N4P8XMWHXNZCCQCNNZBRZNYHS33GMFXV1JJJD1VB8PJSFYFQCTGDDMFM54JJCQZYM0E93VJMDNWR02EQTWMAGSNKERJDFBQ2HEE07TS6T8AW613J0RQBFB0XD0EWMP10CCQP26RD2Q9HKVZ9EWYV7DQ3N8S1VBPZNTKB8PQBNJRTMGA3P2EVZT6ZD45V6KX4DG86T0EZGX4JWDS3BW47VZC1N0P6N72A4NKHT67DJEN0V8YPJNGRQG80DD92K4HPYK6JBX96YRJ65WKP3M1YQZ5WP325DF4FQS87R1AKYNKNR9R", + "BD9TNRSSMEG2NTSN8E7G04VMXKNR4KZK6CJ31TX9QDWBC7T86FYJR3JSYWF1KPNDQSQWF896JV5TW4YY4Q32845KH39DXXTGXMFHBCDC32WEFR65YGPP45A2Q6SVM1WCNJF2RPSTQZR072YA4JR5NF8CQVGPNB459JMFJSGZ2ETBAYH1V65DJ20VBHRW8E8D6JBZQW6MD96C1D7RNHAYEDPGCRPS6RAR8HR0M08E2HXGSBD1PB2C6VYH2KQQBGDE6XRPX0NXD07E5PG46J7X5EMJ6RK8MVVR1XQVKSGWJ2T9RZTRT957295016DMW9G1814JENJ7BVG9954ND37GZ9H9DQXA9GY4QPB778Q2N66HGGSMEZYCHVTZX2FXC25PCCY2XNH3EXSA2742EXE5PJG0QC", + "3WQ1XV9PZGKHSFJ1AQ47RZ3NE8M8ECQ1RA0ZNSG6WJJYVV9F2SENEDXWN5M3DZR3F5WEGAB6VVT5V7EBW42H5A48D29AKZQF6SX2ZEJNQE1DCRBTCC6EPJGBJFXRN8AK4PT8JDTP2VRAMRFN62P2VMHZS4Q5EH431E7DYCPPC5YC5CKJT26R22Q1GZMW972RQ5WEYDMC7YW7M5WQ22G2KACR3MYEJ1BYRPD981ZPFKEB4M8TJZ1N2H7NM3S52PMXZ5HDSEMA8Y2MWW3KG3YJF8P0K8ZWG409S60MWVA7VEC1P1PREKT27S8CCPDQRFR5W9S6R71FK1KARJRCW5GPZCESFQNZT3A6DT2MNTWHG7KBPYAEZG8ZDS5CBFVRK7BQ7ZHVQDB7TTT2M64RF52DX7BNW4" + ), + TestVector( + "JZB0QJS6FC7K18RTF3T2T4XVHNR98BVGP811NE25T1333P7XQQKAKAMSCQMH25D0H0JM8ZW5QBX6H1SX6SZMY7VYAY2HEKRX9JPV86G", + "040000XVGVWCHVQVTQ06Q5V0XRAVQKPPZQZ68GYVXSC5RAG37VDCG0CEQHS4876BX6DDABB2WFY7TRJ7MFKTMMDF7A7ZW9PKQ8S3RQ15TVTKWBFGGKBKYSP6CVHNG9AY738NCPC8AFWYGP8J2VJE9HRR7M1GQK19E2M7Q2Y54KCSZ583BTNX275DW6EYYE1KBV4FK009Z621EHF5R87S6VQDSBCKSK15JCH1JYC2VPRHHAEGRA2WYX1HD9KFET0C9G1CZJB1MHZ5Z7Y803YZJH441P3PJJTRB9WCTA03H6M43CJ9MB33BEJ3KR22R8CS0D6QC2E7ZQS5MGBWCF51FK97SHCJW93SAT7VHB3YX5VVDNTW9N3SDW56HNWT11D306H9VN7BTP84T404VF482Y09K4SHEF5704002", + "RF9NJTZVPAFGRZ062MQBV1136KZQDGVQANQTRD44Y6QTX3Q47BN0", + "DVW867P5XYQAX2B4V9P1N0F6W24MB1E5F62XQS8FBXT3RQBKJYMXSY6P48SEBNMQC3WPXK4PR1QDEYT24B4TVGRN9E4RYWAAE1VNHTBME20HTK6YZBH9EX91WW3Q3XNCARN5M01YKPV3EKRJM4N7DCN47H75WK6QJRQHARC00GSE1640H5BJV9X1DBY73307JT36G08E46RZ48E6HNYPRDVW0KMGJDJN3YZ93ZBRX5B4S5YE89ZSW6VCRXH0X0H9ZM1G67ZXD12CXAWFV1M3Z8WYMY94Z351T3WBFTFPB11XWE1H1H7F8QTBJRZNGBGKHA4X0X8BW7QSB1FP9F1HQBPN2TPW9VN167GEX98MD8C1CSNE9G0GGY60MV74Y12JCTCKWGPMTWG95KZQY5VYVPATXW", + "64DS6GCWVDHC1032251MY50QZ1XVVZHQTH318Z51B3A3PDASXNNWA1MX52GS9V82K53WDGHTSDAJKPTQPC1VDT6VFGFP6JAANJXVDXJAFB3D30ZMRQGEN459B876Q7C8072NA8T3JVMNBH12NHE03GT9RQPESCKR79GAYDVYKW56VJ6Y7R6A4VG9YGFWZWH6CQ4E5DE49FWYMQ815DQHKN0AR3FMK006VAN1PRTNS0SRX67H3BQKSQ3K0HKYJSM96PZZFP8H5WNCF4WFMQP5JDVWH0WZGFEMFJE4BPNXJ0GPPJ0AQA5V8YRT0QY7DK5AVFE7DK0W6VVYAPMNVQDP2ER4ZD56HZVP6AQWCCMHCMJF39FTY7JT2CY14TPYP75RJB9SACVVMP7XDXXBEEJDTBWQ38", + "BCDNEWHSBMDGGEBN1GD85C7T7MCPG3AVB7V8EYSQQ6Q5JNWR95VXMBSQ1QQBAEZ9B0H9VPCZW3G1JDR68P56ZFDDZ4CVWEAK9AWMR5B2T2VF8PD7TP1ZG0YN02GJN3J20NJ5TV06FSZX6JF8MFWZRP646452VE1PY18KCH6CDEHA9YMJREFXV5HMV8KNCHFC0RFZ0CE0HQKF6PZ5VRWMY7ZD3ZC9SGNMWDRS4CB32W9WHX83ETD8ZMSPQENHKG833QY9EPZZFX520D6GKEBNRHBBJS0AGXWKM0DC6V2CE7Z4PR6PT8YT41JP2RQ8JJABSTTFWEE7TR17YE9GG0FSMHAXY7CC3ZEMJRZYKKYQ5SZNC2GHFS5XN1D18TQRASJSWRKSEJBSQ79QDA0GHFKG3D6XT8" + ) + ) + + @Test + fun testBlinding() { + for (v in vectors) { + val bm = crypto.rsaBlind( + Base32Crockford.decode(v.messageHash), + Base32Crockford.decode(v.bks), + Base32Crockford.decode(v.rsaPublicKey) + ) + assertEquals(v.bm, Base32Crockford.encode(bm)) + val sig = crypto.rsaUnblind( + Base32Crockford.decode(v.bs), + Base32Crockford.decode(v.rsaPublicKey), + Base32Crockford.decode(v.bks) + ) + assertEquals(v.sig, Base32Crockford.encode(sig)) + assertTrue( + crypto.rsaVerify( + Base32Crockford.decode(v.messageHash), + Base32Crockford.decode(v.sig), + Base32Crockford.decode(v.rsaPublicKey) + ) + ) + } + } + + private class TestVector( + val messageHash: String, + val rsaPublicKey: String, + val bks: String, + val bm: String, + val bs: String, + val sig: String + ) + +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Amount.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Amount.kt new file mode 100644 index 0000000..2d39bb3 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Amount.kt @@ -0,0 +1,210 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import kotlinx.serialization.Decoder +import kotlinx.serialization.Encoder +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.Serializer +import net.taler.wallet.kotlin.crypto.CryptoImpl.Companion.toByteArray +import kotlin.math.floor +import kotlin.math.pow +import kotlin.math.roundToInt + +class AmountParserException(msg: String? = null, cause: Throwable? = null) : Exception(msg, cause) +class AmountOverflowException(msg: String? = null, cause: Throwable? = null) : Exception(msg, cause) + +@Serializable +data class Amount( + /** + * name of the currency using either a three-character ISO 4217 currency code, + * or a regional currency identifier starting with a "*" followed by at most 10 characters. + * ISO 4217 exponents in the name are not supported, + * although the "fraction" is corresponds to an ISO 4217 exponent of 6. + */ + val currency: String, + + /** + * The integer part may be at most 2^52. + * Note that "1" here would correspond to 1 EUR or 1 USD, depending on currency, not 1 cent. + */ + val value: Long, + + /** + * Unsigned 32 bit fractional value to be added to value representing + * an additional currency fraction, in units of one hundred millionth (1e-8) + * of the base currency value. For example, a fraction + * of 50_000_000 would correspond to 50 cents. + */ + val fraction: Int +) : Comparable<Amount> { + + @Serializer(forClass = Amount::class) + companion object : KSerializer<Amount> { + + private const val FRACTIONAL_BASE: Int = 100000000 // 1e8 + + @Suppress("unused") + private val REGEX = Regex("""^[-_*A-Za-z0-9]{1,12}:([0-9]+)\.?([0-9]+)?$""") + private val REGEX_CURRENCY = Regex("""^[-_*A-Za-z0-9]{1,12}$""") + internal val MAX_VALUE = 2.0.pow(52).toLong() + private const val MAX_FRACTION_LENGTH = 8 + internal const val MAX_FRACTION = 99_999_999 + + fun zero(currency: String): Amount { + return Amount(checkCurrency(currency), 0, 0) + } + + fun fromJSONString(str: String): Amount { + val split = str.split(":") + if (split.size != 2) throw AmountParserException("Invalid Amount Format") + return fromString(split[0], split[1]) + } + + fun fromString(currency: String, str: String): Amount { + // value + val valueSplit = str.split(".") + val value = checkValue(valueSplit[0].toLongOrNull()) + // fraction + val fraction: Int = if (valueSplit.size > 1) { + val fractionStr = valueSplit[1] + if (fractionStr.length > MAX_FRACTION_LENGTH) + throw AmountParserException("Fraction $fractionStr too long") + val fraction = "0.$fractionStr".toDoubleOrNull() + ?.times(FRACTIONAL_BASE) + ?.roundToInt() + checkFraction(fraction) + } else 0 + return Amount(checkCurrency(currency), value, fraction) + } + + fun min(currency: String): Amount = Amount(currency, 0, 1) + fun max(currency: String): Amount = Amount(currency, MAX_VALUE, MAX_FRACTION) + +// fun fromJsonObject(json: JSONObject): Amount { +// val currency = checkCurrency(json.optString("currency")) +// val value = checkValue(json.optString("value").toLongOrNull()) +// val fraction = checkFraction(json.optString("fraction").toIntOrNull()) +// return Amount(currency, value, fraction) +// } + + private fun checkCurrency(currency: String): String { + if (!REGEX_CURRENCY.matches(currency)) + throw AmountParserException("Invalid currency: $currency") + return currency + } + + private fun checkValue(value: Long?): Long { + if (value == null || value > MAX_VALUE) + throw AmountParserException("Value $value greater than $MAX_VALUE") + return value + } + + private fun checkFraction(fraction: Int?): Int { + if (fraction == null || fraction > MAX_FRACTION) + throw AmountParserException("Fraction $fraction greater than $MAX_FRACTION") + return fraction + } + + override fun serialize(encoder: Encoder, value: Amount) { + encoder.encodeString(value.toJSONString()) + } + + override fun deserialize(decoder: Decoder): Amount { + return fromJSONString(decoder.decodeString()) + } + } + + val amountStr: String + get() = if (fraction == 0) "$value" else { + var f = fraction + var fractionStr = "" + while (f > 0) { + fractionStr += f / (FRACTIONAL_BASE / 10) + f = (f * 10) % FRACTIONAL_BASE + } + "$value.$fractionStr" + } + + operator fun plus(other: Amount): Amount { + check(currency == other.currency) { "Can only subtract from same currency" } + val resultValue = value + other.value + floor((fraction + other.fraction).toDouble() / FRACTIONAL_BASE).toLong() + if (resultValue > MAX_VALUE) + throw AmountOverflowException() + val resultFraction = (fraction + other.fraction) % FRACTIONAL_BASE + return Amount(currency, resultValue, resultFraction) + } + + operator fun times(factor: Int): Amount { + // TODO consider replacing with a faster implementation + if (factor == 0) return zero(currency) + var result = this + for (i in 1 until factor) result += this + return result + } + + operator fun minus(other: Amount): Amount { + check(currency == other.currency) { "Can only subtract from same currency" } + var resultValue = value + var resultFraction = fraction + if (resultFraction < other.fraction) { + if (resultValue < 1L) + throw AmountOverflowException() + resultValue-- + resultFraction += FRACTIONAL_BASE + } + check(resultFraction >= other.fraction) + resultFraction -= other.fraction + if (resultValue < other.value) + throw AmountOverflowException() + resultValue -= other.value + return Amount(currency, resultValue, resultFraction) + } + + fun isZero(): Boolean { + return value == 0L && fraction == 0 + } + + fun toJSONString(): String { + return "$currency:$amountStr" + } + + fun toByteArray() = ByteArray(8 + 4 + 12).apply { + value.toByteArray().copyInto(this, 0, 0, 8) + fraction.toByteArray().copyInto(this, 8, 0, 4) + currency.encodeToByteArray().copyInto(this, 12) + } + + override fun toString(): String { + return "$amountStr $currency" + } + + override fun compareTo(other: Amount): Int { + check(currency == other.currency) { "Can only compare amounts with the same currency" } + when { + value == other.value -> { + if (fraction < other.fraction) return -1 + if (fraction > other.fraction) return 1 + return 0 + } + value < other.value -> return -1 + else -> return 1 + } + } + +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Base32Crockford.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Base32Crockford.kt new file mode 100644 index 0000000..9043731 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Base32Crockford.kt @@ -0,0 +1,129 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + + +class EncodingException : Exception("Invalid encoding") + + +object Base32Crockford { + + private fun ByteArray.getIntAt(index: Int): Int { + val x = this[index].toInt() + return if (x >= 0) x else (x + 256) + } + + private var encTable = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + + fun encode(data: ByteArray): String { + val sb = StringBuilder() + val size = data.size + var bitBuf = 0 + var numBits = 0 + var pos = 0 + while (pos < size || numBits > 0) { + if (pos < size && numBits < 5) { + val d = data.getIntAt(pos++) + bitBuf = (bitBuf shl 8) or d + numBits += 8 + } + if (numBits < 5) { + // zero-padding + bitBuf = bitBuf shl (5 - numBits) + numBits = 5 + } + val v = bitBuf.ushr(numBits - 5) and 31 + sb.append(encTable[v]) + numBits -= 5 + } + return sb.toString() + } + + fun decode(encoded: String): ByteArray { + val size = encoded.length + var bitpos = 0 + var bitbuf = 0 + var readPosition = 0 + var writePosition = 0 + val out = ByteArray(calculateDecodedDataLength(size)) + + while (readPosition < size || bitpos > 0) { + //println("at position $readPosition with bitpos $bitpos") + if (readPosition < size) { + val v = getValue(encoded[readPosition++]) + bitbuf = (bitbuf shl 5) or v + bitpos += 5 + } + while (bitpos >= 8) { + val d = (bitbuf ushr (bitpos - 8)) and 0xFF + out[writePosition] = d.toByte() + writePosition++ + bitpos -= 8 + } + if (readPosition == size && bitpos > 0) { + bitbuf = (bitbuf shl (8 - bitpos)) and 0xFF + bitpos = if (bitbuf == 0) 0 else 8 + } + } + return out + } + + private fun getValue(chr: Char): Int { + var a = chr + when (a) { + 'O', 'o' -> a = '0' + 'i', 'I', 'l', 'L' -> a = '1' + 'u', 'U' -> a = 'V' + } + if (a in '0'..'9') + return a - '0' + if (a in 'a'..'z') + a = a.toUpperCase() + var dec = 0 + if (a in 'A'..'Z') { + if ('I' < a) dec++ + if ('L' < a) dec++ + if ('O' < a) dec++ + if ('U' < a) dec++ + return a - 'A' + 10 - dec + } + throw EncodingException() + } + + /** + * Compute the length of the resulting string when encoding data of the given size + * in bytes. + * + * @param dataSize size of the data to encode in bytes + * @return size of the string that would result from encoding + */ + private fun calculateEncodedStringLength(dataSize: Int): Int { + return (dataSize * 8 + 4) / 5 + } + + /** + * Compute the length of the resulting data in bytes when decoding a (valid) string of the + * given size. + * + * @param stringSize size of the string to decode + * @return size of the resulting data in bytes + */ + fun calculateDecodedDataLength(stringSize: Int): Int { + return stringSize * 5 / 8 + } + +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Db.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Db.kt new file mode 100644 index 0000000..3a5ecd6 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Db.kt @@ -0,0 +1,89 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.taler.wallet.kotlin.exchange.DenominationRecord +import net.taler.wallet.kotlin.exchange.ExchangeRecord + +internal interface Db { + suspend fun put(exchange: ExchangeRecord) + suspend fun listExchanges(): List<ExchangeRecord> + suspend fun getExchangeByBaseUrl(baseUrl: String): ExchangeRecord? + suspend fun deleteExchangeByBaseUrl(baseUrl: String) + suspend fun put(denomination: DenominationRecord) + suspend fun getDenominationsByBaseUrl(baseUrl: String): List<DenominationRecord> + suspend fun <T> transaction(function: suspend Db.() -> T): T +} + +internal expect class DbFactory() { + fun openDb(): Db +} + +internal class FakeDb : Db { + + private data class Data( + val exchanges: HashMap<String, ExchangeRecord> = HashMap(), + val denominations: HashMap<String, ArrayList<DenominationRecord>> = HashMap() + ) + + private var data = Data() + private val mutex = Mutex(false) + + override suspend fun put(exchange: ExchangeRecord) { + data.exchanges[exchange.baseUrl] = exchange + } + + override suspend fun listExchanges(): List<ExchangeRecord> { + return data.exchanges.values.toList() + } + + override suspend fun getExchangeByBaseUrl(baseUrl: String): ExchangeRecord? { + return data.exchanges[baseUrl] + } + + override suspend fun deleteExchangeByBaseUrl(baseUrl: String) { + data.exchanges.remove(baseUrl) + } + + override suspend fun put(denomination: DenominationRecord): Unit = mutex.withLock("transaction") { + val list = data.denominations[denomination.exchangeBaseUrl] ?: { + val newList = ArrayList<DenominationRecord>() + data.denominations[denomination.exchangeBaseUrl] = newList + newList + }() + val index = list.indexOfFirst { it.denomPub == denomination.denomPub } + if (index == -1) list.add(denomination) + else list[index] = denomination + } + + override suspend fun getDenominationsByBaseUrl(baseUrl: String): List<DenominationRecord> { + return data.denominations[baseUrl] ?: emptyList() + } + + override suspend fun <T> transaction(function: suspend Db.() -> T): T = mutex.withLock("transaction") { + val dataCopy = data.copy() + return@withLock try { + function() + } catch (e: Throwable) { + data = dataCopy + throw e + } + } + +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/PaytoUri.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/PaytoUri.kt new file mode 100644 index 0000000..f6b11d2 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/PaytoUri.kt @@ -0,0 +1,45 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +data class PaytoUri( + val targetType: String, + val targetPath: String, + val params: Map<String, String> +) { + companion object { + private const val SCHEMA = "payto://" + fun fromString(s: String): PaytoUri? { + if (!s.startsWith(SCHEMA)) return null + val rest = s.slice(SCHEMA.length until s.length).split('?') + val account = rest[0] + val query = if (rest.size > 1) rest[1] else null + val firstSlashPos = account.indexOf('/') + if (firstSlashPos == -1) return null + return PaytoUri( + targetType = account.slice(0 until firstSlashPos), + targetPath = account.slice((firstSlashPos + 1) until account.length), + params = HashMap<String, String>().apply { + query?.split('&')?.forEach { + val field = it.split('=') + if (field.size > 1) put(field[0], field[1]) + } + } + ) + } // end fromString() + } +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/TalerUri.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/TalerUri.kt new file mode 100644 index 0000000..c489d71 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/TalerUri.kt @@ -0,0 +1,60 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +internal object TalerUri { + + private const val SCHEME = "taler://" + private const val SCHEME_INSECURE = "taler+http://" + private const val AUTHORITY_PAY = "pay" + private const val AUTHORITY_WITHDRAW = "withdraw" + private const val AUTHORITY_REFUND = "refund" + private const val AUTHORITY_TIP = "tip" + + data class WithdrawUriResult( + val bankIntegrationApiBaseUrl: String, + val withdrawalOperationId: String + ) + + /** + * Parses a withdraw URI and returns a bank status URL or null if the URI was invalid. + */ + fun parseWithdrawUri(uri: String): WithdrawUriResult? { + val (resultScheme, prefix) = when { + uri.startsWith(SCHEME, ignoreCase = true) -> { + Pair("https://", "${SCHEME}${AUTHORITY_WITHDRAW}/") + } + uri.startsWith(SCHEME_INSECURE, ignoreCase = true) -> { + Pair("http://", "${SCHEME_INSECURE}${AUTHORITY_WITHDRAW}/") + } + else -> return null + } + if (!uri.startsWith(prefix)) return null + val parts = uri.let { + (if (it.endsWith("/")) it.dropLast(1) else it).substring(prefix.length).split('/') + } + if (parts.size < 2) return null + val host = parts[0].toLowerCase() + val pathSegments = parts.slice(1 until parts.size - 1).joinToString("/") + val withdrawId = parts.last() + if (withdrawId.isBlank()) return null + val url = "${resultScheme}${host}/${pathSegments}" + + return WithdrawUriResult(url, withdrawId) + } + +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Time.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Time.kt new file mode 100644 index 0000000..4143389 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Time.kt @@ -0,0 +1,85 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import com.soywiz.klock.DateTime +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import net.taler.wallet.kotlin.Duration.Companion.FOREVER +import net.taler.wallet.kotlin.crypto.CryptoImpl.Companion.toByteArray +import kotlin.math.max + +@Serializable +data class Timestamp( + @SerialName("t_ms") + val ms: Long +) : Comparable<Timestamp> { + + companion object { + const val NEVER: Long = -1 // TODO or UINT64_MAX? + fun now(): Timestamp = Timestamp(DateTime.now().unixMillisLong) + } + + /** + * Returns a copy of this [Timestamp] rounded to seconds. + */ + fun truncateSeconds(): Timestamp { + if (ms == NEVER) return Timestamp(ms) + return Timestamp((ms / 1000L) * 1000L) + } + + fun roundedToByteArray(): ByteArray = ByteArray(8).apply { + (truncateSeconds().ms * 1000L).toByteArray().copyInto(this) + } + + operator fun minus(other: Timestamp): Duration = when { + ms == NEVER -> Duration(FOREVER) + other.ms == NEVER -> throw Error("Invalid argument for timestamp comparision") + ms < other.ms -> Duration(0) + else -> Duration(ms - other.ms) + } + + operator fun minus(other: Duration): Timestamp = when { + ms == NEVER -> this + other.ms == FOREVER -> Timestamp(0) + else -> Timestamp(max(0, ms - other.ms)) + } + + override fun compareTo(other: Timestamp): Int { + return if (ms == NEVER) { + if (other.ms == NEVER) 0 + else 1 + } else { + if (other.ms == NEVER) -1 + else ms.compareTo(other.ms) + } + } + +} + +@Serializable +data class Duration( + /** + * Duration in milliseconds. + */ + @SerialName("d_ms") + val ms: Long +) { + companion object { + const val FOREVER: Long = -1 // TODO or UINT64_MAX? + } +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Types.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Types.kt new file mode 100644 index 0000000..04b17e7 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Types.kt @@ -0,0 +1,96 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + + +class CoinRecord( + /** + * Where did the coin come from? Used for recouping coins. + */ + val coinSource: CoinSourceType, + + /** + * Public key of the coin. + */ + val coinPub: String, + + /** + * Private key to authorize operations on the coin. + */ + val coinPriv: String, + + /** + * Key used by the exchange used to sign the coin. + */ + val denomPub: String, + + /** + * Hash of the public key that signs the coin. + */ + val denomPubHash: String, + + /** + * Unblinded signature by the exchange. + */ + val denomSig: String, + + /** + * Amount that's left on the coin. + */ + val currentAmount: Amount, + + /** + * Base URL that identifies the exchange from which we got the coin. + */ + val exchangeBaseUrl: String, + + /** + * The coin is currently suspended, and will not be used for payments. + */ + val suspended: Boolean, + + /** + * Blinding key used when withdrawing the coin. + * Potentially send again during payback. + */ + val blindingKey: String, + + /** + * Status of the coin. + */ + val status: CoinStatus +) + +enum class CoinSourceType(val value: String) { + WITHDRAW("withdraw"), + REFRESH("refresh"), + TIP("tip") +} + +enum class CoinStatus(val value: String) { + + /** + * Withdrawn and never shown to anybody. + */ + FRESH("fresh"), + + /** + * A coin that has been spent and refreshed. + */ + DORMANT("dormant") + +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Utils.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Utils.kt new file mode 100644 index 0000000..2549195 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Utils.kt @@ -0,0 +1,41 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import io.ktor.client.HttpClient +import io.ktor.client.features.json.JsonFeature +import io.ktor.client.features.json.serializer.KotlinxSerializer +import io.ktor.client.features.logging.LogLevel +import io.ktor.client.features.logging.Logging +import kotlinx.serialization.UnstableDefault +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonConfiguration + +fun getDefaultHttpClient(): HttpClient = HttpClient { + install(JsonFeature) { + serializer = KotlinxSerializer(Json(getJsonConfiguration())) + } + install(Logging) { +// level = LogLevel.HEADERS + level = LogLevel.NONE + } +} + +@OptIn(UnstableDefault::class) +internal fun getJsonConfiguration() = JsonConfiguration( + ignoreUnknownKeys = true +) diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Version.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Version.kt new file mode 100644 index 0000000..45e7840 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/Version.kt @@ -0,0 +1,78 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import kotlin.math.sign + +/** + * Semantic versioning, but libtool-style. + * See https://www.gnu.org/software/libtool/manual/html_node/Libtool-versioning.html + */ + +/** + * Result of comparing two libtool versions. + */ +data class VersionMatchResult( + /** + * Is the first version compatible with the second? + */ + val compatible: Boolean, + /** + * Is the first version older (-1), newer (+1) or identical (0)? + */ + val currentCmp: Int +) + +data class Version( + val current: Int, + val revision: Int, + val age: Int +) + +/** + * Compare two libtool-style version strings. + */ +fun compareVersions(me: String,other: String): VersionMatchResult? { + val meVer = parseVersion (me) + val otherVer = parseVersion (other) + if (meVer == null || otherVer == null) return null + + val compatible = meVer.current - meVer.age <= otherVer.current && + meVer.current >= otherVer.current - otherVer.age + + val currentCmp = sign((meVer.current - otherVer.current).toDouble()).toInt() + + return VersionMatchResult(compatible, currentCmp) +} + +fun parseVersion(v: String): Version? { + val elements = v.split(":") + if (elements.size != 3) return null + val (currentStr, revisionStr, ageStr) = elements + val current = currentStr.toIntOrNull() + val revision = revisionStr.toIntOrNull() + val age = ageStr.toIntOrNull() + if (current == null || revision == null || age == null) return null + return Version(current, revision, age) +} + +class SupportedVersions( + val walletVersion: Version, + val exchangeVersion: Version, + val bankVersion: Version, + val merchantVersion: Version +) diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/WalletApi.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/WalletApi.kt new file mode 100644 index 0000000..11fd181 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/WalletApi.kt @@ -0,0 +1,101 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import io.ktor.client.HttpClient +import net.taler.wallet.kotlin.crypto.Crypto +import net.taler.wallet.kotlin.crypto.CryptoFactory +import net.taler.wallet.kotlin.crypto.Signature +import net.taler.wallet.kotlin.exchange.Exchange +import net.taler.wallet.kotlin.exchange.ExchangeListItem +import net.taler.wallet.kotlin.exchange.GetExchangeTosResult +import net.taler.wallet.kotlin.operations.Withdraw +import net.taler.wallet.kotlin.operations.WithdrawalDetails +import net.taler.wallet.kotlin.operations.WithdrawalDetailsForUri + +public class WalletApi { + + private val httpClient: HttpClient = getDefaultHttpClient() + private val db: Db = DbFactory().openDb() + private val crypto: Crypto = CryptoFactory.getCrypto() + private val signature: Signature = Signature(crypto) + private val exchangeManager: Exchange = Exchange(crypto, signature, httpClient, db = db) + private val withdrawManager = Withdraw(httpClient, db, crypto, signature, exchangeManager) + + public fun getVersions(): SupportedVersions { + return SupportedVersions( + walletVersion = Version(8, 0, 0), + exchangeVersion = Version(8, 0, 0), + bankVersion = Version(0, 0, 0), + merchantVersion = Version(1, 0, 0) + ) + } + + public suspend fun getWithdrawalDetailsForUri(talerWithdrawUri: String): WithdrawalDetailsForUri { + val bankInfo = withdrawManager.getBankInfo(talerWithdrawUri) + return WithdrawalDetailsForUri( + amount = bankInfo.amount, + defaultExchangeBaseUrl = bankInfo.suggestedExchange, + possibleExchanges = emptyList() + ) + } + + public suspend fun getWithdrawalDetailsForAmount( + exchangeBaseUrl: String, + amount: Amount + ): WithdrawalDetails { + val details = withdrawManager.getWithdrawalDetails(exchangeBaseUrl, amount) + return WithdrawalDetails( + tosAccepted = details.exchange.termsOfServiceAccepted, + amountRaw = amount, + amountEffective = amount - details.overhead - details.withdrawFee + ) + } + + public suspend fun listExchanges(): List<ExchangeListItem> { + return db.listExchanges().mapNotNull { exchange -> + ExchangeListItem.fromExchangeRecord(exchange) + } + } + + public suspend fun addExchange(exchangeBaseUrl: String): ExchangeListItem { + val exchange = exchangeManager.updateFromUrl(exchangeBaseUrl) + db.put(exchange) + return ExchangeListItem.fromExchangeRecord(exchange) ?: TODO("error handling") + } + + public suspend fun getExchangeTos(exchangeBaseUrl: String): GetExchangeTosResult { + val record = db.getExchangeByBaseUrl(exchangeBaseUrl) ?: TODO("error handling") + return GetExchangeTosResult( + tos = record.termsOfServiceText ?: TODO("error handling"), + currentEtag = record.termsOfServiceLastEtag ?: TODO("error handling"), + acceptedEtag = record.termsOfServiceAcceptedEtag + ) + } + + public suspend fun setExchangeTosAccepted(exchangeBaseUrl: String, acceptedEtag: String) { + db.transaction { + val record = getExchangeByBaseUrl(exchangeBaseUrl) ?: TODO("error handling") + val updatedRecord = record.copy( + termsOfServiceAcceptedEtag = acceptedEtag, + termsOfServiceAcceptedTimestamp = Timestamp.now() + ) + put(updatedRecord) + } + } + +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Crypto.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Crypto.kt new file mode 100644 index 0000000..226aa64 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Crypto.kt @@ -0,0 +1,78 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +internal interface Crypto { + fun sha256(input: ByteArray): ByteArray + fun sha512(input: ByteArray): ByteArray + fun getHashSha512State(): HashSha512State + fun getRandomBytes(num: Int): ByteArray + fun eddsaGetPublic(eddsaPrivateKey: ByteArray): ByteArray + fun ecdheGetPublic(ecdhePrivateKey: ByteArray): ByteArray + fun createEddsaKeyPair(): EddsaKeyPair + fun createEcdheKeyPair(): EcdheKeyPair + fun eddsaSign(msg: ByteArray, eddsaPrivateKey: ByteArray): ByteArray + fun eddsaVerify(msg: ByteArray, sig: ByteArray, eddsaPub: ByteArray): Boolean + fun keyExchangeEddsaEcdhe(eddsaPrivateKey: ByteArray, ecdhePublicKey: ByteArray): ByteArray + fun keyExchangeEcdheEddsa(ecdhePrivateKey: ByteArray, eddsaPublicKey: ByteArray): ByteArray + fun kdf(outputLength: Int, ikm: ByteArray, salt: ByteArray, info: ByteArray): ByteArray + fun rsaBlind(hm: ByteArray, bks: ByteArray, rsaPubEnc: ByteArray): ByteArray + fun rsaUnblind(sig: ByteArray, rsaPubEnc: ByteArray, bks: ByteArray): ByteArray + fun rsaVerify(hm: ByteArray, rsaSig: ByteArray, rsaPubEnc: ByteArray): Boolean + fun setupRefreshPlanchet(secretSeed: ByteArray, coinNumber: Int): FreshCoin +} + +interface HashSha512State { + fun update(data: ByteArray): HashSha512State + fun final(): ByteArray +} +class EddsaKeyPair(val privateKey: ByteArray, val publicKey: ByteArray) +class EcdheKeyPair(val privateKey: ByteArray, val publicKey: ByteArray) +data class FreshCoin(val coinPublicKey: ByteArray, val coinPrivateKey: ByteArray, val bks: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + other as FreshCoin + if (!coinPublicKey.contentEquals(other.coinPublicKey)) return false + if (!coinPrivateKey.contentEquals(other.coinPrivateKey)) return false + if (!bks.contentEquals(other.bks)) return false + return true + } + + override fun hashCode(): Int { + var result = coinPublicKey.contentHashCode() + result = 31 * result + coinPrivateKey.contentHashCode() + result = 31 * result + bks.contentHashCode() + return result + } +} + +internal expect object CryptoFactory { + internal fun getCrypto(): Crypto +} + +private val hexArray = arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f') + +fun ByteArray.toHexString(): String { + val hexChars = CharArray(this.size * 2) + for (j in this.indices) { + val v = (this[j].toInt() and 0xFF) + hexChars[j * 2] = hexArray[v ushr 4] + hexChars[j * 2 + 1] = hexArray[v and 0x0F] + } + return String(hexChars) +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoImpl.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoImpl.kt new file mode 100644 index 0000000..0780e45 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoImpl.kt @@ -0,0 +1,55 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.crypto.CryptoImpl.Companion.toByteArray + +abstract class CryptoImpl : Crypto { + + companion object { + fun Int.toByteArray(): ByteArray { + val bytes = ByteArray(4) + bytes[3] = (this and 0xFFFF).toByte() + bytes[2] = ((this ushr 8) and 0xFFFF).toByte() + bytes[1] = ((this ushr 16) and 0xFFFF).toByte() + bytes[0] = ((this ushr 24) and 0xFFFF).toByte() + return bytes + } + + fun Long.toByteArray() = ByteArray(8).apply { + var l = this@toByteArray + for (i in 7 downTo 0) { + this[i] = (l and 0xFF).toByte() + l = l shr 8 + } + } + } + + override fun kdf(outputLength: Int, ikm: ByteArray, salt: ByteArray, info: ByteArray): ByteArray { + return Kdf.kdf(outputLength, ikm, salt, info, { sha256(it) }, { sha512(it) }) + } + + override fun setupRefreshPlanchet(secretSeed: ByteArray, coinNumber: Int): FreshCoin { + val info = "taler-coin-derivation".encodeToByteArray() + val salt = coinNumber.toByteArray() + val out = kdf(64, secretSeed, salt, info) + val coinPrivateKey = out.copyOfRange(0, 32) + val bks = out.copyOfRange(32, 64) + return FreshCoin(eddsaGetPublic(coinPrivateKey), coinPrivateKey, bks) + } + +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Deposit.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Deposit.kt new file mode 100644 index 0000000..3156d3f --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Deposit.kt @@ -0,0 +1,110 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.Base32Crockford +import net.taler.wallet.kotlin.Timestamp +import net.taler.wallet.kotlin.crypto.Signature.Companion.WALLET_COIN_DEPOSIT + +/** + * Deposit operations are requested by a merchant during a transaction. + * For the deposit operation, the merchant has to obtain the deposit permission for a coin + * from their customer who owns the coin. + * + * When depositing a coin, the merchant is credited an amount specified in the deposit permission, + * possibly a fraction of the total coin’s value, + * minus the deposit fee as specified by the coin’s denomination. + */ +internal class Deposit(private val crypto: Crypto) { + + /** + * Private data required to make a deposit permission. + */ + data class DepositInfo( + val exchangeBaseUrl: String, + val contractTermsHash: String, + val coinPublicKey: String, + val coinPrivateKey: String, + val spendAmount: Amount, + val timestamp: Timestamp, + val refundDeadline: Timestamp, + val merchantPublicKey: String, + val feeDeposit: Amount, + val wireInfoHash: String, + val denomPublicKey: String, + val denomSignature: String + ) + + /** + * Deposit permission for a single coin. + */ + // TODO rename _ + data class CoinDepositPermission( + /** + * Signature by the coin. + */ + val coinSignature: String, + /** + * Public key of the coin being spend. + */ + val coinPublicKey: String, + /** + * Signature made by the denomination public key. + */ + val denomSignature: String, + /** + * The denomination public key associated with this coin. + */ + val denomPublicKey: String, + /** + * The amount that is subtracted from this coin with this payment. + */ + val contribution: String, + /** + * URL of the exchange this coin was withdrawn from. + */ + val exchangeBaseUrl: String + ) + + /** + * Generate updated coins (to store in the database) and deposit permissions for each given coin. + */ + fun signDepositPermission(depositInfo: DepositInfo): CoinDepositPermission { + val d = Signature.PurposeBuilder(WALLET_COIN_DEPOSIT) + .put(Base32Crockford.decode(depositInfo.contractTermsHash)) + .put(Base32Crockford.decode(depositInfo.wireInfoHash)) + .put(depositInfo.timestamp.roundedToByteArray()) + .put(depositInfo.refundDeadline.roundedToByteArray()) + .put(depositInfo.spendAmount.toByteArray()) + .put(depositInfo.feeDeposit.toByteArray()) + .put(Base32Crockford.decode(depositInfo.merchantPublicKey)) + .put(Base32Crockford.decode(depositInfo.coinPublicKey)) + .build() + val coinPriv = Base32Crockford.decode(depositInfo.coinPrivateKey); + val coinSig = crypto.eddsaSign(d, coinPriv) + return CoinDepositPermission( + coinPublicKey = depositInfo.coinPublicKey, + coinSignature = Base32Crockford.encode(coinSig), + contribution = depositInfo.spendAmount.toJSONString(), + denomPublicKey = depositInfo.denomPublicKey, + exchangeBaseUrl = depositInfo.exchangeBaseUrl, + denomSignature = depositInfo.denomSignature + ) + } + +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Kdf.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Kdf.kt new file mode 100644 index 0000000..44f55cc --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Kdf.kt @@ -0,0 +1,90 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import kotlin.experimental.xor +import kotlin.math.ceil + +internal object Kdf { + + const val HMAC_SHA256_BLOCK_SIZE = 64 + const val HMAC_SHA512_BLOCK_SIZE = 128 + + fun kdf( + outputLength: Int, + ikm: ByteArray, + salt: ByteArray, + info: ByteArray, + sha256: (ByteArray) -> ByteArray, + sha512: (ByteArray) -> ByteArray + ): ByteArray { + //extract + val prk = hmacSha512(salt, ikm, sha512) + + // expand + val n = ceil(outputLength.toDouble() / 32).toInt() + val output = ByteArray(n * 32) + for (i in 0 until n) { + val buf: ByteArray + if (i == 0) { + buf = ByteArray(info.size + 1) + info.copyInto(buf) + } else { + buf = ByteArray(info.size + 1 + 32) + for (j in 0 until 32) { + buf[j] = output[(i - 1) * 32 + j] + } + info.copyInto(buf, destinationOffset = 32) + } + buf[buf.size - 1] = (i + 1).toByte() + val chunk = hmacSha256(prk, buf, sha256) + chunk.copyInto(output, destinationOffset = i * 32) + } + return output.copyOfRange(0, outputLength) + } + + fun hmacSha256(key: ByteArray, message: ByteArray, sha256: (ByteArray) -> ByteArray): ByteArray { + return hmac(HMAC_SHA256_BLOCK_SIZE, key, message) { sha256(it) } + } + + fun hmacSha512(key: ByteArray, message: ByteArray, sha512: (ByteArray) -> ByteArray): ByteArray { + return hmac(HMAC_SHA512_BLOCK_SIZE, key, message) { sha512(it) } + } + + private fun hmac(blockSize: Int, key: ByteArray, message: ByteArray, hash: (ByteArray) -> ByteArray): ByteArray { + var newKey = key + if (newKey.size > blockSize) newKey = hash(newKey) + if (newKey.size < blockSize) newKey = ByteArray(blockSize).apply { + newKey.copyInto(this) + } + val okp = ByteArray(blockSize) + val ikp = ByteArray(blockSize) + for (i in 0 until blockSize) { + ikp[i] = newKey[i] xor 0x36 + okp[i] = newKey[i] xor 0x5c + } + val b1 = ByteArray(blockSize + message.size) + ikp.copyInto(b1) + message.copyInto(b1, destinationOffset = blockSize) + val h0 = hash(b1) + val b2 = ByteArray(blockSize + h0.size) + okp.copyInto(b2) + h0.copyInto(b2, destinationOffset = blockSize) + return hash(b2) + } + +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Planchet.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Planchet.kt new file mode 100644 index 0000000..b29007e --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Planchet.kt @@ -0,0 +1,87 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.Base32Crockford + +internal class Planchet(private val crypto: Crypto) { + + data class CreationRequest( + val value: Amount, + val feeWithdraw: Amount, + val denomPub: String, + val reservePub: String, + val reservePriv: String + ) + + data class CreationResult( + val coinPub: String, + val coinPriv: String, + val reservePub: String, + val denomPubHash: String, + val denomPub: String, + val blindingKey: String, + val withdrawSig: String, + val coinEv: String, + val coinValue: Amount, + val coinEvHash: String + ) + + internal fun create(req: CreationRequest, coinKeyPair: EddsaKeyPair, blindingFactor: ByteArray): CreationResult { + val reservePub = Base32Crockford.decode(req.reservePub) + val reservePriv = Base32Crockford.decode(req.reservePriv) + val denomPub = Base32Crockford.decode(req.denomPub) + val coinPubHash = crypto.sha512(coinKeyPair.publicKey) + val ev = crypto.rsaBlind(coinPubHash, blindingFactor, denomPub) + val amountWithFee = req.value + req.feeWithdraw + val denomPubHash = crypto.sha512(denomPub) + val evHash = crypto.sha512(ev) + + val withdrawRequest = Signature.PurposeBuilder(Signature.RESERVE_WITHDRAW) + .put(reservePub) + .put(amountWithFee.toByteArray()) + .put(req.feeWithdraw.toByteArray()) + .put(denomPubHash) + .put(evHash) + .build() + + val sig = crypto.eddsaSign(withdrawRequest, reservePriv) + return CreationResult( + blindingKey = Base32Crockford.encode(blindingFactor), + coinEv = Base32Crockford.encode(ev), + coinPriv = Base32Crockford.encode(coinKeyPair.privateKey), + coinPub = Base32Crockford.encode(coinKeyPair.publicKey), + coinValue = req.value, + denomPub = req.denomPub, + denomPubHash = Base32Crockford.encode(denomPubHash), + reservePub = req.reservePub, + withdrawSig = Base32Crockford.encode(sig), + coinEvHash = Base32Crockford.encode(evHash) + ) + } + + /** + * Create a pre-coin ([Planchet]) of the given [CreationRequest]. + */ + fun create(req: CreationRequest): CreationResult { + val coinKeyPair = crypto.createEddsaKeyPair() + val blindingFactor = crypto.getRandomBytes(32) + return create(req, coinKeyPair, blindingFactor) + } + +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Recoup.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Recoup.kt new file mode 100644 index 0000000..0f2b6df --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Recoup.kt @@ -0,0 +1,83 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.Base32Crockford +import net.taler.wallet.kotlin.CoinRecord +import net.taler.wallet.kotlin.CoinSourceType.REFRESH +import net.taler.wallet.kotlin.crypto.Signature.Companion.WALLET_COIN_RECOUP + +internal class Recoup(private val crypto: Crypto) { + + /** + * Request that we send to the exchange to get a payback. + */ + data class Request( + /** + * Hashed denomination public key of the coin we want to get + * paid back. + */ + val denomPubHash: String, + + /** + * Signature over the coin public key by the denomination. + */ + val denomSig: String, + + /** + * Coin public key of the coin we want to refund. + */ + val coinPub: String, + + /** + * Blinding key that was used during withdraw, + * used to prove that we were actually withdrawing the coin. + */ + val coinBlindKeySecret: String, + + /** + * Signature made by the coin, authorizing the payback. + */ + val coinSig: String, + + /** + * Was the coin refreshed (and thus the recoup should go to the old coin)? + */ + val refreshed: Boolean + ) + + /** + * Create and sign a message to recoup a coin. + */ + fun createRequest(coin: CoinRecord): Request { + val p = Signature.PurposeBuilder(WALLET_COIN_RECOUP) + .put(Base32Crockford.decode(coin.coinPub)) + .put(Base32Crockford.decode(coin.denomPubHash)) + .put(Base32Crockford.decode(coin.blindingKey)) + .build() + val coinSig = crypto.eddsaSign(p, Base32Crockford.decode(coin.coinPriv)) + return Request( + coinBlindKeySecret = coin.blindingKey, + coinPub = coin.coinPub, + coinSig = Base32Crockford.encode(coinSig), + denomPubHash = coin.denomPubHash, + denomSig = coin.denomSig, + refreshed = coin.coinSource === REFRESH + ) + } + +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Refresh.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Refresh.kt new file mode 100644 index 0000000..cd24b07 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Refresh.kt @@ -0,0 +1,265 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.Base32Crockford +import net.taler.wallet.kotlin.CoinRecord +import net.taler.wallet.kotlin.Timestamp +import net.taler.wallet.kotlin.crypto.Signature.Companion.WALLET_COIN_LINK +import net.taler.wallet.kotlin.crypto.Signature.Companion.WALLET_COIN_MELT +import net.taler.wallet.kotlin.crypto.Signature.PurposeBuilder +import net.taler.wallet.kotlin.exchange.DenominationSelectionInfo +import net.taler.wallet.kotlin.exchange.SelectedDenomination + +internal class Refresh(private val crypto: Crypto) { + + data class RefreshSessionRecord( + + /** + * Public key that's being melted in this session. + */ + val meltCoinPub: String, + + /** + * How much of the coin's value is melted away with this refresh session? + */ + val amountRefreshInput: Amount, + + /** + * Sum of the value of denominations we want to withdraw in this session, without fees. + */ + val amountRefreshOutput: Amount, + + /** + * Signature to confirm the melting. + */ + val confirmSig: String, + + /** + * Hashed denominations of the newly requested coins. + */ + val newDenominationHashes: List<String>, + + /** + * Denominations of the newly requested coins. + */ + val newDenominations: List<String>, + + /** + * Planchets for each cut-and-choose instance. + */ + val planchetsForGammas: List<List<RefreshPlanchetRecord>>, + + /** + * The transfer public keys, kappa of them. + */ + val transferPublicKeys: List<String>, + + /** + * Private keys for the transfer public keys. + */ + val transferPrivateKeys: List<String>, + + /** + * The no-reveal-index after we've done the melting. + */ + val noRevealIndex: Int?, + + /** + * Hash of the session. + */ + val hash: String, + + /** + * Timestamp when the refresh session finished. + */ + val finishedTimestamp: Timestamp?, + + /** + * When was this refresh session created? + */ + val timestampCreated: Timestamp, + + /** + * Base URL for the exchange we're doing the refresh with. + */ + val exchangeBaseUrl: String + ) + + data class RefreshPlanchetRecord( + /** + * Public key for the coin. + */ + val publicKey: String, + /** + * Private key for the coin. + */ + val privateKey: String, + /** + * Blinded public key. + */ + val coinEv: String, + /** + * Blinding key used. + */ + val blindingKey: String + ) + + /** + * Create a new refresh session. + */ + fun createRefreshSession( + exchangeBaseUrl: String, + meltCoin: CoinRecord, + meltFee: Amount, + newCoinDenominations: DenominationSelectionInfo, + kappa: Int = newCoinDenominations.selectedDenominations.size + ) : RefreshSessionRecord { + return createRefreshSession(exchangeBaseUrl, meltCoin, meltFee, newCoinDenominations, kappa) { + crypto.createEcdheKeyPair() + } + } + + /** + * Create a new refresh session and allow to provide transfer key pairs for testing. + */ + fun createRefreshSession( + exchangeBaseUrl: String, + meltCoin: CoinRecord, + meltFee: Amount, + newCoinDenominations: DenominationSelectionInfo, + kappa: Int = newCoinDenominations.selectedDenominations.size, + kappaKeys: (Int) -> EcdheKeyPair + ): RefreshSessionRecord { + val sessionHashState = crypto.getHashSha512State() + + // create fresh transfer keys, one pair for each selected denomination (kappa-many) + val transferPublicKeys = ArrayList<String>() + val transferPrivateKeys = ArrayList<String>() + for (i in 0 until kappa) { + val transferKeyPair = kappaKeys(i) + sessionHashState.update(transferKeyPair.publicKey) + transferPrivateKeys.add(Base32Crockford.encode(transferKeyPair.privateKey)) + transferPublicKeys.add(Base32Crockford.encode(transferKeyPair.publicKey)) + } + + // add denomination public keys to session hash + val newDenominations = ArrayList<String>() + val newDenominationHashes = ArrayList<String>() + for (selectedDenomination in newCoinDenominations.selectedDenominations) { + for (i in 0 until selectedDenomination.count) { + newDenominations.add(selectedDenomination.denominationRecord.denomPub) + newDenominationHashes.add(selectedDenomination.denominationRecord.denomPubHash) + sessionHashState.update(Base32Crockford.decode(selectedDenomination.denominationRecord.denomPub)) + } + } + + // add public key of melted coin to session hash + sessionHashState.update(Base32Crockford.decode(meltCoin.coinPub)) + + // calculate total value with all fees and add to session hash + val (totalOutput, withdrawFee) = calculateOutputAndWithdrawFee(newCoinDenominations.selectedDenominations) + val valueWithFee = totalOutput + withdrawFee + meltFee + sessionHashState.update(valueWithFee.toByteArray()) + + val planchetsForGammas = ArrayList<ArrayList<RefreshPlanchetRecord>>() + for (i in 0 until kappa) { + val planchets = ArrayList<RefreshPlanchetRecord>() + for (selectedDenomination in newCoinDenominations.selectedDenominations) { + for (k in 0 until selectedDenomination.count) { + val coinNumber = planchets.size + val transferPrivateKey = Base32Crockford.decode(transferPrivateKeys[i]) + val oldCoinPub = Base32Crockford.decode(meltCoin.coinPub) + val transferSecret = crypto.keyExchangeEcdheEddsa(transferPrivateKey, oldCoinPub) + val fresh = crypto.setupRefreshPlanchet(transferSecret, coinNumber) + val publicKeyHash = crypto.sha512(fresh.coinPublicKey) + val denominationPub = Base32Crockford.decode(selectedDenomination.denominationRecord.denomPub) + val ev = crypto.rsaBlind(publicKeyHash, fresh.bks, denominationPub) + val planchet = RefreshPlanchetRecord( + blindingKey = Base32Crockford.encode(fresh.bks), + coinEv = Base32Crockford.encode(ev), + privateKey = Base32Crockford.encode(fresh.coinPrivateKey), + publicKey = Base32Crockford.encode(fresh.coinPublicKey) + ) + planchets.add(planchet) + sessionHashState.update(ev) + } + } + planchetsForGammas.add(planchets) + } + + val sessionHash = sessionHashState.final() + + // make a signature over sessionHash, value (again?), meltFee and meltCoin public key with meltCoin private key + val confirmData = PurposeBuilder(WALLET_COIN_MELT) + .put(sessionHash) + .put(valueWithFee.toByteArray()) + .put(meltFee.toByteArray()) + .put(Base32Crockford.decode(meltCoin.coinPub)) + .build() + val confirmSignature = crypto.eddsaSign(confirmData, Base32Crockford.decode(meltCoin.coinPriv)) + + return RefreshSessionRecord( + confirmSig = Base32Crockford.encode(confirmSignature), + exchangeBaseUrl = exchangeBaseUrl, + hash = Base32Crockford.encode(sessionHash), + meltCoinPub = meltCoin.coinPub, + newDenominationHashes = newDenominationHashes, + newDenominations = newDenominations, + noRevealIndex = null, + planchetsForGammas = planchetsForGammas, + transferPrivateKeys = transferPrivateKeys, + transferPublicKeys = transferPublicKeys, + amountRefreshOutput = totalOutput, + amountRefreshInput = valueWithFee, + timestampCreated = Timestamp.now(), + finishedTimestamp = null + ) + } + + private fun calculateOutputAndWithdrawFee(selectedDenomination: List<SelectedDenomination>): Pair<Amount, Amount> { + val currency = selectedDenomination[0].denominationRecord.value.currency + var total = Amount.zero(currency) + var fee = Amount.zero(currency) + for (ncd in selectedDenomination) { + total += ncd.denominationRecord.value * ncd.count + fee += ncd.denominationRecord.feeWithdraw * ncd.count + } + return Pair(total, fee) + } + + fun signCoinLink( + oldCoinPrivateKey: String, + newDenominationHash: String, + oldCoinPublicKey: String, + transferPublicKey: String, + coinEv: String + ): String { + val coinEvHash = crypto.sha512(Base32Crockford.decode(coinEv)) + val coinLink = PurposeBuilder(WALLET_COIN_LINK) + .put(Base32Crockford.decode(newDenominationHash)) + .put(Base32Crockford.decode(oldCoinPublicKey)) + .put(Base32Crockford.decode(transferPublicKey)) + .put(coinEvHash) + .build() + val coinPrivateKey = Base32Crockford.decode(oldCoinPrivateKey) + val sig = crypto.eddsaSign(coinLink, coinPrivateKey) + return Base32Crockford.encode(sig) + } + +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Signature.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Signature.kt new file mode 100644 index 0000000..9b06756 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/crypto/Signature.kt @@ -0,0 +1,155 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.Base32Crockford +import net.taler.wallet.kotlin.crypto.CryptoImpl.Companion.toByteArray +import net.taler.wallet.kotlin.exchange.DenominationRecord +import net.taler.wallet.kotlin.exchange.WireFee + +internal class Signature(private val crypto: Crypto) { + + @Suppress("unused") + companion object { + const val RESERVE_WITHDRAW = 1200 + const val WALLET_COIN_DEPOSIT = 1201 + const val MASTER_DENOMINATION_KEY_VALIDITY = 1025 + const val MASTER_WIRE_FEES = 1028 + const val MASTER_WIRE_DETAILS = 1030 + const val WALLET_COIN_MELT = 1202 + const val TEST = 4242 + const val MERCHANT_PAYMENT_OK = 1104 + const val WALLET_COIN_RECOUP = 1203 + const val WALLET_COIN_LINK = 1204 + const val EXCHANGE_CONFIRM_RECOUP = 1039 + const val EXCHANGE_CONFIRM_RECOUP_REFRESH = 1041 + } + + internal class PurposeBuilder(private val purposeNum: Int) { + private val chunks = ArrayList<ByteArray>() + + fun put(bytes: ByteArray): PurposeBuilder { + chunks.add(bytes) + return this + } + + fun build(): ByteArray { + var payloadLen = 0 + for (c in chunks) payloadLen += c.size + val size = 4 + 4 + payloadLen + val bytes = ByteArray(size) + size.toByteArray().copyInto(bytes, 0) + purposeNum.toByteArray().copyInto(bytes, 4) + var offset = 8 + for (c in chunks) { + c.copyInto(bytes, offset) + offset += c.size + } + return bytes + } + } + + private fun verifyPayment(sig: ByteArray, contractHash: ByteArray, merchantPub: ByteArray): Boolean { + val p = PurposeBuilder(MERCHANT_PAYMENT_OK) + .put(contractHash) + .build() + return crypto.eddsaVerify(p, sig, merchantPub) + } + + /** + * Verifies an EdDSA payment signature made with [MERCHANT_PAYMENT_OK]. + * + * @param merchantPub an EdDSA public key, usually belonging to a merchant. + * + * @return true if the signature is valid, false otherwise + */ + fun verifyPayment(sig: String, contractHash: String, merchantPub: String): Boolean { + val sigBytes = Base32Crockford.decode(sig) + val hashBytes = Base32Crockford.decode(contractHash) + val pubBytes = Base32Crockford.decode(merchantPub) + return verifyPayment(sigBytes, hashBytes, pubBytes) + } + + /** + * Verifies an EdDSA wire fee signature made with [MASTER_WIRE_FEES]. + * + * @param masterPub an EdDSA public key + * + * @return true if the signature is valid, false otherwise + */ + fun verifyWireFee(type: String, wireFee: WireFee, masterPub: String): Boolean { + val p = PurposeBuilder(MASTER_WIRE_FEES) + .put(crypto.sha512("$type\u0000".encodeToByteArray())) + .put(wireFee.startStamp.roundedToByteArray()) + .put(wireFee.endStamp.roundedToByteArray()) + .put(wireFee.wireFee.toByteArray()) + .put(wireFee.closingFee.toByteArray()) + .build() + val sig = Base32Crockford.decode(wireFee.signature) + val pub = Base32Crockford.decode(masterPub) + return crypto.eddsaVerify(p, sig, pub) + } + + /** + * Verifies an EdDSA denomination record signature made with [MASTER_DENOMINATION_KEY_VALIDITY]. + * + * @param masterPub an EdDSA public key + * + * @return true if the signature is valid, false otherwise + */ + fun verifyDenominationRecord(d: DenominationRecord, masterPub: String): Boolean { + val pub = Base32Crockford.decode(masterPub) + val p = PurposeBuilder(MASTER_DENOMINATION_KEY_VALIDITY) + .put(pub) + .put(d.stampStart.roundedToByteArray()) + .put(d.stampExpireWithdraw.roundedToByteArray()) + .put(d.stampExpireDeposit.roundedToByteArray()) + .put(d.stampExpireLegal.roundedToByteArray()) + .put(d.value.toByteArray()) + .put(d.feeWithdraw.toByteArray()) + .put(d.feeDeposit.toByteArray()) + .put(d.feeRefresh.toByteArray()) + .put(d.feeRefund.toByteArray()) + .put(Base32Crockford.decode(d.denomPubHash)) + .build() + val sig = Base32Crockford.decode(d.masterSig) + return crypto.eddsaVerify(p, sig, pub) + } + + /** + * Verifies an EdDSA wire account signature made with [MASTER_WIRE_DETAILS]. + * + * @param masterPub an EdDSA public key + * + * @return true if the signature is valid, false otherwise + */ + fun verifyWireAccount(paytoUri: String, signature: String, masterPub: String): Boolean { + val h = crypto.kdf( + 64, + "exchange-wire-signature".encodeToByteArray(), + "$paytoUri\u0000".encodeToByteArray(), + ByteArray(0) + ) + val p = PurposeBuilder(MASTER_WIRE_DETAILS) + .put(h) + .build() + val sig = Base32Crockford.decode(signature) + val pub = Base32Crockford.decode(masterPub) + return crypto.eddsaVerify(p, sig, pub) + } + +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Auditor.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Auditor.kt new file mode 100644 index 0000000..4df0bdf --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Auditor.kt @@ -0,0 +1,56 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.exchange + +import kotlinx.serialization.Serializable + +/** + * Auditor information as given by the exchange in /keys. + */ +@Serializable +data class Auditor( + /** + * Auditor's public key. + */ + val auditor_pub: String, + + /** + * Base URL of the auditor. + */ + val auditor_url: String, + + /** + * List of signatures for denominations by the auditor. + */ + val denomination_keys: List<AuditorDenomSig> +) + +/** + * Signature by the auditor that a particular denomination key is audited. + */ +@Serializable +data class AuditorDenomSig( + /** + * Denomination public key's hash. + */ + val denom_pub_h: String, + + /** + * The signature. + */ + val auditor_sig: String +) diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Denomination.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Denomination.kt new file mode 100644 index 0000000..88a81fd --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Denomination.kt @@ -0,0 +1,234 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.exchange + +import kotlinx.serialization.Serializable +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.Base32Crockford +import net.taler.wallet.kotlin.Duration +import net.taler.wallet.kotlin.Timestamp +import net.taler.wallet.kotlin.exchange.DenominationStatus.Unverified +import net.taler.wallet.kotlin.exchange.DenominationStatus.VerifiedGood + +/** + * Denomination as found in the /keys response from the exchange. + */ +@Serializable +internal data class Denomination( + /** + * Value of one coin of the denomination. + */ + val value: Amount, + + /** + * Public signing key of the denomination. + */ + val denom_pub: String, + + /** + * Fee for withdrawing. + */ + val fee_withdraw: Amount, + + /** + * Fee for depositing. + */ + val fee_deposit: Amount, + + /** + * Fee for refreshing. + */ + val fee_refresh: Amount, + + /** + * Fee for refunding. + */ + val fee_refund: Amount, + + /** + * Start date from which withdraw is allowed. + */ + val stamp_start: Timestamp, + + /** + * End date for withdrawing. + */ + val stamp_expire_withdraw: Timestamp, + + /** + * Expiration date after which the exchange can forget about + * the currency. + */ + val stamp_expire_legal: Timestamp, + + /** + * Date after which the coins of this denomination can't be + * deposited anymore. + */ + val stamp_expire_deposit: Timestamp, + + /** + * Signature over the denomination information by the exchange's master + * signing key. + */ + val master_sig: String +) { + fun toDenominationRecord( + baseUrl: String, + denomPubHash: ByteArray, + isOffered: Boolean, + isRevoked: Boolean, + status: DenominationStatus + ): DenominationRecord = + DenominationRecord( + denomPub = denom_pub, + denomPubHash = Base32Crockford.encode(denomPubHash), + exchangeBaseUrl = baseUrl, + feeDeposit = fee_deposit, + feeRefresh = fee_refresh, + feeRefund = fee_refund, + feeWithdraw = fee_withdraw, + isOffered = isOffered, + isRevoked = isRevoked, + masterSig = master_sig, + stampExpireDeposit = stamp_expire_deposit, + stampExpireLegal = stamp_expire_legal, + stampExpireWithdraw = stamp_expire_withdraw, + stampStart = stamp_start, + status = status, + value = value + ) +} + +enum class DenominationStatus { + /** + * Verification was delayed. + */ + Unverified, + + /** + * Verified as valid. + */ + VerifiedGood, + + /** + * Verified as invalid. + */ + VerifiedBad +} + +data class DenominationRecord( + /** + * Value of one coin of the denomination. + */ + val value: Amount, + /** + * The denomination public key. + */ + val denomPub: String, + /** + * Hash of the denomination public key. + * Stored in the database for faster lookups. + */ + val denomPubHash: String, + /** + * Fee for withdrawing. + */ + val feeWithdraw: Amount, + /** + * Fee for depositing. + */ + val feeDeposit: Amount, + /** + * Fee for refreshing. + */ + val feeRefresh: Amount, + /** + * Fee for refunding. + */ + val feeRefund: Amount, + /** + * Validity start date of the denomination. + */ + val stampStart: Timestamp, + /** + * Date after which the currency can't be withdrawn anymore. + */ + val stampExpireWithdraw: Timestamp, + /** + * Date after the denomination officially doesn't exist anymore. + */ + val stampExpireLegal: Timestamp, + /** + * Data after which coins of this denomination can't be deposited anymore. + */ + val stampExpireDeposit: Timestamp, + /** + * Signature by the exchange's master key over the denomination + * information. + */ + val masterSig: String, + /** + * Did we verify the signature on the denomination? + */ + val status: DenominationStatus, + /** + * Was this denomination still offered by the exchange the last time + * we checked? + * Only false when the exchange redacts a previously published denomination. + */ + val isOffered: Boolean, + /** + * Did the exchange revoke the denomination? + * When this field is set to true in the database, the same transaction + * should also mark all affected coins as revoked. + */ + val isRevoked: Boolean, + /** + * Base URL of the exchange. + */ + val exchangeBaseUrl: String +) { + fun isWithdrawable(now: Timestamp = Timestamp.now()): Boolean { + if (isRevoked) return false // can not use revoked denomination + if (status != Unverified && status != VerifiedGood) return false // verified to be bad + if (now < stampStart) return false // denomination has not yet started + val lastPossibleWithdraw = stampExpireWithdraw - Duration(50 * 1000) + if ((lastPossibleWithdraw - now).ms == 0L) return false // denomination has expired + return true + } +} + +data class DenominationSelectionInfo( + val totalCoinValue: Amount, + val totalWithdrawCost: Amount, + val selectedDenominations: List<SelectedDenomination> +) { + fun getEarliestDepositExpiry(): Timestamp { + if (selectedDenominations.isEmpty()) return Timestamp( + Timestamp.NEVER + ) + var earliest = selectedDenominations[0].denominationRecord.stampExpireDeposit + for (i in 1 until selectedDenominations.size) { + val stampExpireDeposit = selectedDenominations[i].denominationRecord.stampExpireDeposit + if (stampExpireDeposit < earliest) earliest = stampExpireDeposit + } + return earliest + } +} + +data class SelectedDenomination(val count: Int, val denominationRecord: DenominationRecord) diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Exchange.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Exchange.kt new file mode 100644 index 0000000..7a6ac7f --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Exchange.kt @@ -0,0 +1,262 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.exchange + +import io.ktor.client.HttpClient +import io.ktor.client.request.accept +import io.ktor.client.request.get +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.readText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.Base32Crockford +import net.taler.wallet.kotlin.Db +import net.taler.wallet.kotlin.DbFactory +import net.taler.wallet.kotlin.Timestamp +import net.taler.wallet.kotlin.compareVersions +import net.taler.wallet.kotlin.crypto.Crypto +import net.taler.wallet.kotlin.crypto.CryptoFactory +import net.taler.wallet.kotlin.crypto.Signature +import net.taler.wallet.kotlin.exchange.DenominationStatus.Unverified +import net.taler.wallet.kotlin.exchange.ExchangeUpdateReason.Initial +import net.taler.wallet.kotlin.exchange.ExchangeUpdateStatus.FetchKeys +import net.taler.wallet.kotlin.exchange.ExchangeUpdateStatus.FetchTerms +import net.taler.wallet.kotlin.exchange.ExchangeUpdateStatus.FetchWire +import net.taler.wallet.kotlin.exchange.ExchangeUpdateStatus.FinalizeUpdate +import net.taler.wallet.kotlin.exchange.ExchangeUpdateStatus.Finished +import net.taler.wallet.kotlin.getDefaultHttpClient + +internal class Exchange( + private val crypto: Crypto = CryptoFactory.getCrypto(), + private val signature: Signature = Signature(crypto), + private val httpClient: HttpClient = getDefaultHttpClient(), + // using the default Http client adds a json Accept header to each request, so we need a different one + // because the exchange is returning XML when it doesn't exactly match a mime type. + private val httpNoJsonClient: HttpClient = HttpClient(), + private val db: Db = DbFactory().openDb() +) { + + companion object { + private const val PROTOCOL_VERSION = "8:0:0" + fun getVersionMatch(version: String) = compareVersions(PROTOCOL_VERSION, version) + fun normalizeUrl(exchangeBaseUrl: String): String { + var url = exchangeBaseUrl + if (!url.startsWith("http")) url = "http://$url" + if (!url.endsWith("/")) url = "$url/" + // TODO also remove query and hash + return url + } + } + + /** + * Update or add exchange DB entry by fetching the /keys, /wire and /terms information. + */ + suspend fun updateFromUrl(baseUrl: String): ExchangeRecord { + val now = Timestamp.now() + val url = normalizeUrl(baseUrl) + var record = db.getExchangeByBaseUrl(url) ?: ExchangeRecord( + baseUrl = url, + timestampAdded = now, + updateStatus = FetchKeys, + updateStarted = now, + updateReason = Initial + ).also { db.put(it) } + val recordBeforeUpdate = record.copy() + + record = updateKeys(record) // TODO add denominations in transaction at the end + record = updateWireInfo(record) + record = updateTermsOfService(record) + record = finalizeUpdate(record) + db.transaction { + val dbRecord = getExchangeByBaseUrl(record.baseUrl) + if (dbRecord != recordBeforeUpdate) throw Error("Concurrent modification of $dbRecord") + put(record) + } + return record + } + + /** + * Fetch the exchange's /keys and update database accordingly. + * + * Exceptions thrown in this method must be caught and reported in the pending operations. + */ + internal suspend fun updateKeys(record: ExchangeRecord): ExchangeRecord { + val keys: Keys = Keys.fetch(httpClient, record.baseUrl) + // check if there are denominations offered + // TODO provide more error information for catcher + if (keys.denoms.isEmpty()) { + throw Error("Exchange doesn't offer any denominations") + } + // check if the exchange version is compatible + val versionMatch = getVersionMatch(keys.version) + if (versionMatch == null || !versionMatch.compatible) { + throw Error("Exchange protocol version not compatible with wallet") + } + val currency = keys.denoms[0].value.currency + val newDenominations = keys.denoms.map { d -> + getDenominationRecord(record.baseUrl, currency, d) + } + // update exchange details + val details = ExchangeDetails( + auditors = keys.auditors, + currency = currency, + lastUpdateTime = keys.list_issue_date, + masterPublicKey = keys.master_public_key, + protocolVersion = keys.version, + signingKeys = keys.signkeys + ) + val updatedRecord = record.copy(details = details, updateStatus = FetchWire) + for (newDenomination in newDenominations) { + // TODO check oldDenominations and do consistency checks + db.put(newDenomination) + } + + // TODO handle keys.recoup + + return updatedRecord + } + + /** + * Turn an exchange's denominations from /keys into [DenominationRecord]s + * + * Visible for testing. + */ + internal fun getDenominationRecord(baseUrl: String, currency: String, d: Denomination): DenominationRecord { + checkCurrency(currency, d.value) + checkCurrency(currency, d.fee_refund) + checkCurrency(currency, d.fee_withdraw) + checkCurrency(currency, d.fee_refresh) + checkCurrency(currency, d.fee_deposit) + return d.toDenominationRecord( + baseUrl = baseUrl, + denomPubHash = crypto.sha512(Base32Crockford.decode(d.denom_pub)), + isOffered = true, + isRevoked = false, + status = Unverified + ) + } + + /** + * Fetch wire information for an exchange and store it in the database. + */ + internal suspend fun updateWireInfo(record: ExchangeRecord): ExchangeRecord { + if (record.updateStatus != FetchWire) { + throw Error("Unexpected updateStatus: ${record.updateStatus}, expected: $FetchWire") + } + if (record.details == null) throw Error("Invalid exchange state") + val wire = Wire.fetch(httpClient, record.baseUrl) + // check account signatures + for (a in wire.accounts) { + val valid = signature.verifyWireAccount( + paytoUri = a.paytoUri, + signature = a.masterSig, + masterPub = record.details.masterPublicKey + ) + if (!valid) throw Error("Exchange wire account signature invalid") + } + // check fee signatures + for (fee in wire.fees) { + val wireMethod = fee.key + val wireFees = fee.value + for (wireFee in wireFees) { + val valid = signature.verifyWireFee( + type = wireMethod, + wireFee = wireFee, + masterPub = record.details.masterPublicKey + ) + if (!valid) throw Error("Exchange wire fee signature invalid") + checkCurrency(record.details.currency, wireFee.wireFee) + checkCurrency(record.details.currency, wireFee.closingFee) + } + } + val wireInfo = ExchangeWireInfo( + accounts = wire.accounts.map { ExchangeBankAccount(it.paytoUri) }, + feesForType = wire.fees + ) + return record.copy(updateStatus = FetchTerms, wireInfo = wireInfo) + } + + /** + * Fetch wire information for an exchange and store it in the database. + */ + internal suspend fun updateTermsOfService(record: ExchangeRecord): ExchangeRecord { + if (record.updateStatus != FetchTerms) { + throw Error("Unexpected updateStatus: ${record.updateStatus}, expected: $FetchTerms") + } + val response: HttpResponse = httpNoJsonClient.get("${record.baseUrl}terms") { + accept(ContentType.Text.Plain) + } + if (response.status != HttpStatusCode.OK) { + throw Error("/terms response has unexpected status code (${response.status.value})") + } + val text = response.readText() + val eTag = response.headers[HttpHeaders.ETag] + return record.copy(updateStatus = FinalizeUpdate, termsOfServiceText = text, termsOfServiceLastEtag = eTag) + } + + internal fun finalizeUpdate(record: ExchangeRecord): ExchangeRecord { + if (record.updateStatus != FinalizeUpdate) { + throw Error("Unexpected updateStatus: ${record.updateStatus}, expected: $FinalizeUpdate") + } + // TODO store an event log for this update (exchangeUpdatedEvents) + return record.copy(updateStatus = Finished, addComplete = true) + } + + private fun checkCurrency(currency: String, amount: Amount) { + if (currency != amount.currency) throw Error("Expected currency $currency, but found ${amount.currency}") + } + +} + + +data class ExchangeListItem( + val exchangeBaseUrl: String, + val currency: String, + val paytoUris: List<String> +) { + companion object { + fun fromExchangeRecord(exchange: ExchangeRecord): ExchangeListItem? { + return if (exchange.details == null || exchange.wireInfo == null) null + else ExchangeListItem( + exchangeBaseUrl = exchange.baseUrl, + currency = exchange.details.currency, + paytoUris = exchange.wireInfo.accounts.map { + it.paytoUri + } + ) + } + } +} + +data class GetExchangeTosResult( + /** + * Markdown version of the current ToS. + */ + val tos: String, + + /** + * Version tag of the current ToS. + */ + val currentEtag: String, + + /** + * Version tag of the last ToS that the user has accepted, if any. + */ + val acceptedEtag: String? = null +) diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/ExchangeRecord.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/ExchangeRecord.kt new file mode 100644 index 0000000..9bfd649 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/ExchangeRecord.kt @@ -0,0 +1,151 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.exchange + +import net.taler.wallet.kotlin.Timestamp + +/** + * Exchange record as stored in the wallet's database. + */ +data class ExchangeRecord( + /** + * Base url of the exchange. + */ + val baseUrl: String, + + /** + * Did we finish adding the exchange? + */ + val addComplete: Boolean = false, + + /** + * Was the exchange added as a built-in exchange? + */ + val builtIn: Boolean = false, + + /** + * Details, once known. + */ + val details: ExchangeDetails? = null, + + /** + * Mapping from wire method type to the wire fee. + */ + val wireInfo: ExchangeWireInfo? = null, + + /** + * When was the exchange added to the wallet? + */ + val timestampAdded: Timestamp, + + /** + * Terms of service text or undefined if not downloaded yet. + */ + val termsOfServiceText: String? = null, + + /** + * ETag for last terms of service download. + */ + val termsOfServiceLastEtag: String? = null, + + /** + * ETag for last terms of service download. + */ + val termsOfServiceAcceptedEtag: String? = null, + + /** + * ETag for last terms of service download. + */ + val termsOfServiceAcceptedTimestamp: Timestamp? = null, + + /** + * Time when the update to the exchange has been started or + * undefined if no update is in progress. + */ + val updateStarted: Timestamp? = null, + + val updateStatus: ExchangeUpdateStatus, + + val updateReason: ExchangeUpdateReason? = null +) { + init { + check(baseUrl == Exchange.normalizeUrl(baseUrl)) { "Base URL was not normalized" } + } + + val termsOfServiceAccepted: Boolean + get() = termsOfServiceAcceptedTimestamp != null && termsOfServiceAcceptedEtag == termsOfServiceLastEtag +} + +/** + * Details about the exchange that we only know after querying /keys and /wire. + */ +data class ExchangeDetails( + /** + * Master public key of the exchange. + */ + val masterPublicKey: String, + + /** + * Auditors (partially) auditing the exchange. + */ + val auditors: List<Auditor>, + + /** + * Currency that the exchange offers. + */ + val currency: String, + + /** + * Last observed protocol version. + */ + val protocolVersion: String, + + /** + * Signing keys we got from the exchange, can also contain + * older signing keys that are not returned by /keys anymore. + */ + val signingKeys: List<SigningKey>, + + /** + * Timestamp for last update. + */ + val lastUpdateTime: Timestamp +) + +data class ExchangeWireInfo( + val feesForType: Map<String, List<WireFee>>, + val accounts: List<ExchangeBankAccount> +) + +// TODO is this class needed? +data class ExchangeBankAccount( + val paytoUri: String +) + +sealed class ExchangeUpdateStatus(val value: String) { + object FetchKeys : ExchangeUpdateStatus("fetch-keys") + object FetchWire : ExchangeUpdateStatus("fetch-wire") + object FetchTerms : ExchangeUpdateStatus("fetch-terms") + object FinalizeUpdate : ExchangeUpdateStatus("finalize-update") + object Finished : ExchangeUpdateStatus("finished") +} + +sealed class ExchangeUpdateReason(val value: String) { + object Initial : ExchangeUpdateReason("initial") + object Forced : ExchangeUpdateReason("forced") + object Scheduled : ExchangeUpdateReason("scheduled") +} diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Keys.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Keys.kt new file mode 100644 index 0000000..54806f9 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Keys.kt @@ -0,0 +1,97 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.exchange + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import kotlinx.serialization.Serializable +import net.taler.wallet.kotlin.Timestamp + +/** + * Structure that the exchange gives us in /keys. + */ +@Serializable +internal data class Keys( + /** + * List of offered denominations. + */ + val denoms: List<Denomination>, + + /** + * The exchange's master public key. + */ + val master_public_key: String, + + /** + * The list of auditors (partially) auditing the exchange. + */ + val auditors: List<Auditor>, + + /** + * Timestamp when this response was issued. + */ + val list_issue_date: Timestamp, + + /** + * List of revoked denominations. + */ + val recoup: List<Recoup>?, + + /** + * Short-lived signing keys used to sign online + * responses. + */ + val signkeys: List<SigningKey>, + + /** + * Protocol version. + */ + val version: String +) { + companion object { + /** + * Fetch an exchange's /keys with the given normalized base URL. + */ + suspend fun fetch(httpClient: HttpClient, exchangeBaseUrl: String): Keys { + return httpClient.get("${exchangeBaseUrl}keys") + } + } +} + +/** + * Structure of one exchange signing key in the /keys response. + */ +@Serializable +data class SigningKey( + val stamp_start: Timestamp, + val stamp_expire: Timestamp, + val stamp_end: Timestamp, + val key: String, + val master_sig: String +) + +/** + * Element of the payback list that the + * exchange gives us in /keys. + */ +@Serializable +data class Recoup( + /** + * The hash of the denomination public key for which the payback is offered. + */ + val h_denom_pub: String +) diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Wire.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Wire.kt new file mode 100644 index 0000000..c8fae88 --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/exchange/Wire.kt @@ -0,0 +1,79 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.exchange + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.Timestamp + +@Serializable +internal data class Wire( + val accounts: List<AccountInfo>, + val fees: Map<String, List<WireFee>> +) { + companion object { + /** + * Fetch an exchange's /wire with the given normalized base URL. + */ + suspend fun fetch(httpClient: HttpClient, exchangeBaseUrl: String): Wire { + return httpClient.get("${exchangeBaseUrl}wire") + } + } +} + +@Serializable +data class AccountInfo( + @SerialName("payto_uri") + val paytoUri: String, + @SerialName("master_sig") + val masterSig: String +) + +/** + * Wire fees as announced by the exchange. + */ +@Serializable +data class WireFee( + /** + * Fee for wire transfers. + */ + @SerialName("wire_fee") + val wireFee: Amount, + /** + * Fees to close and refund a reserve. + */ + @SerialName("closing_fee") + val closingFee: Amount, + /** + * Start date of the fee. + */ + @SerialName("start_date") + val startStamp: Timestamp, + /** + * End date of the fee. + */ + @SerialName("end_date") + val endStamp: Timestamp, + /** + * Signature made by the exchange master key. + */ + @SerialName("sig") + val signature: String +) diff --git a/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/operations/Withdraw.kt b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/operations/Withdraw.kt new file mode 100644 index 0000000..e51e9ec --- /dev/null +++ b/wallet/src/commonMain/kotlin/net/taler/wallet/kotlin/operations/Withdraw.kt @@ -0,0 +1,305 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.operations + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.Db +import net.taler.wallet.kotlin.DbFactory +import net.taler.wallet.kotlin.TalerUri.parseWithdrawUri +import net.taler.wallet.kotlin.Timestamp +import net.taler.wallet.kotlin.VersionMatchResult +import net.taler.wallet.kotlin.crypto.Crypto +import net.taler.wallet.kotlin.crypto.CryptoFactory +import net.taler.wallet.kotlin.crypto.Signature +import net.taler.wallet.kotlin.exchange.DenominationRecord +import net.taler.wallet.kotlin.exchange.DenominationSelectionInfo +import net.taler.wallet.kotlin.exchange.DenominationStatus.Unverified +import net.taler.wallet.kotlin.exchange.DenominationStatus.VerifiedBad +import net.taler.wallet.kotlin.exchange.DenominationStatus.VerifiedGood +import net.taler.wallet.kotlin.exchange.Exchange +import net.taler.wallet.kotlin.exchange.ExchangeListItem +import net.taler.wallet.kotlin.exchange.ExchangeRecord +import net.taler.wallet.kotlin.exchange.ExchangeWireInfo +import net.taler.wallet.kotlin.exchange.SelectedDenomination +import net.taler.wallet.kotlin.getDefaultHttpClient + +internal class Withdraw( + private val httpClient: HttpClient = getDefaultHttpClient(), + private val db: Db = DbFactory().openDb(), + private val crypto: Crypto = CryptoFactory.getCrypto(), + private val signature: Signature = Signature(crypto), + private val exchange: Exchange = Exchange(crypto, signature, httpClient, db = db) +) { + + data class BankDetails( + val amount: Amount, + val selectionDone: Boolean, + val transferDone: Boolean, + val senderPaytoUri: String?, + val suggestedExchange: String?, + val confirmTransferUrl: String?, + val wireTypes: List<String>, + val extractedStatusUrl: String + ) + + @Serializable + data class Response( + @SerialName("selection_done") + val selectionDone: Boolean, + @SerialName("transfer_done") + val transferDone: Boolean, + val amount: Amount, + @SerialName("wire_types") + val wireTypes: List<String>, + @SerialName("sender_wire") + val senderPaytoUri: String?, + @SerialName("suggested_exchange") + val suggestedExchange: String?, + @SerialName("confirm_transfer_url") + val confirmTransferUrl: String? + ) { + fun toBankDetails(extractedStatusUrl: String) = BankDetails( + amount = amount, + confirmTransferUrl = confirmTransferUrl, + extractedStatusUrl = extractedStatusUrl, + selectionDone = selectionDone, + senderPaytoUri = senderPaytoUri, + suggestedExchange = suggestedExchange, + transferDone = transferDone, + wireTypes = wireTypes + ) + } + + suspend fun getBankInfo(talerWithdrawUri: String): BankDetails { + val uriResult = + parseWithdrawUri(talerWithdrawUri) ?: throw Error("Can't parse URI $talerWithdrawUri") + val url = + "${uriResult.bankIntegrationApiBaseUrl}api/withdraw-operation/${uriResult.withdrawalOperationId}" + val response: Response = httpClient.get(url) + return response.toBankDetails(url) + } + + /** + * Information about what will happen when creating a reserve. + * + * Sent to the wallet frontend to be rendered and shown to the user. + */ + data class WithdrawalDetails( + /** + * Exchange that the reserve will be created at. + */ + // TODO we probably don't need to include our internal exchange record in here + val exchange: ExchangeRecord, + + /** + * Selected denominations for withdraw. + */ + val selectedDenominations: DenominationSelectionInfo, + + /** + * Fees for withdraw. + */ + val withdrawFee: Amount, + + /** + * Remaining balance that is too small to be withdrawn. + */ + val overhead: Amount, + + /** + * The earliest deposit expiration of the selected coins. + */ + // TODO what is this needed for? + val earliestDepositExpiration: Timestamp, + + /** + * Number of currently offered denominations. + */ + // TODO what is this needed for? + val numOfferedDenoms: Int + ) { + init { + check(exchange.details != null) + check(exchange.wireInfo != null) + } + + /** + * Filtered wire info to send to the bank. + */ + val exchangeWireAccounts: List<String> get() = exchange.wireInfo!!.accounts.map { it.paytoUri } + + /** + * Wire fees from the exchange. + */ + val wireFees: ExchangeWireInfo get() = exchange.wireInfo!! + + /** + * Did the user already accept the current terms of service for the exchange? + */ + val termsOfServiceAccepted: Boolean get() = exchange.termsOfServiceAccepted + + /** + * Result of checking the wallet's version against the exchange's version. + */ + val versionMatch: VersionMatchResult? + get() = Exchange.getVersionMatch(exchange.details!!.protocolVersion) + + } + + internal suspend fun getWithdrawalDetails( + exchangeBaseUrl: String, + amount: Amount + ): WithdrawalDetails { + val exchange = exchange.updateFromUrl(exchangeBaseUrl) + check(exchange.details != null) + check(exchange.wireInfo != null) + val selectedDenominations = selectDenominations(exchange, amount) + val possibleDenominations = + db.getDenominationsByBaseUrl(exchangeBaseUrl).filter { it.isOffered } + // TODO determine trust and audit status + return WithdrawalDetails( + exchange = exchange, + selectedDenominations = selectedDenominations, + withdrawFee = selectedDenominations.totalWithdrawCost - selectedDenominations.totalCoinValue, + overhead = amount - selectedDenominations.totalWithdrawCost, + earliestDepositExpiration = selectedDenominations.getEarliestDepositExpiry(), + numOfferedDenoms = possibleDenominations.size + ) + } + + /** + * Get a list of denominations to withdraw from the given exchange for the given amount, + * making sure that all denominations' signatures are verified. + */ + internal suspend fun selectDenominations( + exchange: ExchangeRecord, + amount: Amount + ): DenominationSelectionInfo { + val exchangeDetails = + exchange.details ?: throw Error("Exchange $exchange details not available.") + + val possibleDenominations = getPossibleDenominations(exchange.baseUrl) + val selectedDenominations = getDenominationSelection(amount, possibleDenominations) + // TODO consider validating denominations before writing them into the DB + for (selectedDenomination in selectedDenominations.selectedDenominations) { + var denomination = selectedDenomination.denominationRecord + if (denomination.status == Unverified) { + val valid = signature.verifyDenominationRecord( + denomination, + exchangeDetails.masterPublicKey + ) + denomination = if (!valid) { + denomination.copy(status = VerifiedBad) + } else { + denomination.copy(status = VerifiedGood) + } + db.put(denomination) + } + if (denomination.status == VerifiedBad) throw Error("Exchange $exchange has bad denomination.") + } + return selectedDenominations + } + + suspend fun getPossibleDenominations(exchangeBaseUrl: String): List<DenominationRecord> { + return db.getDenominationsByBaseUrl(exchangeBaseUrl).filter { denomination -> + (denomination.status == Unverified || denomination.status == VerifiedGood) && + !denomination.isRevoked + } + } + + /** + * Get a list of denominations (with repetitions possible) + * whose total value is as close as possible to the available amount, but never larger. + * + * Note that this algorithm does not try to optimize withdrawal fees. + */ + fun getDenominationSelection( + amount: Amount, + denoms: List<DenominationRecord> + ): DenominationSelectionInfo { + val selectedDenominations = ArrayList<SelectedDenomination>() + var totalCoinValue = Amount.zero(amount.currency) + var totalWithdrawCost = Amount.zero(amount.currency) + + // denominations need to be sorted, so we try the highest ones first + val now = Timestamp.now() + val denominations = denoms.filter { it.isWithdrawable(now) }.sortedByDescending { it.value } + var remainingAmount = amount.copy() + val zero = Amount.zero(amount.currency) + for (d in denominations) { + var count = 0 + val totalCost = d.value + d.feeWithdraw + // keep adding this denomination as long as its total cost fits into remaining amount + while (remainingAmount >= totalCost) { + remainingAmount -= totalCost + count++ + } + // calculate new totals based on count-many added denominations + if (count > 0) { + totalCoinValue += d.value * count + totalWithdrawCost += totalCost * count + selectedDenominations.add(SelectedDenomination(count, d)) + } + // stop early if nothing is remaining + if (remainingAmount == zero) break + } + return DenominationSelectionInfo( + selectedDenominations = selectedDenominations, + totalCoinValue = totalCoinValue, + totalWithdrawCost = totalWithdrawCost + ) + } + +} + +data class WithdrawalDetailsForUri( + /** + * The amount that the user wants to withdraw + */ + val amount: Amount, + + /** + * Exchange suggested by the wallet + */ + val defaultExchangeBaseUrl: String?, + + /** + * A list of exchanges that can be used for this withdrawal + */ + val possibleExchanges: List<ExchangeListItem> +) + +data class WithdrawalDetails( + /** + * Did the user accept the current version of the exchange's terms of service? + */ + val tosAccepted: Boolean, + + /** + * Amount that will be transferred to the exchange. + */ + val amountRaw: Amount, + + /** + * Amount that will be added to the user's wallet balance. + */ + val amountEffective: Amount +) diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/AmountTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/AmountTest.kt new file mode 100644 index 0000000..08ee618 --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/AmountTest.kt @@ -0,0 +1,276 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.fail + +class AmountTest { + + companion object { + private val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9') + fun getRandomString(minLength: Int = 1, maxLength: Int = Random.nextInt(0, 1337)) = (minLength..maxLength) + .map { Random.nextInt(0, charPool.size) } + .map(charPool::get) + .joinToString("") + + fun getRandomAmount() = getRandomAmount(getRandomString(1, Random.nextInt(1, 12))) + + fun getRandomAmount(currency: String): Amount { + val value = Random.nextLong(0, Amount.MAX_VALUE) + val fraction = Random.nextInt(0, Amount.MAX_FRACTION) + return Amount(currency, value, fraction) + } + } + + @Test + fun testFromJSONString() { + var str = "TESTKUDOS:23.42" + var amount = Amount.fromJSONString(str) + assertEquals(str, amount.toJSONString()) + assertEquals("TESTKUDOS", amount.currency) + assertEquals(23, amount.value) + assertEquals((0.42 * 1e8).toInt(), amount.fraction) + assertEquals("23.42 TESTKUDOS", amount.toString()) + + str = "EUR:500000000.00000001" + amount = Amount.fromJSONString(str) + assertEquals(str, amount.toJSONString()) + assertEquals("EUR", amount.currency) + assertEquals(500000000, amount.value) + assertEquals(1, amount.fraction) + assertEquals("500000000.00000001 EUR", amount.toString()) + + str = "EUR:1500000000.00000003" + amount = Amount.fromJSONString(str) + assertEquals(str, amount.toJSONString()) + assertEquals("EUR", amount.currency) + assertEquals(1500000000, amount.value) + assertEquals(3, amount.fraction) + assertEquals("1500000000.00000003 EUR", amount.toString()) + } + + @Test + fun testFromJSONStringAcceptsMaxValuesRejectsAbove() { + val maxValue = 4503599627370496 + val str = "TESTKUDOS123:$maxValue.99999999" + val amount = Amount.fromJSONString(str) + assertEquals(str, amount.toJSONString()) + assertEquals("TESTKUDOS123", amount.currency) + assertEquals(maxValue, amount.value) + assertEquals("$maxValue.99999999 TESTKUDOS123", amount.toString()) + + // longer currency not accepted + assertThrows<AmountParserException>("longer currency was accepted") { + Amount.fromJSONString("TESTKUDOS1234:$maxValue.99999999") + } + + // max value + 1 not accepted + assertThrows<AmountParserException>("max value + 1 was accepted") { + Amount.fromJSONString("TESTKUDOS123:${maxValue + 1}.99999999") + } + + // max fraction + 1 not accepted + assertThrows<AmountParserException>("max fraction + 1 was accepted") { + Amount.fromJSONString("TESTKUDOS123:$maxValue.999999990") + } + } + + @Test + fun testFromJSONStringRejections() { + assertThrows<AmountParserException> { + Amount.fromJSONString("TESTKUDOS:0,5") + } + assertThrows<AmountParserException> { + Amount.fromJSONString("+TESTKUDOS:0.5") + } + assertThrows<AmountParserException> { + Amount.fromJSONString("0.5") + } + assertThrows<AmountParserException> { + Amount.fromJSONString(":0.5") + } + assertThrows<AmountParserException> { + Amount.fromJSONString("EUR::0.5") + } + assertThrows<AmountParserException> { + Amount.fromJSONString("EUR:.5") + } + } + + @Test + fun testAddition() { + assertEquals( + Amount.fromJSONString("EUR:2"), + Amount.fromJSONString("EUR:1") + Amount.fromJSONString("EUR:1") + ) + assertEquals( + Amount.fromJSONString("EUR:3"), + Amount.fromJSONString("EUR:1.5") + Amount.fromJSONString("EUR:1.5") + ) + assertEquals( + Amount.fromJSONString("EUR:500000000.00000002"), + Amount.fromJSONString("EUR:500000000.00000001") + Amount.fromJSONString("EUR:0.00000001") + ) + assertThrows<AmountOverflowException>("addition didn't overflow") { + Amount.fromJSONString("EUR:4503599627370496.99999999") + Amount.fromJSONString("EUR:0.00000001") + } + assertThrows<AmountOverflowException>("addition didn't overflow") { + Amount.fromJSONString("EUR:4000000000000000") + Amount.fromJSONString("EUR:4000000000000000") + } + } + + @Test + fun testTimes() { + assertEquals( + Amount.fromJSONString("EUR:2"), + Amount.fromJSONString("EUR:2") * 1 + ) + assertEquals( + Amount.fromJSONString("EUR:2"), + Amount.fromJSONString("EUR:1") * 2 + ) + assertEquals( + Amount.fromJSONString("EUR:4.5"), + Amount.fromJSONString("EUR:1.5") * 3 + ) + assertEquals(Amount.fromJSONString("EUR:0"), Amount.fromJSONString("EUR:1.11") * 0) + assertEquals(Amount.fromJSONString("EUR:1.11"), Amount.fromJSONString("EUR:1.11") * 1) + assertEquals(Amount.fromJSONString("EUR:2.22"), Amount.fromJSONString("EUR:1.11") * 2) + assertEquals(Amount.fromJSONString("EUR:3.33"), Amount.fromJSONString("EUR:1.11") * 3) + assertEquals(Amount.fromJSONString("EUR:4.44"), Amount.fromJSONString("EUR:1.11") * 4) + assertEquals(Amount.fromJSONString("EUR:5.55"), Amount.fromJSONString("EUR:1.11") * 5) + assertEquals( + Amount.fromJSONString("EUR:1500000000.00000003"), + Amount.fromJSONString("EUR:500000000.00000001") * 3 + ) + assertThrows<AmountOverflowException>("times didn't overflow") { + Amount.fromJSONString("EUR:4000000000000000") * 2 + } + } + + @Test + fun testSubtraction() { + assertEquals( + Amount.fromJSONString("EUR:0"), + Amount.fromJSONString("EUR:1") - Amount.fromJSONString("EUR:1") + ) + assertEquals( + Amount.fromJSONString("EUR:1.5"), + Amount.fromJSONString("EUR:3") - Amount.fromJSONString("EUR:1.5") + ) + assertEquals( + Amount.fromJSONString("EUR:500000000.00000001"), + Amount.fromJSONString("EUR:500000000.00000002") - Amount.fromJSONString("EUR:0.00000001") + ) + assertThrows<AmountOverflowException>("subtraction didn't underflow") { + Amount.fromJSONString("EUR:23.42") - Amount.fromJSONString("EUR:42.23") + } + assertThrows<AmountOverflowException>("subtraction didn't underflow") { + Amount.fromJSONString("EUR:0.5") - Amount.fromJSONString("EUR:0.50000001") + } + } + + @Test + fun testIsZero() { + assertTrue(Amount.zero("EUR").isZero()) + assertTrue(Amount.fromJSONString("EUR:0").isZero()) + assertTrue(Amount.fromJSONString("EUR:0.0").isZero()) + assertTrue(Amount.fromJSONString("EUR:0.00000").isZero()) + assertTrue((Amount.fromJSONString("EUR:1.001") - Amount.fromJSONString("EUR:1.001")).isZero()) + + assertFalse(Amount.fromJSONString("EUR:0.00000001").isZero()) + assertFalse(Amount.fromJSONString("EUR:1.0").isZero()) + assertFalse(Amount.fromJSONString("EUR:0001.0").isZero()) + } + + @Test + fun testComparision() { + assertTrue(Amount.fromJSONString("EUR:0") <= Amount.fromJSONString("EUR:0")) + assertTrue(Amount.fromJSONString("EUR:0") <= Amount.fromJSONString("EUR:0.00000001")) + assertTrue(Amount.fromJSONString("EUR:0") < Amount.fromJSONString("EUR:0.00000001")) + assertTrue(Amount.fromJSONString("EUR:0") < Amount.fromJSONString("EUR:1")) + assertEquals(Amount.fromJSONString("EUR:0"), Amount.fromJSONString("EUR:0")) + assertEquals(Amount.fromJSONString("EUR:42"), Amount.fromJSONString("EUR:42")) + assertEquals(Amount.fromJSONString("EUR:42.00000001"), Amount.fromJSONString("EUR:42.00000001")) + assertTrue(Amount.fromJSONString("EUR:42.00000001") >= Amount.fromJSONString("EUR:42.00000001")) + assertTrue(Amount.fromJSONString("EUR:42.00000002") >= Amount.fromJSONString("EUR:42.00000001")) + assertTrue(Amount.fromJSONString("EUR:42.00000002") > Amount.fromJSONString("EUR:42.00000001")) + assertTrue(Amount.fromJSONString("EUR:0.00000002") > Amount.fromJSONString("EUR:0.00000001")) + assertTrue(Amount.fromJSONString("EUR:0.00000001") > Amount.fromJSONString("EUR:0")) + assertTrue(Amount.fromJSONString("EUR:2") > Amount.fromJSONString("EUR:1")) + + assertThrows<IllegalStateException>("could compare amounts with different currencies") { + Amount.fromJSONString("EUR:0.5") < Amount.fromJSONString("USD:0.50000001") + } + } + + @Test + fun testToByteArray() { + val vectors = listOf( + Pair("ceicWVf9GhJ:3902026702525079.40496378", "006XSQV3G899E0K9XKX66SB9CDBNCSHS8XM4M00"), + Pair("asYDLuK2A:3800267550024600.02072907", "006R0MNXBVHSG00ZM55P2WTS8H67AJSJ8400000"), + Pair("pV1m:1347558259914570.09786232", "002CK66VCNVMM04NADW70NHHDM0000000000000"), + Pair("geO82l:553744321840253.41004983", "000ZF855K627T0KHNYVPESAF70S6R0000000000"), + Pair("B9bWK7WPEO:3663912678613976.12122563", "006G8KS5P9HXG05RZ71M4EB2AX5KENTG8N7G000"), + Pair("X:1537372109907438.77850768", "002QCETPFYJYW153X285G000000000000000000"), + Pair("5:4271492725553118.39728399", "007JSSK6J4VXW0JY6M7KA000000000000000000"), + Pair("OSdV:801656289790342.08256189", "001DJ6H6CA4RC03XZAYMYMV4AR0000000000000"), + Pair("Y6:2908617536334646.94126271", "0055AQTB19NKC1CW82ZNJDG0000000000000000"), + Pair("kSHoOZj:2610656582865206.00292046", "004MCR6T828KC004EK76PMT8DX7NMTG00000000"), + Pair("GkhLXrlGES:4246330707533398.83874252", "007HC0Z9DFF5C17ZT764ETV89HC74V278N9G000"), + Pair("CNS09:738124490298524.71259462", "0019YMG01DA9R11ZAN346KJK60WG00000000000"), + Pair("sw0b1tKXZym:2132978464977419.28199478", "003S7VNZPZS0P0DE98V76XSGC8RQ8JTRB9WPT00"), + Pair("fC:1275322307696988.17178522", "0028FSGX3ZCNR0863YD6CGR0000000000000000"), + Pair("cRai6j:166032749022734.69444771", "0009E0C30V70W113MJHP6MK1D4V6M0000000000"), + Pair("KOADwTb3:3932974019564218.48282023", "006ZJ16ZB39BM0Q0Q6KMPKT18HVN8RHK0000000"), + Pair("9Fi9wcLgDe:1268366772151214.97268853", "002834N6WRHTW1EC6HTKJHK975VP6K378HJG000"), + Pair("SDN:3370670470236379.88943272", "005ZK6V0124DP1AD5AM56H2E000000000000000"), + Pair("zGCP5V:4010014441349620.76121145", "0073Y5HYA8GZ8149GGWQMHT3A0TNC0000000000"), + Pair("VsW1JjBLn:2037070181191907.99717275", "003KSD2WH18E61FHJ2DNCWTQ6556MGJCDR00000"), + Pair("A:1806895799429502.00887758", "0036PQ5P8NMQW00DHF742000000000000000000"), + Pair("njA8:4015261148004966.43708687", "00747PYPD116C0MTY47PWTJ1700000000000000"), + Pair("Bwq:3562876074139250.28829179", "006AGTNTRWF740DQWQXM4XVH000000000000000"), + Pair("8e75v8:3716241006992995.95213823", "006K7SP93WF661DCV3ZKGS9Q6NV3G0000000000"), + Pair("XrnbQTTn:3887603772953949.94721267", "006WZGA9X8ANT1D5AKSNGWKEC98N8N3E0000000"), + Pair("MIN:0.00000001", "0000000000000000000MTJAE000000000000000"), + Pair("MAX:4503599627370496.99999999", "00800000000001FNW3ZMTGAR000000000000000") + ) + for (v in vectors) { + val amount = Amount.fromJSONString(v.first) + val encodedBytes = Base32Crockford.encode(amount.toByteArray()) + assertEquals(v.second, encodedBytes) + } + } + + private inline fun <reified T : Throwable> assertThrows( + msg: String? = null, + function: () -> Any + ) { + try { + function.invoke() + fail(msg) + } catch (e: Exception) { + assertTrue(e is T) + } + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/Base32CrockfordTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/Base32CrockfordTest.kt new file mode 100644 index 0000000..565a395 --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/Base32CrockfordTest.kt @@ -0,0 +1,123 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@ExperimentalStdlibApi +class Base32CrockfordTest { + + private class TestVector(val value: ByteArray, val encoding: List<String>) + + private val vectors = listOf( + TestVector(byteArrayOf(0), listOf("00", "0O", "0o")), + TestVector(byteArrayOf(0), listOf("00", "0O", "0o")), + TestVector(byteArrayOf(1), listOf("04")), + TestVector(byteArrayOf(2), listOf("08")), + TestVector(byteArrayOf(3), listOf("0C")), + TestVector(byteArrayOf(4), listOf("0G")), + TestVector(byteArrayOf(5), listOf("0M")), + TestVector(byteArrayOf(6), listOf("0R")), + TestVector(byteArrayOf(7), listOf("0W")), + TestVector(byteArrayOf(8), listOf("10")), + TestVector(byteArrayOf(9), listOf("14")), + TestVector(byteArrayOf(10), listOf("18")), + TestVector(byteArrayOf(11), listOf("1C")), + TestVector(byteArrayOf(12), listOf("1G")), + TestVector(byteArrayOf(13), listOf("1M")), + TestVector(byteArrayOf(14), listOf("1R")), + TestVector(byteArrayOf(15), listOf("1W")), + TestVector(byteArrayOf(16), listOf("20")), + TestVector(byteArrayOf(17), listOf("24")), + TestVector(byteArrayOf(18), listOf("28")), + TestVector(byteArrayOf(19), listOf("2C")), + TestVector(byteArrayOf(20), listOf("2G")), + TestVector(byteArrayOf(21), listOf("2M")), + TestVector(byteArrayOf(22), listOf("2R")), + TestVector(byteArrayOf(23), listOf("2W")), + TestVector(byteArrayOf(24), listOf("30")), + TestVector(byteArrayOf(25), listOf("34")), + TestVector(byteArrayOf(26), listOf("38")), + TestVector(byteArrayOf(27), listOf("3C")), + TestVector(byteArrayOf(28), listOf("3G")), + TestVector(byteArrayOf(29), listOf("3M")), + TestVector(byteArrayOf(30), listOf("3R")), + TestVector(byteArrayOf(31), listOf("3W")), + TestVector(byteArrayOf(0, 0), listOf("0000", "oooo", "OOOO", "0oO0")), + TestVector(byteArrayOf(1, 0), listOf("0400", "o4oo", "O4OO", "04oO")), + TestVector(byteArrayOf(0, 1), listOf("000G", "ooog", "OOOG", "0oOg")), + TestVector(byteArrayOf(1, 1), listOf("040G", "o4og", "O4og", "04Og")), + TestVector(byteArrayOf(136.toByte(), 64), listOf("H100", "hio0", "HLOo")), + TestVector(byteArrayOf(139.toByte(), 188.toByte()), listOf("HEY0", "heyo", "HeYO")), + TestVector(byteArrayOf(54, 31, 127), listOf("6RFQY", "6rfqy")), + TestVector( + byteArrayOf(72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33), + listOf("91JPRV3F41BPYWKCCGGG", "91jprv3f41bpywkccggg", "9Ljprv3f4ibpywkccggg") + ), + TestVector( + byteArrayOf(139.toByte(), 130.toByte(), 16, 112, 24, 11, 64), + listOf("HE110W0R1D00", "helloworld00", "heiiOw0RidoO") + ), + TestVector( + byteArrayOf(139.toByte(), 130.toByte(), 16, 112, 24, 11), + listOf("HE110W0R1C", "helloworlc", "heiiOw0RiC") + ), + TestVector( + byteArrayOf(139.toByte(), 130.toByte(), 16, 112, 24, 11, 0), + listOf("HE110W0R1C00", "helloworlc00", "heiiOw0RiC00") + ) + ) + + @Test + fun testEncode() { + for (vector in vectors) { + assertEquals(vector.encoding[0], Base32Crockford.encode(vector.value)) + } + } + + @Test + fun testDecode() { + for (vector in vectors) { + for (encoding in vector.encoding) { + assertTrue(vector.value contentEquals Base32Crockford.decode(encoding)) + } + } + } + + @Ignore // TODO + @Test + fun testDecodeFuck() { + val bytes = byteArrayOf(0x7e, 0xd9.toByte()) + assertTrue(bytes contentEquals Base32Crockford.decode("FUCK")) + assertTrue(bytes contentEquals Base32Crockford.decode("FuCk")) + assertTrue(bytes contentEquals Base32Crockford.decode("fUcK")) + assertTrue(bytes contentEquals Base32Crockford.decode("FVCK")) + } + + @Test + fun testEncodingDecoding() { + val input = "Hello, World" + val encoded = Base32Crockford.encode(input.encodeToByteArray()) + val decoded = Base32Crockford.decode(encoded) + val output = decoded.decodeToString() + assertEquals(input, output) + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/DbTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/DbTest.kt new file mode 100644 index 0000000..ab4770d --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/DbTest.kt @@ -0,0 +1,100 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import net.taler.wallet.kotlin.exchange.Denominations.denomination10 +import net.taler.wallet.kotlin.exchange.Denominations.denomination5 +import net.taler.wallet.kotlin.exchange.DenominationStatus.Unverified +import net.taler.wallet.kotlin.exchange.DenominationStatus.VerifiedGood +import net.taler.wallet.kotlin.exchange.ExchangeRecord +import net.taler.wallet.kotlin.exchange.ExchangeUpdateReason.Initial +import net.taler.wallet.kotlin.exchange.ExchangeUpdateStatus.FetchKeys +import kotlin.test.Test +import kotlin.test.assertEquals + +class DbTest { + + private val dbFactory = DbFactory() + + private val exchange1 = ExchangeRecord( + baseUrl = "https://example1.org/", + timestampAdded = Timestamp.now(), + updateStatus = FetchKeys, + updateStarted = Timestamp.now(), + updateReason = Initial + ) + private val exchange2 = ExchangeRecord( + baseUrl = "https://example2.org/", + timestampAdded = Timestamp.now(), + updateStatus = FetchKeys, + updateStarted = Timestamp.now(), + updateReason = Initial + ) + + @Test + fun testExchanges() = runCoroutine { + val db = dbFactory.openDb() + var exchanges = db.listExchanges() + assertEquals(0, exchanges.size) + + db.put(exchange1) + exchanges = db.listExchanges() + assertEquals(1, exchanges.size) + assertEquals(exchange1, exchanges[0]) + + db.put(exchange2) + exchanges = db.listExchanges() + assertEquals(2, exchanges.size) + assertEquals(exchange1, db.getExchangeByBaseUrl(exchange1.baseUrl)) + assertEquals(exchange2, db.getExchangeByBaseUrl(exchange2.baseUrl)) + + db.deleteExchangeByBaseUrl(exchange1.baseUrl) + exchanges = db.listExchanges() + assertEquals(1, exchanges.size) + assertEquals(exchange2, exchanges[0]) + } + + @Test + fun testDenominations() = runCoroutine { + val db = dbFactory.openDb() + val url = denomination10.exchangeBaseUrl + + // no denominations should be in DB + var denominations = db.getDenominationsByBaseUrl(url) + assertEquals(0, denominations.size) + + // add a denomination and check that it is really there + db.put(denomination10) + denominations = db.getDenominationsByBaseUrl(url) + assertEquals(1, denominations.size) + assertEquals(denomination10, denominations[0]) + + // modify existing denomination and check that it gets updated + assertEquals(Unverified, denomination10.status) + val newDenomination = denomination10.copy(status = VerifiedGood) + db.put(newDenomination) + denominations = db.getDenominationsByBaseUrl(url) + assertEquals(1, denominations.size) + assertEquals(newDenomination, denominations[0]) + + // add one more denomination + db.put(denomination5) + denominations = db.getDenominationsByBaseUrl(url) + assertEquals(2, denominations.size) + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/PaytoUriTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/PaytoUriTest.kt new file mode 100644 index 0000000..4f080e3 --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/PaytoUriTest.kt @@ -0,0 +1,58 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class PaytoUriTest { + + @Test + fun testFromString() { + // wrong scheme + var uri = "https://example.com/" + assertNull(PaytoUri.fromString(uri)) + + // incomplete scheme + uri = "payto:blabla" + assertNull(PaytoUri.fromString(uri)) + + // proper URI + uri = "payto://x-taler-bank/123" + var parsedUri = PaytoUri.fromString(uri) + assertNotNull(parsedUri) + assertEquals("x-taler-bank", parsedUri.targetType) + assertEquals("123", parsedUri.targetPath) + + // proper URI with incomplete query + uri = "payto://x-taler-bank/123?foo" + parsedUri = PaytoUri.fromString(uri) + assertNotNull(parsedUri) + assertEquals(0, parsedUri.params.size) + + // proper URI with two query param + uri = "payto://x-taler-bank/123?foo=bar&hip=hop" + parsedUri = PaytoUri.fromString(uri) + assertNotNull(parsedUri) + assertEquals(2, parsedUri.params.size) + assertEquals("bar", parsedUri.params["foo"]) + assertEquals("hop", parsedUri.params["hip"]) + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/TalerUriTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/TalerUriTest.kt new file mode 100644 index 0000000..cfcc8bd --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/TalerUriTest.kt @@ -0,0 +1,65 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import net.taler.wallet.kotlin.TalerUri.WithdrawUriResult +import net.taler.wallet.kotlin.TalerUri.parseWithdrawUri +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TalerUriTest { + + @Test + fun testParseWithdrawUri() { + // correct parsing + var uri = "taler://withdraw/bank.example.com/12345" + var expected = WithdrawUriResult("https://bank.example.com/", "12345") + assertEquals(expected, parseWithdrawUri(uri)) + + // correct parsing with insecure http + uri = "taler+http://withdraw/bank.example.org/foo" + expected = WithdrawUriResult("http://bank.example.org/", "foo") + assertEquals(expected, parseWithdrawUri(uri)) + + // correct parsing with long path + uri = "taler://withdraw/bank.example.com/foo/bar/23/42/1337/1234567890" + expected = WithdrawUriResult("https://bank.example.com/foo/bar/23/42/1337", "1234567890") + assertEquals(expected, parseWithdrawUri(uri)) + + // rejects incorrect scheme + uri = "talerx://withdraw/bank.example.com/12345" + assertNull(parseWithdrawUri(uri)) + + // rejects incorrect authority + uri = "taler://withdrawx/bank.example.com/12345" + assertNull(parseWithdrawUri(uri)) + + // rejects incorrect authority with insecure http + uri = "taler+http://withdrawx/bank.example.com/12345" + assertNull(parseWithdrawUri(uri)) + + // rejects empty withdrawalId + uri = "taler://withdraw/bank.example.com//" + assertNull(parseWithdrawUri(uri)) + + // rejects empty path and withdrawalId + uri = "taler://withdraw/bank.example.com////" + assertNull(parseWithdrawUri(uri)) + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/TestUtils.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/TestUtils.kt new file mode 100644 index 0000000..0ece68e --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/TestUtils.kt @@ -0,0 +1,69 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockEngineConfig +import io.ktor.client.engine.mock.respond +import io.ktor.client.features.json.JsonFeature +import io.ktor.client.features.json.serializer.KotlinxSerializer +import io.ktor.http.ContentType.Application +import io.ktor.http.Url +import io.ktor.http.fullPath +import io.ktor.http.headersOf +import io.ktor.http.hostWithPort +import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.json.Json + +enum class PlatformTarget { + ANDROID, + JS, + NATIVE, +} + +/** + * Workaround to use suspending functions in unit tests + */ +expect fun runCoroutine(block: suspend (scope: CoroutineScope) -> Unit) + +expect fun getPlatformTarget(): PlatformTarget + +fun getMockHttpClient(): HttpClient = HttpClient(MockEngine) { + install(JsonFeature) { + serializer = KotlinxSerializer(Json(getJsonConfiguration())) + } + engine { + addHandler { error("No test handler added") } + } +} + +fun HttpClient.giveJsonResponse(url: String, jsonProducer: () -> String) { + val httpConfig = engineConfig as MockEngineConfig + httpConfig.requestHandlers.removeAt(0) + httpConfig.requestHandlers.add { request -> + if (request.url.fullUrl == url) { + val headers = headersOf("Content-Type" to listOf(Application.Json.toString())) + respond(jsonProducer(), headers = headers) + } else { + error("Unexpected URL: ${request.url.fullUrl}") + } + } +} + +private val Url.hostWithPortIfRequired: String get() = if (port == protocol.defaultPort) host else hostWithPort +private val Url.fullUrl: String get() = "${protocol.name}://$hostWithPortIfRequired$fullPath" diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/TimestampTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/TimestampTest.kt new file mode 100644 index 0000000..1a12549 --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/TimestampTest.kt @@ -0,0 +1,75 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals + +class TimestampTest { + + companion object { + fun getRandomTimestamp() = Timestamp(Random.nextLong(0, 9007199254740991)) + } + + @Test + fun testRoundedToByteArray() { + val vectors = listOf<Pair<Long, String>>( + Pair(0, "0000000000000"), + Pair(23, "0000000000000"), + Pair(3349, "000000005Q3C0"), + Pair(61227, "00000003MB4M0"), + Pair(143879, "00000008GR0W0"), + Pair(8879237, "000000GH7B4W0"), + Pair(16058145, "000000XX46H80"), + Pair(270909464, "00000FRKDX4M0"), + Pair(5500325225, "0000A054XBSM0"), + Pair(52631835363, "0002ZQJDTGYC0"), + Pair(567067373675, "00107FN9AKAM0"), + Pair(1036693403335, "001TXQFY0VEC0"), + Pair(88636710366804, "04XED7JKDJSR0"), + Pair(852207301364437, "1F9TC1M0SEJG0"), + Pair(8312646819781097, "EDE78FC4AEXM0"), + Pair(7473472692572260, "CYVHQMAQAR7G0"), + Pair(1148188526507363, "1ZQJYRD9M40C0"), + Pair(5418115526173127, "9CRG6QASJ80M0"), + Pair(4046218176532046, "70KGVZK7XCPG0"), + Pair(2421361923399585, "46D6FNS4VAFW0"), + Pair(7305555710693483, "CNH8RDJYNV1M0"), + Pair(2857858080018042, "4YMJDJ1XYFM80"), + Pair(2037218281967033, "3H2TEEYTJCCW0"), + Pair(7912348432268295, "DQ74XYJCEFXG0"), + Pair(6416777738213721, "B46FJPQRT81M0"), + Pair(6914097778740296, "BZSWYK0W3NTG0"), + Pair(7090360690428000, "C9K0AYTABAVG0"), + Pair(1998560202566445, "3EY4ZTJR5QER0"), + Pair(7896179665141956, "DPADV4EQCHKM0"), + Pair(6266851558322330, "AVW58JG1WB880"), + Pair(1878397422799871, "388PH07WY68W0"), + Pair(3767372253320333, "6H46A6MZSMS00"), + Pair(8065344266359580, "DZPXQR6KHZ5W0"), + Pair(7947440620360995, "DS5FP8RA25H00"), + Pair(3414000286898485, "5XGFEB1VMC880"), + Pair(9007199254740991, "FKZZZZZZY3EG0") + ) // TODO add more test vectors beyond 9007199254740991 (typescript max of wallet-core) + for (v in vectors) { + val encodedBytes = Base32Crockford.encode(Timestamp(v.first).roundedToByteArray()) + assertEquals(v.second, encodedBytes) + } + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/VersionTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/VersionTest.kt new file mode 100644 index 0000000..d445ebc --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/VersionTest.kt @@ -0,0 +1,57 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class VersionTest { + + @Test + fun testComparision() { + assertNull(compareVersions("0:0:0", "")) + assertNull(compareVersions("", "0:0:0")) + assertNull(compareVersions("foo", "0:0:0")) + assertNull(compareVersions("0:0:0", "foo")) + assertEquals( + VersionMatchResult(true, 0), + compareVersions("0:0:0", "0:0:0") + ) + assertEquals( + VersionMatchResult(true, -1), + compareVersions("0:0:0", "1:0:1") + ) + assertEquals( + VersionMatchResult(true, -1), + compareVersions("0:0:0", "1:5:1") + ) + assertEquals( + VersionMatchResult(false, -1), + compareVersions("0:0:0", "1:5:0") + ) + assertEquals( + VersionMatchResult(false, 1), + compareVersions("1:0:0", "0:5:0") + ) + assertEquals( + VersionMatchResult(true, 0), + compareVersions("1:0:1", "1:5:1") + ) + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/WalletApiTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/WalletApiTest.kt new file mode 100644 index 0000000..7971be5 --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/WalletApiTest.kt @@ -0,0 +1,93 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@Ignore // Live-Test which fails when test environment changes or is not available +class WalletApiTest { + + private val api = WalletApi() + + private val exchangeBaseUrl = "https://exchange.test.taler.net/" + private val withdrawUri = + "taler://withdraw/bank.test.taler.net/8adabbf8-fe61-4d31-a2d1-89350b5be8fa" + private val paytoUris = listOf("payto://x-taler-bank/bank.test.taler.net/Exchange") + private val tosEtag = "0" + + @Test + fun testGetVersions() { + api.getVersions() + } + + @Test + fun testGetWithdrawalDetails() = runCoroutine { + val detailsForUri = api.getWithdrawalDetailsForUri(withdrawUri) + assertEquals(Amount("TESTKUDOS", 5, 0), detailsForUri.amount) + assertEquals(exchangeBaseUrl, detailsForUri.defaultExchangeBaseUrl) + + val detailsForAmount = api.getWithdrawalDetailsForAmount( + detailsForUri.defaultExchangeBaseUrl!!, + detailsForUri.amount + ) + assertEquals(Amount("TESTKUDOS", 5, 0), detailsForAmount.amountRaw) + assertEquals(Amount("TESTKUDOS", 4, 80000000), detailsForAmount.amountEffective) + assertEquals(false, detailsForAmount.tosAccepted) + } + + @Test + fun testExchangeManagement() = runCoroutine { + // initially we have no exchanges + assertTrue(api.listExchanges().isEmpty()) + + // test exchange gets added correctly + val exchange = api.addExchange(exchangeBaseUrl) + assertEquals(exchangeBaseUrl, exchange.exchangeBaseUrl) + assertEquals("TESTKUDOS", exchange.currency) + assertEquals(paytoUris, exchange.paytoUris) + + // added exchange got added to list + val exchanges = api.listExchanges() + assertEquals(1, exchanges.size) + assertEquals(exchange, exchanges[0]) + + // ToS are not accepted + val tosResult = api.getExchangeTos(exchangeBaseUrl) + assertEquals(null, tosResult.acceptedEtag) + assertEquals(tosEtag, tosResult.currentEtag) + assertTrue(tosResult.tos.length > 100) + + // withdrawal details also show ToS as not accepted + val withdrawalDetails = + api.getWithdrawalDetailsForAmount(exchangeBaseUrl, Amount.zero("TESTKUDOS")) + assertEquals(false, withdrawalDetails.tosAccepted) + + // accept ToS + api.setExchangeTosAccepted(exchangeBaseUrl, tosResult.currentEtag) + val newTosResult = api.getExchangeTos(exchangeBaseUrl) + assertEquals(newTosResult.currentEtag, newTosResult.acceptedEtag) + + // withdrawal details now show ToS as accepted as well + val newWithdrawalDetails = + api.getWithdrawalDetailsForAmount(exchangeBaseUrl, Amount.zero("TESTKUDOS")) + assertEquals(true, newWithdrawalDetails.tosAccepted) + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/DepositTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/DepositTest.kt new file mode 100644 index 0000000..399b754 --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/DepositTest.kt @@ -0,0 +1,90 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.Timestamp +import net.taler.wallet.kotlin.crypto.Deposit.CoinDepositPermission +import net.taler.wallet.kotlin.crypto.Deposit.DepositInfo +import kotlin.test.Test +import kotlin.test.assertEquals + +class DepositTest { + + private val crypto = CryptoFactory.getCrypto() + private val deposit = Deposit(crypto) + + private class DepositVector(val depositInfo: DepositInfo, val permission: CoinDepositPermission) + + @Test + fun test() { + val vectors = listOf( + DepositVector( + DepositInfo( + exchangeBaseUrl = "example.org", + contractTermsHash = "QS3RK40J3262QRAJG4SVZB0C60N45W7D71ZXFTP385E05PKR7RGH22QTPA109Z0EBD120SHA09WHA72K4PCFM6JTFMCZEGVW2T9FT2R", + coinPublicKey = "6DMP3NPY6DZD0QDMEW6955R6ZCX3T052PRJE0FYHTKRA837VMQT0", + coinPrivateKey = "XWVF454Q600NGSMF7RYXY369N2DJKC1YQZG0RFAF8EMRRY8DJE50", + spendAmount = Amount("TESTKUDOS", 8, 0), + timestamp = Timestamp(1593522039000), + refundDeadline = Timestamp(1593522939000), + merchantPublicKey = "RQSR35MJJDP5Y27XMBEV7QBG4HTE3JXR1JCE4NCYCAQHKJ8CB5T0", + feeDeposit = Amount("TESTKUDOS", 0, 2000000), + wireInfoHash = "5C3WXB9PVPXAGGQVRJGAMBJJQBBYPHZEZZYVHQ8RS97F9EZABPAWNWSGD1VH6YNFB58GPJWX1A17DFNCK0S9YPP7PED3ZXR76E2A0XR", + denomPublicKey = "020000YQX8JZNBMJ8PTVRZZT27TTSJN5HM0037K0BSY3YWP0VF8GFYYAN06J0PTQVQGQJGVJ91CHWD6QCH9MKADR1VH31P5WEMTMWWTX6KWA3KX0FD1RWQSPJ8W8ZXVWPVDJVVJ08W12VWV39A9W4SDCMWBSQ17QV4RP86JJFMTEWB540KKYHY3ZYJEQYVN9XD7CM7SCAMM8WV03P0VG2001", + denomSignature = "9F3M4AZ8BN3MJRH6T49TD9R4EVTF6C5NH365DHCC73F39N3SAK422NY9ZWN7WANGW3M4XZTJDDV1B1E7MSW03VCESKSF8889EDTRE1VHF0FT3E5CT0Q449JAQQ6DSDY4D9JMPP99TRKZX86VAXN45FBSBXTJZ2FN85Y67T9ADDNDXMV060J0HP7G5YXXJQ0V7KHACEZFVXFH6" + ), + CoinDepositPermission( + coinSignature = "CZX4HMBR6H3W1KAN1T0HZ11VBSERZ6JM715MKSDAJJSXWQTZ0GDCZX92MAKMH0DB7B389E24XGP6X7ZSAQVW540KTQZJ9RMR0R9CM08", + coinPublicKey = "6DMP3NPY6DZD0QDMEW6955R6ZCX3T052PRJE0FYHTKRA837VMQT0", + denomSignature = "9F3M4AZ8BN3MJRH6T49TD9R4EVTF6C5NH365DHCC73F39N3SAK422NY9ZWN7WANGW3M4XZTJDDV1B1E7MSW03VCESKSF8889EDTRE1VHF0FT3E5CT0Q449JAQQ6DSDY4D9JMPP99TRKZX86VAXN45FBSBXTJZ2FN85Y67T9ADDNDXMV060J0HP7G5YXXJQ0V7KHACEZFVXFH6", + denomPublicKey = "020000YQX8JZNBMJ8PTVRZZT27TTSJN5HM0037K0BSY3YWP0VF8GFYYAN06J0PTQVQGQJGVJ91CHWD6QCH9MKADR1VH31P5WEMTMWWTX6KWA3KX0FD1RWQSPJ8W8ZXVWPVDJVVJ08W12VWV39A9W4SDCMWBSQ17QV4RP86JJFMTEWB540KKYHY3ZYJEQYVN9XD7CM7SCAMM8WV03P0VG2001", + contribution = "TESTKUDOS:8", + exchangeBaseUrl = "example.org" + ) + ), + DepositVector( + DepositInfo( + exchangeBaseUrl = "example.org", + contractTermsHash = "CWWDVCEX745A092KB3W7K98M7EVK4G5HJRHKR0RTPKAFR1VSK147ER131PT23P8ZWH2VMWAWENTVTAXP4KDRQ9YY0951N2G2JZFGEXG", + coinPublicKey = "ZR6BER43XSR3NK705HFKGC842Q3Q2R4G3T6VQ5JEK8EAC34JYW6G", + coinPrivateKey = "CP8WW83V239DTXD84M87TZ0DG1TGM4RC3D77NN936554B7GDVBRG", + spendAmount = Amount("TESTKUDOS", 1, 0), + timestamp = Timestamp(1593522635000), + refundDeadline = Timestamp(1593523535000), + merchantPublicKey = "S8WFGCK6CJGFWYWHAY56NTZTD84S31TGM244GTVKTCETKW5HDBQG", + feeDeposit = Amount("TESTKUDOS", 0, 2000000), + wireInfoHash = "8GQFCMQXHHTF7VG73WVJN8PFCPH4Y0ZRSWAQ01A6A9F0Y0HY59H3RDPMBPVNJJDJP1S3E8JJVE7MGAC9YDACWAVCTE75QZ8ZRNAH3X8", + denomPublicKey = "020000X1NCWC14MNXTAJ17W6HY5AMGXKGV392BESTESJJ8TQ41W9W2RBN69Q3WQYQQXS7KS5ZMDSSGHY0H7921X0RRA6ZNW2RSKJGXJNQM66KRDEFTQ50B6ZK60CSCY3KZ0RSGAYBG8VHC2A87Z6DQS361G8BAJS937J9YX89MGVMG896MKVEZ3H3NGRJTT89QNGN5KZWFSE6G5129GG2001", + denomSignature = "7XJDMABJHV01GX7S764ZY3XR3AX2KAK4AXWKZJHBRV8BBQ7KQ5FHC372GE8RF8M2NT128G85RWW87CNNVYNWWPEPSFN8QXTA2H6SGTR0EFRDF4CXAJRXHWPB450YMJM7MNNPJKXDDXCFN87RSHFZ4ESH06S0SBX6185DX2HD6JWQ3BESCK8PYCB6A09KP5ZD0EZSQKDGNGMEG" + ), + CoinDepositPermission( + coinSignature = "A8N1NSMSPZ4H1VR1YANYMAE74VAJ7W88EMRNDY74YXPK2WFEEHHM28VVQ3HAK6J0P9YX61XGNHRP08AEX59M0YGJ7AW8ZG2Y5FQD23G", + coinPublicKey = "ZR6BER43XSR3NK705HFKGC842Q3Q2R4G3T6VQ5JEK8EAC34JYW6G", + denomSignature = "7XJDMABJHV01GX7S764ZY3XR3AX2KAK4AXWKZJHBRV8BBQ7KQ5FHC372GE8RF8M2NT128G85RWW87CNNVYNWWPEPSFN8QXTA2H6SGTR0EFRDF4CXAJRXHWPB450YMJM7MNNPJKXDDXCFN87RSHFZ4ESH06S0SBX6185DX2HD6JWQ3BESCK8PYCB6A09KP5ZD0EZSQKDGNGMEG", + denomPublicKey = "020000X1NCWC14MNXTAJ17W6HY5AMGXKGV392BESTESJJ8TQ41W9W2RBN69Q3WQYQQXS7KS5ZMDSSGHY0H7921X0RRA6ZNW2RSKJGXJNQM66KRDEFTQ50B6ZK60CSCY3KZ0RSGAYBG8VHC2A87Z6DQS361G8BAJS937J9YX89MGVMG896MKVEZ3H3NGRJTT89QNGN5KZWFSE6G5129GG2001", + contribution = "TESTKUDOS:1", + exchangeBaseUrl = "example.org" + ) + ) + ) + for (v in vectors) { + assertEquals(v.permission, deposit.signDepositPermission(v.depositInfo)) + } + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/EllipticCurveTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/EllipticCurveTest.kt new file mode 100644 index 0000000..4e83b47 --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/EllipticCurveTest.kt @@ -0,0 +1,156 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.Base32Crockford +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EllipticCurveTest { + + private val crypto = CryptoFactory.getCrypto() + + @Test + fun testExchangeTvgEddsaKey() { + val pri = "9TM70AKDTS57AWY9JK2J4TMBTMW6K62WHHGZWYDG0VM5ABPZKD40" + val pub = "8GSJZ649T2PXMKZC01Y4ANNBE7MF14QVK9SQEC4E46ZHKCVG8AS0" + val pubBytes = crypto.eddsaGetPublic(Base32Crockford.decode(pri)) + assertEquals(pub, Base32Crockford.encode(pubBytes)) + + val pri2 = "C9C5X5JS2SWRTSX5AQBPFA4S2DFD6V9Q04XTTEZDQ5XSSQK2P39G" + val pub2 = "6WC3MPYM5XKPKRA2Z6SYB81CPFV3E7EC6S2GVE095X8XH63QTZCG" + val pub2Bytes = crypto.eddsaGetPublic(Base32Crockford.decode(pri2)) + assertEquals(pub2, Base32Crockford.encode(pub2Bytes)) + } + + @Test + fun testExchangeTvgEcdheKey() { + val pri = "X4T4N0M8PVQXQEBW2BA7049KFSM7J437NSDFC6GDNM3N5J9367A0" + val pub = "M997P494MS6A95G1P0QYWW2VNPSHSX5Q6JBY5B9YMNYWP0B50X3G" + val pubBytes = crypto.ecdheGetPublic(Base32Crockford.decode(pri)) + assertEquals(pub, Base32Crockford.encode(pubBytes)) + + val pri2 = "MB8ZSQFTVPX4V0MGT3BA1PQS12NJ7KH33DA8D22NCWTNA5BHH2YG" + val pub2 = "8T2CN1W8G3XZE9C9158A5ASS74117GK1XQ1XAX5SGBFDGHS8H5W0" + val pub2Bytes = crypto.ecdheGetPublic(Base32Crockford.decode(pri2)) + assertEquals(pub2, Base32Crockford.encode(pub2Bytes)) + } + + @Test + fun testCreateEddsaKeyPair() { + val pair1 = crypto.createEddsaKeyPair() + val pair2 = crypto.createEddsaKeyPair() + assertFalse(pair1.privateKey contentEquals pair2.privateKey) + assertFalse(pair1.publicKey contentEquals pair2.publicKey) + } + + @Test + fun testCreateEcdheKeyPair() { + val pair1 = crypto.createEcdheKeyPair() + val pair2 = crypto.createEcdheKeyPair() + assertFalse(pair1.privateKey contentEquals pair2.privateKey) + assertFalse(pair1.publicKey contentEquals pair2.publicKey) + } + + @Test + @ExperimentalStdlibApi + fun testEddsaSignAndVerify() { + val msg = "Hallo world!".encodeToByteArray() + val pri = "9TM70AKDTS57AWY9JK2J4TMBTMW6K62WHHGZWYDG0VM5ABPZKD40" + val expectedSig = + "Z6H76JXPJFP3JBGSF54XBF0BVXDJ0CJBK4YT9GVR1AT916ZD57KP53YZN5G67A4YN95WGMZKQW7744483P5JDF06B6S7TMK195QGP20" + val sig = crypto.eddsaSign(msg, Base32Crockford.decode(pri)) + assertEquals(expectedSig, Base32Crockford.encode(sig)) + + val pub = crypto.eddsaGetPublic(Base32Crockford.decode(pri)) + assertTrue(crypto.eddsaVerify(msg, sig, pub)) + + val wrongSig = Random.nextBytes(64) + assertFalse(crypto.eddsaVerify(msg, wrongSig, pub)) + + val wrongPub = Random.nextBytes(32) + assertFalse(crypto.eddsaVerify(msg, sig, wrongPub)) + + val wrongMsg = Random.nextBytes(16) + assertFalse(crypto.eddsaVerify(wrongMsg, sig, pub)) + + val pri2 = "T4NK2VVZZ1ZF6EBGEQHRK7KY67PCEVAZC5YHYH612XG5R6NJXSB0" + val pub2 = "2X3PSPT7D6TEM97R98C0DHZREFVAVA3XTH11D5A2Z2K7GBKQ7AEG" + val data2 = + "00000J00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + val expectedSig2 = + "RFC59ZWJB88TD03F4GTQD3RWKH3KKRVEY2T1VN7CFEF51SCZ4BX8HGHC708GW1SN7Y4XFEDE3A5PFZEJ7PR7V09YZXX10DM608T1W20" + val sig2 = crypto.eddsaSign(Base32Crockford.decode(data2), Base32Crockford.decode(pri2)) + assertEquals(expectedSig2, Base32Crockford.encode(sig2)) + assertTrue(crypto.eddsaVerify(Base32Crockford.decode(data2), sig2, Base32Crockford.decode(pub2))) + } + + @Test + fun testExchangeTvgEddsaEcdh() { + val ecdhePrivateKey = "4AFZWMSGTVCHZPQ0R81NWXDCK4N58G7SDBBE5KXE080Y50370JJG" + val ecdhePublicKey = "FXFN5GPAFTKVPWJDPVXQ87167S8T82T5ZV8CDYC0NH2AE14X0M30" + val ecdhePublicKeyBytes = crypto.ecdheGetPublic(Base32Crockford.decode(ecdhePrivateKey)) + assertEquals(ecdhePublicKey, Base32Crockford.encode(ecdhePublicKeyBytes)) + + val eddsaPrivateKey = "1KG54M8T3X8BSFSZXCR3SQBSR7Y9P53NX61M864S7TEVMJ2XVPF0" + val eddsaPublicKey = "7BXWKG6N224C57RTDV8XEAHR108HG78NMA995BE8QAT5GC1S7E80" + val eddsaPublicKeyBytes = crypto.eddsaGetPublic(Base32Crockford.decode(eddsaPrivateKey)) + assertEquals(eddsaPublicKey, Base32Crockford.encode(eddsaPublicKeyBytes)) + + val keyMaterial = + "PKZ42Z56SVK2796HG1QYBRJ6ZQM2T9QGA3JA4AAZ8G7CWK9FPX175Q9JE5P0ZAX3HWWPHAQV4DPCK10R9X3SAXHRV0WF06BHEC2ZTKR" + val keyMaterial1Bytes = crypto.keyExchangeEddsaEcdhe( + Base32Crockford.decode(eddsaPrivateKey), + Base32Crockford.decode(ecdhePublicKey) + ) + assertEquals(keyMaterial, Base32Crockford.encode(keyMaterial1Bytes)) + + val keyMaterial2Bytes = crypto.keyExchangeEcdheEddsa( + Base32Crockford.decode(ecdhePrivateKey), + Base32Crockford.decode(eddsaPublicKey) + ) + assertEquals(keyMaterial, Base32Crockford.encode(keyMaterial2Bytes)) + + val ecdhePrivateKey2 = "3AMRJ1KC87VWX9MVQMW8MB9YVX5DMS6P5V2SYZNCZ44XVMSDVEFG" + val ecdhePublicKey2 = "CFFVCRFTNP7701KZC7187BC42MGSVCMBWK38F23E7T9VVK9D41Q0" + val ecdhePublicKeyBytes2 = crypto.ecdheGetPublic(Base32Crockford.decode(ecdhePrivateKey2)) + assertEquals(ecdhePublicKey2, Base32Crockford.encode(ecdhePublicKeyBytes2)) + + val eddsaPrivateKey2 = "8AZTVPCXNCVBKJVQ6XS7Z38KB9JB38WJ7Z80F9FTCX1WHZACTXJ0" + val eddsaPublicKey2 = "T3F1VYAVZX3DVCNJTB3BNQTRZGXN446QP2VD847CB6N5V75RT2B0" + val eddsaPublicKeyBytes2 = crypto.eddsaGetPublic(Base32Crockford.decode(eddsaPrivateKey2)) + assertEquals(eddsaPublicKey2, Base32Crockford.encode(eddsaPublicKeyBytes2)) + + val keyMaterial2 = + "FPQSHC6MWWJGEPXXVF76Q8C4PBJFE59RSGRGFQ3Z6ESR67HMG7FAK6JTCQ4ZKHSVNNCF53DX8FY55EA2193N2A6KD510AEV5TBVZRJR" + val keyMaterial1Bytes2 = crypto.keyExchangeEddsaEcdhe( + Base32Crockford.decode(eddsaPrivateKey2), + Base32Crockford.decode(ecdhePublicKey2) + ) + assertEquals(keyMaterial2, Base32Crockford.encode(keyMaterial1Bytes2)) + + val keyMaterial2Bytes2 = crypto.keyExchangeEcdheEddsa( + Base32Crockford.decode(ecdhePrivateKey2), + Base32Crockford.decode(eddsaPublicKey2) + ) + assertEquals(keyMaterial2, Base32Crockford.encode(keyMaterial2Bytes2)) + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/KdfTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/KdfTest.kt new file mode 100644 index 0000000..974f9aa --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/KdfTest.kt @@ -0,0 +1,213 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.Base32Crockford +import net.taler.wallet.kotlin.crypto.Kdf.HMAC_SHA256_BLOCK_SIZE +import net.taler.wallet.kotlin.crypto.Kdf.HMAC_SHA512_BLOCK_SIZE +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class KdfTest { + + private val crypto = CryptoFactory.getCrypto() + + @Test + fun testHmacSha256() { + // key smaller than block size + val key1 = "EDJP6WK5EG" + val message1 = "91JPRV3F41BPYWKCCGGG" + val expectedOut1 = "DYKV9QN2HVHMHQRGZ6XNJPPSGQZHA2JAVZB1676ACXYSNKQ0FQ30" + val out1 = Kdf.hmacSha256(Base32Crockford.decode(key1), Base32Crockford.decode(message1)) { crypto.sha256(it) } + assertTrue(Base32Crockford.decode(key1).size < HMAC_SHA256_BLOCK_SIZE) + assertEquals(expectedOut1, Base32Crockford.encode(out1)) + + // key bigger than block size + val key2 = + "AHM6JWS089GQ6S9K68G7CRBJD5GPWX10EXGQ6833E9JP2X35CGG64Y908HQQASVCC5SJ0GVJDXHPPSKFE9J20X3F41GQCVV9CGG66VVECSTQ6TBFDRQ0" + val message2 = "89P62RVB4166JXK5ECG4TRBMEHJQ488" + val expectedOut2 = "QGEK8853DW3GSQ4RHDYXZ0KX9R6E356R5495Z10KDNFNZ98V9FQG" + val out2 = Kdf.hmacSha256(Base32Crockford.decode(key2), Base32Crockford.decode(message2)) { crypto.sha256(it) } + assertTrue(Base32Crockford.decode(key2).size > HMAC_SHA256_BLOCK_SIZE) + assertEquals(expectedOut2, Base32Crockford.encode(out2)) + + // key exactly block size + val key3 = + "AHM6JWS0EDJP6WK5EGG6PSBS41MQ6831ECG6RVVECWG62WS0EHM6A832DHQP6TS0EDMQMS90DXK20J2D851J0MT884S3ADH07MG3CD0" + val message3 = "89P62RVB4166JXK5ECG4TRBMEHJQ488" + val expectedOut3 = "HAEWRWXEWWPRMT4W6E32GS0P6QMW85MMZZA5CX2BMJW36QZQFJ2G" + val out3 = Kdf.hmacSha256(Base32Crockford.decode(key3), Base32Crockford.decode(message3)) { crypto.sha256(it) } + assertEquals(HMAC_SHA256_BLOCK_SIZE, Base32Crockford.decode(key3).size) + assertEquals(expectedOut3, Base32Crockford.encode(out3)) + } + + @Test + fun testHmacSha512() { + // key smaller than block size + val key1 = "EDJP6WK5EG" + val message1 = "91JPRV3F41BPYWKCCGGG" + val expectedOut1 = + "ZMVHDTWE341YSE6YQ4S0Y6XESW7RVFG4WK2CE7K4ZESQ10125JJ2VTRBCHPAQTTKW9MT4H350ZXN8GMP2SBFG03SB6YK63QMHQRE2FG" + val out1 = Kdf.hmacSha512(Base32Crockford.decode(key1), Base32Crockford.decode(message1)) { crypto.sha512(it) } + assertTrue(Base32Crockford.decode(key1).size < HMAC_SHA512_BLOCK_SIZE) + assertEquals(expectedOut1, Base32Crockford.encode(out1)) + + // key bigger than block size + val key2 = + "AHM6JWS089GQ6S9K68G7CRBJD5GPWX10EXGQ6833E9JP2X35CGG64Y908HQQASVCC5SJ0GVJDXHPPSKFE9J2W829EGG6AY33DHTP8SBK414JR82C5GG4Y83MDWG62XKFD5J20RVFDSK7AWV9DXQ20XV9EHM20S39CXMQ8WS0C5Q6882N41T6Y83GE9JQCSBEEGG62RV3D5J6AVKMC5P20VV2EDHPAVK9EHWJW" + val message2 = "89P62RVB4166JXK5ECG4TRBMEHJQ488" + val expectedOut2 = + "0N9GF90FES6HXS344WB396CF78SQDRZ7CS9MRAVCVZPJ3PJ97HYSB5A3C7RCXTTYYSS4CV11X3DANKQD71YMTA46R3NS1X8A99YVAE0" + val out2 = Kdf.hmacSha512(Base32Crockford.decode(key2), Base32Crockford.decode(message2)) { crypto.sha512(it) } + assertTrue(Base32Crockford.decode(key2).size > HMAC_SHA512_BLOCK_SIZE) + assertEquals(expectedOut2, Base32Crockford.encode(out2)) + + // key exactly block size + val key3 = + "AHM6JWS0ESJQ4Y90EDJP6WK5EGG6PSBS41MQ6835F1GP6X3CF4G62WS0DHQPWSS0C5SJ0X38CMG64V3FCDNJ0WV9F9JJ0VV641T6GS90916M2GS0AD44281N64S20RBCCXQQ4TBMD1PJ0XV8D5HPG839ECG32CHR41H7JX35ECG62VK441GJ0V3FEGG6YSH0EHJQGX1E5RQ2W" + val message3 = "89P62RVB4166JXK5ECG4TRBMEHJQ488" + val expectedOut3 = + "A3ZAKHA0EADSBP832KBCPDNVB4GP876PMQEEB2F55ERTQFQ7KMYNTJ3SEWJEQRNH3EV2H0HSA740JE4JQH7GM8FC708KHF4A5FG9QNG" + val out3 = Kdf.hmacSha512(Base32Crockford.decode(key3), Base32Crockford.decode(message3)) { crypto.sha512(it) } + assertEquals(HMAC_SHA512_BLOCK_SIZE, Base32Crockford.decode(key3).size) + assertEquals(expectedOut3, Base32Crockford.encode(out3)) + } + + @Test + fun testRfc4231() { + val key1 = ByteArray(20) { 0x0b } + val data1 = "91MJ0N38CNS6A" + assertEquals( + "P0T4RRERVCW56Q58NZ7AY2ZH5E41VGG0S61KV9S6X4VPRBHJSZVG", + Base32Crockford.encode(Kdf.hmacSha256(key1, Base32Crockford.decode(data1)) { crypto.sha256(it) }) + ) + assertEquals( + "GYN7SQN5XXGSTKZGPGJ1M7BCP0HQKX72SS7C4Y3TT2SGAHF1FKFDNA1KPZBBH9R20E5JEKNEMFTE9FMXJ57EPRFHE0Q6JV107896GN0", + Base32Crockford.encode(Kdf.hmacSha512(key1, Base32Crockford.decode(data1)) { crypto.sha512(it) }) + ) + + val key2 = Base32Crockford.decode("99JPCS8") + val data2 = "EXM62X10CHQJ0YB141VP2VKM41K6YWH0DSQQ8T39DSKKY" + assertEquals( + "BFEC2HNZC1TMWTG44GK0H5BNRXD00FR8KMKKK0WXXHCBJS7C711G", + Base32Crockford.encode(Kdf.hmacSha256(key2, Base32Crockford.decode(data2)) { crypto.sha256(it) }) + ) + assertEquals( + "2S5QMYZWZ0CY5RWNZFKKPNQ0ME3VTS125T1HZNGG4W6DFTH50NA9EP5ZEQ05N6AADM1MYSFRY3KFVJQAP6HMTJKB9DHPW1RA72YEEDR", + Base32Crockford.encode(Kdf.hmacSha512(key2, Base32Crockford.decode(data2)) { crypto.sha512(it) }) + ) + + val key3 = ByteArray(20) { 0xaa.toByte() } + val data3 = "VQEXVQEXVQEXVQEXVQEXVQEXVQEXVQEXVQEXVQEXVQEXVQEXVQEXVQEXVQEXVQEXVQEXVQEXVQEXVQEX" + assertEquals( + "EWZAJ7HPG074D1ADQ3NX14C1MWMNJ2CB7VWC28PSCDAH9KPNCQZ0", + Base32Crockford.encode(Kdf.hmacSha256(key3, Base32Crockford.decode(data3)) { crypto.sha256(it) }) + ) + assertEquals( + "Z9SV024XATH89VXGY1TPS28BX6RVBPYXHVM1MDJNZ0Z37CH7KMWVYFM4G9WTE8P80TT8B93YCZ40FEA6MCVVXT4M4ST2F22SW4S95YR", + Base32Crockford.encode(Kdf.hmacSha512(key3, Base32Crockford.decode(data3)) { crypto.sha512(it) }) + ) + + val key4 = Base32Crockford.decode("041061050R3GG28A1C60T3GF208H44RM2MB1E60S") + val data4 = "SQ6WVKEDSQ6WVKEDSQ6WVKEDSQ6WVKEDSQ6WVKEDSQ6WVKEDSQ6WVKEDSQ6WVKEDSQ6WVKEDSQ6WVKED" + assertEquals( + "G9ARME4T8GY0X96CG6C9KWG87A2Z1YN3WNWFG1VT5RZZ8SS9CSDG", + Base32Crockford.encode(Kdf.hmacSha256(key4, Base32Crockford.decode(data4)) { crypto.sha256(it) }) + ) + assertEquals( + "P2X4CNHQ8P66K475N32ZC7AAYZJQDPBZZ55REBF7DY050DGYWFDTJ755R4DA4QNMTSWJEQ65F20679FHJX0H432F5QHAVTZB22H9HQ8", + Base32Crockford.encode(Kdf.hmacSha512(key4, Base32Crockford.decode(data4)) { crypto.sha512(it) }) + ) + + val key5 = ByteArray(20) { 0x0c.toByte() } + val data5 = "AHJQ6X10AXMQ8T10AHS7AVK3C5T6JVVE" + assertEquals( + "MEV1CX3K207E0VGCF5P2JNAN5C", + Base32Crockford.encode(Kdf.hmacSha256(key5, Base32Crockford.decode(data5)) { crypto.sha256(it) } + .copyOfRange(0, 16)) + ) + assertEquals( + "85FTTRKHB05567A1F6Y8J7C7MR", + Base32Crockford.encode(Kdf.hmacSha512(key5, Base32Crockford.decode(data5)) { crypto.sha512(it) } + .copyOfRange(0, 16)) + ) + + val key6 = ByteArray(131) { 0xaa.toByte() } + val data6 = "AHJQ6X10ANSPJVK741662WK7CNS20N38C5Q20GKCDXHPPBAKD5X6A82BCNWJ0B9091GQ6T109DJQJ826D5S76X0" + assertEquals( + "C3J32P8YW2V7Y3CA4TNCQXDQFY70QHH16WMCA5058R20Y3Q3FXA0", + Base32Crockford.encode(Kdf.hmacSha256(key6, Base32Crockford.decode(data6)) { crypto.sha256(it) }) + ) + assertEquals( + "G2S44RY7R6HYQDRMJF0XTYZ8PJDMDMFM3D5EXG8J3C0KF0ZRYD96PNPG6ZG5Y9CRQM7X48AXD8F555F69XSZCFRAXJ5S2PMRBNW6B60", + Base32Crockford.encode(Kdf.hmacSha512(key6, Base32Crockford.decode(data6)) { crypto.sha512(it) }) + ) + + val key7 = ByteArray(131) { 0xaa.toByte() } + val data7 = + "AHM6JWS0D5SJ0R90EHJQ6X10ENSPJVK741GJ0V31E9KPAWH0EHM62VH0C9P6YRVB5NSPJYK541NPAY90C5Q6883141P62WK7CNS20X38C5Q20RKCDXHPPBBKD5X6A834C5T62BH0AHM6A83BCNWJ0VK5CNJ7683MDWG64S90D1GQ6T35CGG64SB6DXS6A832CNMPWSS0ENSPAS10C9WJ0X38CMG4GKA18CG62V37DXS6JX38DMQ0" + assertEquals( + "KC4ZZ9RVJGQWP9V3BYYDBC798JZXRRV49W3H74WAFX8N6Q1T6QH0", + Base32Crockford.encode(Kdf.hmacSha256(key7, Base32Crockford.decode(data7)) { crypto.sha256(it) }) + ) + assertEquals( + "WDXPMXTXS1YVN96ZN7WPWQHZZQFBTWFRGSS8K1JXYPHJT86DS52BC0HCNGY4K0NH1NFEPNE3WKF1A4T6EVXPVR24C1JWJX20ZA66MP0", + Base32Crockford.encode(Kdf.hmacSha512(key7, Base32Crockford.decode(data7)) { crypto.sha512(it) }) + ) + } + + @Test + fun testKdf() { + val salt = "94KPT83PCNS7J83KC5P78Y8" + val ikm = "94KPT83MD1JJ0WV5CDS6AX10D5Q70XBM41NPAY90DNGQ8SBJD5GPR" + val ctx = "94KPT83141HPYVKMCNW78833D1TPWTSC41GPRWVF41NPWVVQDRG62WS04XMPWSKF4WG6JVH0EHM6A82J8S1G" + val expectedOut = + "GTMR4QT05Z9WF5HKVG0WK9RPXGHSMHJNW377G9GJXCA8B0FEKPF4D27RJMSJZYWSQNTBJ5EYVV7ZW18B48Z0JVJJ80RHB706Y96Q358" + val out = + crypto.kdf(64, Base32Crockford.decode(ikm), Base32Crockford.decode(salt), Base32Crockford.decode(ctx)) + assertEquals(expectedOut, Base32Crockford.encode(out)) + + val salt2 = "94KPT83141V6AWKS41SP2V3MF4G76VK1CDNG" + val ikm2 = + "94KPT83MD1JJ0WV5CDS6AX10D5Q70XBM41NPAY90DNGQ8SBJD5GPR83MD1GQ8838C5SJ0RK5CNQ20RV8C5Q6ESB441K6YWH08X75A82MC5P6AWG" + val ctx2 = + "94KPT83141HPYVKMCNW78833D1TPWTSC41GPRWVF41NPWVVQDRG62WS04XMPWSKF4WG6JVH0EHM6A82J8S1J0WVF41VPGY90DSQQ8833C5P6R83DCMG6JVK6DWZG" + val expectedOut2 = + "C6TXT6GMVZ8JNWZF27SPD6939FK035Y3FTWXYT0W4EZX9FNJ5KH24MQSK9D0MW3G7T1BBZ4ERVQC1RE24BNDPJQ68ZRQ8S3XN4GNHC8" + val out2 = + crypto.kdf(64, Base32Crockford.decode(ikm2), Base32Crockford.decode(salt2), Base32Crockford.decode(ctx2)) + assertEquals(expectedOut2, Base32Crockford.encode(out2)) + + val salt3 = "C4G76RBCEG" + val ikm3 = "ESJQ4Y90EDJP6WK5EGG6PSBS" + val ctx3 = "D5Q6CVR" + val expectedOut3 = "ZYMG67TD51XYS0ZM3QZKH8HF8FW7CTVT91NAWD0PGZM2QGTNYKMXAA4ZJBH5V633TJW9E124CRYEY" + val out3 = + crypto.kdf(48, Base32Crockford.decode(ikm3), Base32Crockford.decode(salt3), Base32Crockford.decode(ctx3)) + assertEquals(expectedOut3, Base32Crockford.encode(out3)) + + val salt4 = "84G76XV5CNT20WV1DHT20EH9" + val ikm4 = "9NWJ0SBREHS6AVB5DHWJ0WV5CDS6AX10DDJQJ" + val ctx4 = "CDQPWX35F1T20TBECSQKY88" + val expectedOut4 = "YM46SN0HYJ0RNGY1V5S7WY1SD2RQ1Y17G5Z37JD46E2Z8KY11PH5EV772RQ5NQ9SBMGG" + val out4 = + crypto.kdf(42, Base32Crockford.decode(ikm4), Base32Crockford.decode(salt4), Base32Crockford.decode(ctx4)) + assertEquals(expectedOut4, Base32Crockford.encode(out4)) + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/RecoupTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/RecoupTest.kt new file mode 100644 index 0000000..ea74e3c --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/RecoupTest.kt @@ -0,0 +1,90 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.CoinRecord +import net.taler.wallet.kotlin.CoinSourceType.REFRESH +import net.taler.wallet.kotlin.CoinSourceType.WITHDRAW +import net.taler.wallet.kotlin.CoinStatus.FRESH +import net.taler.wallet.kotlin.crypto.Recoup.Request +import kotlin.test.Test +import kotlin.test.assertEquals + +class RecoupTest { + + private val crypto = CryptoFactory.getCrypto() + private val recoup = Recoup(crypto) + + private class RecoupRequestVector(val record: CoinRecord, val request: Request) + + @Test + fun test() { + val vectors = listOf( + RecoupRequestVector( + CoinRecord( + coinSource = WITHDRAW, + coinPub = "9YW99NYH54FWG87TP3SKCGR9MRWYSVR75X42FN4YAJC9579CQBJ0", + coinPriv = "EPPYWTDVWM4CXW75J8AAGWW620C7DCC3B45TM31KHKPYMT9VM7DG", + denomPub = "020000X3T40FNGSM3Y1QFKX9H4JY5EP70Y2CKDHD29B5BEZCTWRMT6AC9SA0G5YJ1XVYY580K6S93SFCKM5PFKP96H3KXDNP58EVQPTYDB5S0QY4V8B873NYA7EYRH25NJ8MR2VP6F7WWVMBK3NR3FSFP17PHPGF279NBSRTXWZSJZFX6RCTR6VS5WMSYFHZCR0P8R6MGHDCB3QW4M3G2001", + denomPubHash = "DG3114X57XKHQ1XM6AN0P7D2B6J96SVFG09S3SF43ZXCYYK9PGX84XP3ZY7WY3QD9JE1BWS2T8DGR78QXZZAVGED79HES10HAPTWBX8", + denomSig = "AHE8DGMTTKNWGCVQYTV56CBWA81DH10BQEBAM0A5YGRAZXRPVHMZ5FH0XW1523QXSTXT3WMS1X7FDMEZ3BR898YEDTXDTHEMX6RS11KCPBAZCGTNPHKYF6RH9414Q0PYT5BZKGKWJNAFPWQS715NXEFZBY1D6RPTAN520REJ4RTREC9PP5D8WVQ3B66Q4ARYQ3CK49K0ZDME0", + currentAmount = Amount("TESTKUDOS", 0, 0), + exchangeBaseUrl = "example.org", + suspended = false, + blindingKey = "1Y29A3ABERGYJR8Y9HS7XS8AYYDAKV6BZSXMZ0WS5VDTS150C100", + status = FRESH + ), + Request( + denomPubHash = "DG3114X57XKHQ1XM6AN0P7D2B6J96SVFG09S3SF43ZXCYYK9PGX84XP3ZY7WY3QD9JE1BWS2T8DGR78QXZZAVGED79HES10HAPTWBX8", + denomSig = "AHE8DGMTTKNWGCVQYTV56CBWA81DH10BQEBAM0A5YGRAZXRPVHMZ5FH0XW1523QXSTXT3WMS1X7FDMEZ3BR898YEDTXDTHEMX6RS11KCPBAZCGTNPHKYF6RH9414Q0PYT5BZKGKWJNAFPWQS715NXEFZBY1D6RPTAN520REJ4RTREC9PP5D8WVQ3B66Q4ARYQ3CK49K0ZDME0", + coinPub = "9YW99NYH54FWG87TP3SKCGR9MRWYSVR75X42FN4YAJC9579CQBJ0", + coinBlindKeySecret = "1Y29A3ABERGYJR8Y9HS7XS8AYYDAKV6BZSXMZ0WS5VDTS150C100", + coinSig = "GBN5MVEY6JATGGSTX5YF32G3G204Y1PF9ASVXQFN895DWN5ZK3CBY2NHC8ATB1E9JWSV1QD4ECM0XHP8Y6DFZ1S02MYD5NBKZ45B018", + refreshed = false + ) + ), + RecoupRequestVector( + CoinRecord( + coinSource = REFRESH, + coinPub = "2YE003173JB6WNQ9HS73Z468F11KDHWWVGCPDHDTD6AY5AVJPQPG", + coinPriv = "GCR4R26XTCFNS109ZYC0G6M374K1ACNES1YH2CESWD86JBAE22WG", + denomPub = "020000X9M8MQVNH28D4J4YFA5ZZNKGNR0423BXQZV00RRN754XTDQMS5YKWQ3KSN8NV4V7CHDM22CRJ4WWQW05FDZC7VN0KK4S8VK9PYPPXNW6FJKHBSEZ2X1FCJKRC3T6PK2BKQ422Y2ASE76ZZAH6RRQT4SQGZTV3TRTSBC5AECJ5Z6C4RX7XFBERKVB45DA7H3V53YCYX1C41ZY5G2001", + denomPubHash = "J0G3G880JJJD09923AAWNQQZHJVRQT71ZK8KZGYW7T1P18PCPZ72FBAKDW3EFZ3QFZEW72EYJ9K0FG3RFZTFADQKZDDN9YT6BT2PE70", + denomSig = "8HVKAGMKRQRWB1HX9WCPX3FJ0SVE24DCAWQSHX4ZMXZ1KFZDNF4F0Z4K6ZCW142B2WDEH0W848W8WKH8P6A6EJR7J635QEF78CSJFF0EX1FRS5VY484GEX0HH3BDRDFGTHXNQRTTF1DD5ETMEG1QNKA3SAB24XZXZNQ6RDGTK02MRETP859NGMDD2F94F58JH4HYGXMAY0X32", + currentAmount = Amount("TESTKUDOS", 0, 0), + exchangeBaseUrl = "example.org", + suspended = false, + blindingKey = "C5VPT5F925ADJWK48PR07KV2W66EZQN4KYE146NY77DFM8GFCTXG", + status = FRESH + ), + Request( + denomPubHash = "J0G3G880JJJD09923AAWNQQZHJVRQT71ZK8KZGYW7T1P18PCPZ72FBAKDW3EFZ3QFZEW72EYJ9K0FG3RFZTFADQKZDDN9YT6BT2PE70", + denomSig = "8HVKAGMKRQRWB1HX9WCPX3FJ0SVE24DCAWQSHX4ZMXZ1KFZDNF4F0Z4K6ZCW142B2WDEH0W848W8WKH8P6A6EJR7J635QEF78CSJFF0EX1FRS5VY484GEX0HH3BDRDFGTHXNQRTTF1DD5ETMEG1QNKA3SAB24XZXZNQ6RDGTK02MRETP859NGMDD2F94F58JH4HYGXMAY0X32", + coinPub = "2YE003173JB6WNQ9HS73Z468F11KDHWWVGCPDHDTD6AY5AVJPQPG", + coinBlindKeySecret = "C5VPT5F925ADJWK48PR07KV2W66EZQN4KYE146NY77DFM8GFCTXG", + coinSig = "HGPAWTM2ZXVBZWYVSPS6S9DMSQWSVEJCQ78BN6WG2VND3PA7BQNHVE6142CGYX0VA82G5YP9SAV5YDNPNQJH2FTY5M6VM92QF6CB228", + refreshed = true + ) + ) + ) + for (v in vectors) { + assertEquals(v.request, recoup.createRequest(v.record)) + } + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/RefreshPlanchetTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/RefreshPlanchetTest.kt new file mode 100644 index 0000000..51eb5c6 --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/RefreshPlanchetTest.kt @@ -0,0 +1,112 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.Base32Crockford +import kotlin.test.Test +import kotlin.test.assertEquals + +class RefreshPlanchetTest { + + private val crypto = CryptoFactory.getCrypto() + + private val vectors = listOf( + TestVector( + "A9M953G6CJWBD21S82784Q3ZFEZYYWWKB9M6K4WQTTJZ8VTHPTTSHN8W3RMJQCCEKAFM04QD1PAQEN80DR6K0MNKYP404KMYW494D6R", + 27, + FreshCoin( + Base32Crockford.decode("JR124F1W9Q984Y01Y323CN88MVCJN7D8BJHYHCST5NWJTNCJMCQG"), + Base32Crockford.decode("YQS09P0FGBWS2FYY4NE5EDB93MKAYATNT8GZ33GSM34KF9PW6YWG"), + Base32Crockford.decode("KMWCX1W2HDKQM55ZRDZ630JE75YNDP8S8X8J02F9VSVZMNBWQ70G") + ) + ), + TestVector( + "MRQ6KTFKJ5JMQS8QV5Z189W110V7F6BJJQMDHVKY17HDMTY7MYSB1MKRJY0ZGF0SC1TSZQNT7NJPJVJE17VBSBH05MJ26SQVPXCM9R8", + 65, + FreshCoin( + Base32Crockford.decode("W6BVA4JQKDJ294HFBDQGZK0VGGKRRXG820WWPJ1Y2TJ9SQYEXZWG"), + Base32Crockford.decode("FKH9KW7F73V8F4KEYME899RTKED2DVRM3A74MJJ5YT3P3CCGFS60"), + Base32Crockford.decode("BNDT4TPMV2R7HJVJAA8ZPF9J3Q0V1ZHY1JQ76723CBBQ99RX0GR0") + ) + ), + TestVector( + "SCHMBND9ZMHDHTWQTSF1M41W4WPF9GM3FR5PJHC5JTZM25XEEC7CWC5SEN8FEWM54MSH27B9DJG91AKZ2YZ7ZMERG14QTBYJC02K21G", + 89, + FreshCoin( + Base32Crockford.decode("J75FF26KDZPBQW960SBG81W24BAY0YA04ZJ6PKQ1V0NQ7K62XNC0"), + Base32Crockford.decode("QS7BMWNG7SAEX7Z4H6QF66J5Q8GAX7JWHD4JTM9A656N43RH66C0"), + Base32Crockford.decode("JTNY89C0Y69X0NGMK2KB2JGGQZXX41GASPBN1YRS7KKV0MTP84CG") + ) + ), + TestVector( + "S8DC2D6N1TW1TJQFRVDM5Q2HZ9G66MWVS4651GB2ST49TPFK2KWCRSADH51YNCMNGWK4JFT2SKQNWVA17XAR7EC18Z0X214PM1RF2MG", + 37, + FreshCoin( + Base32Crockford.decode("QWP8560H613GSNR2A1NGVQ023V2BP8ZYBVJJ0E2Y66ZGSHCX92K0"), + Base32Crockford.decode("XF58PHSGFDC30CBWQ55KDFWBDSZJMQ3AA6DF4V9SQ4E2DEFBS7N0"), + Base32Crockford.decode("KQT2K5HDKVVBHD188XS0KFBM63B4MAWM42HABT9FH806GME1QXHG") + ) + ), + TestVector( + "THX83Y6G26CYMF186V6GPP5A4DQ6624D9EGCA6R7DVW6T32P6E6Q0TDW0S9MJSRG84SR2AMK1KKS6D627EJ02XSNH0BXDNWD1W35BWR", + 31, + FreshCoin( + Base32Crockford.decode("QWCD3DMV7NX87SPHYFWE3STBZQ02Q1NMXJ8GQGAA6WZ4GF7B3C80"), + Base32Crockford.decode("7J26RGBJFRGAYX8XDZZZ8PE9PCW3DH5KFA7564WZ6F5ADC159KB0"), + Base32Crockford.decode("M0A1EPG9WFB02Y7R6YYE6V6Q8DRJ466XCD6N99YFDAGN1QVA62NG") + ) + ), + TestVector( + "D949HYZA6MWNKBKGTRRP1SGFA4X7AZK7HG5XX9YBVMWA05B884WAYY7MRDQNQYMRGA7AE01V6EEWZDF9JXMQKSYG0G1FVSZG4096JV8", + 26, + FreshCoin( + Base32Crockford.decode("BXRTK601B5NRJV9BAE88SAVPSMA20ZWDH5P0T6WFY7BSM3SZD930"), + Base32Crockford.decode("5N4WJWFR1J1SDNJGC8G9AV3ET4MPF6TGA4S1PJNQRJ5BRDHEEJN0"), + Base32Crockford.decode("P7XFE4W0H444XBYPRHB9VBTHKAZHNJE4E0R7P4D5FT90W3JJRE20") + ) + ), + TestVector( + "7CXEEDABFD2FM802314FE91J2DS7ZHCHDGX27SJHJWWJWJZ1NGZBNZJ8T55W1P9ZEBPVYCHDNKQWRG68K3EJD069723857R06XDYE00", + 2147483647, + FreshCoin( + Base32Crockford.decode("QCAK6CA31KJ7HX4V3QEFK9CENT3E6DVE76M4VY9ZQ030CM8TZQJG"), + Base32Crockford.decode("2V7HAFK2ATBB9MDWRR8P9PSPXBWMGFXW5G75WVGYCRZB0G5PDG9G"), + Base32Crockford.decode("6C02KA6496FG5X0SDSWTS4JJHH87GYNSW9FZE6S34AWG5RJGPWQ0") + ) + ), + TestVector( + "9F65N3N1BZW7TT6EB4X2GCDC5BN3J2R33R7H0NWPZENN6B4Y41XYTVC9SA5NP0E4WQFQEJ63WNC5R0C8KBMKDA5JCXEKEGJYVMTR7VG", + 0, + FreshCoin( + Base32Crockford.decode("S6RM093DHGG882T39KWC48807CFC36SRGGX54DBB1Z6NDAMT875G"), + Base32Crockford.decode("2DY3KVKA978S85ERF6QYF73Q95KQ0YVE89KKQ8JYR0FX161PV1S0"), + Base32Crockford.decode("PCY47EC1APBKBG9HWQCTJN6FWNJPGGC6XDJTYWBHTJ9A952AGCK0") + ) + ) + ) + + @Test + fun testRefreshPlanchets() { + for (v in vectors) { + val freshCoin = crypto.setupRefreshPlanchet(Base32Crockford.decode(v.seed), v.coinNumber) + assertEquals(v.freshCoin, freshCoin) + } + } + + private class TestVector(val seed: String, val coinNumber: Int, val freshCoin: FreshCoin) + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/Sha256Test.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/Sha256Test.kt new file mode 100644 index 0000000..3209e05 --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/Sha256Test.kt @@ -0,0 +1,70 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.Base32Crockford +import kotlin.test.Test +import kotlin.test.assertEquals + +@kotlin.ExperimentalStdlibApi +class Sha256Test { + + private val crypto = CryptoFactory.getCrypto() + + @Test + fun testAbc() { + assertEquals( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + crypto.sha256("abc".encodeToByteArray()).toHexString() + ) + } + + @Test + fun testEmptyString() { + assertEquals( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + crypto.sha256("".encodeToByteArray()).toHexString() + ) + } + + @Test + fun testAbc448bits() { + assertEquals( + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", + crypto.sha256("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq".encodeToByteArray()).toHexString() + ) + } + + @Test + fun testAbc896bits() { + assertEquals( + "cf5b16a778af8380036ce59e7b0492370b249b11e8f07a51afac45037afee9d1", + crypto.sha256("abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu".encodeToByteArray()) + .toHexString() + ) + } + + @Test + fun testAMillionAs() { + val input = ByteArray(1_000_000) { 0x61 } + assertEquals( + "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0", + crypto.sha256(input).toHexString() + ) + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/Sha512Test.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/Sha512Test.kt new file mode 100644 index 0000000..24be282 --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/Sha512Test.kt @@ -0,0 +1,116 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.Base32Crockford +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals + +class Sha512Test { + + private val crypto = CryptoFactory.getCrypto() + + @Test + fun testAbc() { + assertEquals( + "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f", + crypto.sha512("abc".encodeToByteArray()).toHexString() + ) + } + + @Test + fun testEmptyString() { + assertEquals( + "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", + crypto.sha512("".encodeToByteArray()).toHexString() + ) + } + + @Test + fun testAbc448bits() { + assertEquals( + "204a8fc6dda82f0a0ced7beb8e08a41657c16ef468b228a8279be331a703c33596fd15c13b1b07f9aa1d3bea57789ca031ad85c7a71dd70354ec631238ca3445", + crypto.sha512("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq".encodeToByteArray()).toHexString() + ) + } + + @Test + fun testAbc896bits() { + assertEquals( + "8e959b75dae313da8cf4f72814fc143f8f7779c6eb9f7fa17299aeadb6889018501d289e4900f7e4331b99dec4b5433ac7d329eeb6dd26545e96e55b874be909", + crypto.sha512("abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu".encodeToByteArray()) + .toHexString() + ) + } + + @Test + fun testAMillionAs() { + val input = ByteArray(1_000_000) { 0x61 } + assertEquals( + "e718483d0ce769644e2e42c7bc15b4638e1f98b13b2044285632a803afa973ebde0ff244877ea60a4cb0432ce577c31beb009c5c2c49aa2e4eadb217ad8cc09b", + crypto.sha512(input).toHexString() + ) + } + + @Test + fun testExchangeTvgHashCode() { + val input = "91JPRV3F5GG4EKJN41A62V35E8" + val output = + "CW96WR74JS8T53EC8GKSGD49QKH4ZNFTZXDAWMMV5GJ1E4BM6B8GPN5NVHDJ8ZVXNCW7Q4WBYCV61HCA3PZC2YJD850DT29RHHN7ESR" + assertEquals(output, Base32Crockford.encode(crypto.sha512(Base32Crockford.decode(input)))) + } + + @Test + fun testIncrementalHashing() { + val n = 1024 + val d = Random.nextBytes(n) + + val h1 = crypto.sha512(d) + val h2 = crypto.getHashSha512State().update(d).final() + assertEquals(Base32Crockford.encode(h1), Base32Crockford.encode(h2)) + + val s = crypto.getHashSha512State() + for (i in 0 until n) { + val b = ByteArray(1) + b[0] = d[i] + s.update(b) + } + val h3 = s.final() + assertEquals(Base32Crockford.encode(h1), Base32Crockford.encode(h3)) + } + + @Test + fun testIncrementalHashing2() { + val n = 10 + val d = Random.nextBytes(n) + + val h1 = crypto.sha512(d) + val h2 = crypto.getHashSha512State().update(d).final() + assertEquals(Base32Crockford.encode(h1), Base32Crockford.encode(h2)) + + val s = crypto.getHashSha512State() + for (i in 0 until n) { + val b = ByteArray(1) + b[0] = d[i] + s.update(b) + } + val h3 = s.final() + assertEquals(Base32Crockford.encode(h1), Base32Crockford.encode(h3)) + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/SignatureTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/SignatureTest.kt new file mode 100644 index 0000000..1306c14 --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/crypto/SignatureTest.kt @@ -0,0 +1,493 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.Base32Crockford +import net.taler.wallet.kotlin.Timestamp +import net.taler.wallet.kotlin.crypto.Signature.PurposeBuilder +import net.taler.wallet.kotlin.exchange.DenominationRecord +import net.taler.wallet.kotlin.exchange.DenominationStatus.Unverified +import net.taler.wallet.kotlin.exchange.DenominationStatus.VerifiedBad +import net.taler.wallet.kotlin.exchange.WireFee +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SignatureTest { + + private val crypto = CryptoFactory.getCrypto() + private val signature = Signature(crypto) + + private class PurposeBuilderVector(val purposeNum: Int, val chunks: List<String>, val result: String) + + @Test + fun testSignaturePurposeBuilder() { + val vectors = listOf( + PurposeBuilderVector(42, listOf("46D6FNS4VAFW0"), "000004000002M8CTCZBJ9PMZR0"), + PurposeBuilderVector( + 1375883724, + listOf("7H2ENBHG1AVRD5QVKYCXKAZQQQGCSEAAZ4"), + "000007AJ098WRF24XAQ302NQGTBFQ7WSV6NZFFF0SJWMNY8" + ), + PurposeBuilderVector(251873401, listOf("KVSRVCV6J7QGYR0"), "0000048F0D47K7QKHPSPD4FF1XG0"), + PurposeBuilderVector( + 2126178285, + listOf("EJN927GRT1HNS0K53TBPWYB4", "QGSXW3NGHPJCVTBX3KHGW7GBC1SJWPK0", "XK7GA"), + "00000BKYQBKYTX5AJ4F1HM33BJ16A7MQDSWP9F1KVR7B13D4SQMQT7731RF0PR3K5SD61V6F0M" + ), + PurposeBuilderVector( + 871975602, + listOf("JC26GG7M", "D5GER58NSW3MX3AB2EA0", "HVB81PFV0YYQ2170H77TQPZTB21T314P9B1N3VSXEHZ88VEPHW"), + "00000E1KZ55B54R4D10F8TB0XGAHBKR79T6MP4WMHVB81PFV0YYQ2170H77TQPZTB21T314P9B1N3VSXEHZ88VEPHW" + ), + PurposeBuilderVector( + 366986877, + listOf("28", "48213TXMKY8H8EGRJ9XP00D9MHRHQG5N02W5GG0", "VR4CDW7WQ2FYQF3WBG60"), + "00000B8NVZ37T4H20G8YQD4ZJ4A3M64JFDG03AD4E4DW1D80Q1C41QG8RVRFSE4ZXEY7RQ0C" + ), + PurposeBuilderVector( + 1369171164, + listOf( + "SJTSKNX7W7CAP848GCESA09HQQQYQN50G1GY5AHPP12D4GMZJCXMCFPYECGEBF8", + "J70SVFRH8WN1DP7DZ60BZW0", + "275VB93D2MV6NN7V97QXC16VMX07JNCC5R3HBH1TSWTW5V72G1E66HP73ANWFXE6NG" + ), + "00000SJHKFJDSK5NK7BTFRERNCG8H0RXJM0K3FFFXFAA1031WAN3DC24T919Z4SV8RZDWWS0WPYS3GCXQW8MEAGPV3PZK05ZY08WQDD4DMAKCTPMZD4YZNG4VEKM0YANHGQ0E5E47B7KBGQCWA05RRT6RWDAQHZNRTP0" + ), + PurposeBuilderVector( + 2115762114, + listOf("Z7A3PQ7BV5G5D1P3F2EY9KN4GKVEG95KZD50", "SZ3PA4BSWF8T22TJMDZN5877Z9DQFC67K9E0"), + "00000D3Y3FVW5YEM7DEEQPB0AT3C6Y4XWK7A917PX0JB7YTASZ3PA4BSWF8T22TJMDZN5877Z9DQFC67K9E0" + ), + PurposeBuilderVector( + 1382722498, + listOf("0WEEY7GRA8Q8ZWS2NRP0", "FQ5YY3ZWJ64014A0SPWMCE4B58", "G03C6M9PZ45D99G4M2HZJ"), + "00000CAJDANW41RWXWF1GMHEHZSJ5BHCFQ5YY3ZWJ64014A0SPWMCE4B5A00DGTH6VWGNN560JGA7Y8" + ), + PurposeBuilderVector( + 1557782497, + listOf( + "CEB9E7Q87X12R172RZ8DAHSGGS60", + "M17JXQ290P209Q7EH759PW558FYZYTEHR40J18M81W", + "B918DJVWTERCHS54S0G93DFPVK9TV383DMRJR43ZTFZBAE3CVSFG" + ), + "00000MTWV7FY2RWPJWFEGFT25G2E5HYGTN3K11JCM17JXQ290P209Q7EH759PW558FYZYTEHR40J18M81XD451PBFK9V1J74MK4214DNYVED7BCD0DPK2B0GFZ9ZXD9RDKF5Y" + ), + PurposeBuilderVector(1662106597, listOf("621Y24CA3ZCHJJ8"), "000004B326XYAC43W48RM7YS354G"), + PurposeBuilderVector( + 393436430, + listOf( + "6WMDVK6XKGEAX152ZM4SHQ77DCRQB4RYJX0MGCZFVVK3XTG", + "8TKEY2F1PMAT396GC3E191182F34FXXR12MR7Z6WMG4S10GZX7RJG" + ), + "00000HGQEDEGWDS8VQ6DV70WNT2A5Z89K3EEETSHEP9HX5T190SYZQQ67VN4D9QF17GVA5D1MK861Q0MGGM17HJ7YYW0HAC3ZKEA82CGG8FYKW98" + ), + PurposeBuilderVector( + 1616746709, + listOf("ZQT1F901YWFDX6XZZB6R8PSQ1QJANCPF63T8RE09HRK2XQ7EE2K0F74GERH4GQ0"), + "00000BV0BPCDBZFM2YJ03XRYVTDVZYPDGHDKE3F4NASCYC7MHGW0K3H65VEEWW560YE90XH291E0" + ), + PurposeBuilderVector( + 691857007, + listOf("5JT5DM7KWVW7A7P764NTG0FJDWEQTQAGB253J547GXWX93204NM0"), + "00000A197KK6YB5MAV8F7SQREMFCEC9BN00Z4VRXFNEN0P4A74A8F1VSTJ6409B8" + ), + PurposeBuilderVector( + 582885064, + listOf("W5P2HP14MCNSKX4CZ4FZ0BDPSX2SV7MCE45V8PF7DW9EXAGK3TC0"), + "00000A12QRFCHRBC53C298SBK7T8SY8ZY0PVDKT5KPF8RW8BPHCYEVRJXTN167MR" + ), + PurposeBuilderVector( + 480383332, + listOf( + "49DZJY0K1WJFDK7P86Q1R2F150S50JQR8HQW059HBG9CR615N4V82", + "D0XXQKX9BB0HVJK34TKCZJ1E2JQKF06EQVXRRD2ZKX4QGXG", + "99JY6SS2HJ1J29GS1793CGE5BKSG" + ), + "00000NRWM88P88JVZ5W163S4YV6FCGDE3G4Y2A1JA15FGH3FR0AK2Q0JSGC2BA9PG5M3QPYFN5DC27EACCKADKY85RAAYDW0STZFQ31MBYFMJY3P99JY6SS2HJ1J29GS1793CGE5BKSG" + ), + PurposeBuilderVector( + 877078324, + listOf("RC", "K1PKX78BPGNQSSZCVRB3B6PJYR", "74"), + "000006HM8WKK9GWRDMZ9T2XM5DYEFV6Y2RTSNMQP74" + ), + PurposeBuilderVector( + 25488155, + listOf( + "FNG58X0W2A4HV4ZY5TKS73RGNHA8KZ3B4WWDJS2V1B27F3G6G08FNHM8NYBZ45Q8", + "NQGNDPDNW4G9AEHXB1BKXQXK84" + ), + "00000G01GKNHPZB0AHT1R4M93P9ZWBN7JE7H1B2MH7Y6P9SRV5J5P2P4EY70D00GZB38HBWQY8BEHBF1AVCVBR90JMX3TP2Q7VFV6G8" + ), + PurposeBuilderVector( + 749389285, + listOf( + "FVD8X18A3S2KHJ7143TCEHE3K083DRBG2CA0D766NMRVHX7MSN78NSYHZSWNH9V2DM", + "1TSPZ4VBKEBKCQEB2673H2FJHCZ6M19R2691NY79A0", + "P9VW044VY2TGKNNF4ZWZ1S2ACM4VJYCWP7ZGWRKR1264ZS2CQZGSXZ775AC41X5778" + ), + "00000X1CNB2YAZPTHT2GM7J5734E287MRX2W760G6VGQ04RM0TECDB9HQ3TF9KAEHBKX3ZKSB2KP4V8EPDQS6TWVJWV5VJRHHRW8KWMB7SN0AE0HJ8DFHTAGP9VW044VY2TGKNNF4ZWZ1S2ACM4VJYCWP7ZGWRKR1264ZS2CQZGSXZ775AC41X5778" + ), + PurposeBuilderVector( + 434249902, + listOf( + "VFERBNSH7X70", + "T5DWNBWMF3BPAGWSESKF7WGTTS3JHVKAAR3DR", + "SMCA0184DN3WE934BGFE4MCE14279NYCFF0PJDQ20JRHFZXSGXE6CP1BSMGR479HAC" + ), + "00000KRSW8GAXPYXGQBK2FTET5DWNBWMF3BPAGWSESKF7WGTTS3JHVKAAR3DSK8RM02G8VA7RWJ68Q0YW98RW284EKBWRYY1D4VE415H2ZZVK1TWCSC2QK91G8EK2MR" + ), + PurposeBuilderVector( + 1710327887, + listOf("S64XB965NEAQQ5287XHNT126BW"), + "00000635Y644ZJC9TPJCBAWNFEA4GFV3BM24CQR" + ), + PurposeBuilderVector( + 1247371119, + listOf("CSXX5W59E3S03412M29J0HG0R67BW4B3X946GS2T0TVYPH9HFP9FC0M7", "931KSGS0ZJG4A79BT55G0Q4Q"), + "00000EJAB5FPYSKVTBRAJW7J0682584K41301GCEQR8P7TJ8D1J5M1NQXD2K2ZCJYR18EJ637K1J1Z508MEJQMAB01E9E" + ), + PurposeBuilderVector( + 2024386767, + listOf( + "R2WZA0FKD57D418NB2KK84PJKHAVPEC6219X2KQJ78JFB76F0R", + "CNYD5TAC7Y3N58CN6VK9F7NBKD2NJJK3VJ91CZKBFZWDVVZB28", + "G7WQJVXXYT4JHA1NK22RXS45BM0W5H5JD7JNX3PWD2WZ27Y5Y1FB4" + ), + "00000SVRN6RCZG5SYM0Z6TAET82HAP576G9D572NQCWRC42KT57F4EH4YPECY1K5FK9EJK1ZGX9A359PWTBSXAWV8NCMMRYWJ8B7WTVZZ3EYZTRJG7WQJVXXYT4JHA1NK22RXS45BM0W5H5JD7JNX3PWD2WZ27Y5Y1FB4" + ), + PurposeBuilderVector(1182512118, listOf("KH32THSNNZGJPC0"), "000004A6FESZD7265N3KBBZ15CR0"), + PurposeBuilderVector( + 2029783771, + listOf("N7468QH02K49BTD16S1F6FB4NKNXB0Q410"), + "000007BRZG5DQAE8CHF20568JQMT2DJ2YCYP9B7BTP1E820" + ), + PurposeBuilderVector( + 1111140596, + listOf("6BN45PA2RCVRR8PJ6XKRJ9ZER2BR3MY9RQ290FADAG"), + "000008J27AMF8CQA8BCM5GSQHGHD4DV7H4KYXG4QG79WKHE4J0YMTN0" + ) + ) + for (v in vectors) { + val builder = PurposeBuilder(v.purposeNum) + for (chunk in v.chunks) builder.put(Base32Crockford.decode(chunk)) + val encodedBytes = Base32Crockford.encode(builder.build()) + assertEquals(v.result, encodedBytes) + } + } + + private class PaymentSignatureVector(val publicKey: String, val hash: String, val signature: String) + + @Test + fun testVerifyPaymentSignature() { + val vectors = listOf( + PaymentSignatureVector( + "8GSJZ649T2PXMKZC01Y4ANNBE7MF14QVK9SQEC4E46ZHKCVG8AS0", + "CW96WR74JS8T53EC8GKSGD49QKH4ZNFTZXDAWMMV5GJ1E4BM6B8GPN5NVHDJ8ZVXNCW7Q4WBYCV61HCA3PZC2YJD850DT29RHHN7ESR", + "JSNG99MX5W4AS7AEA8D4ADCHYTHFER0GX1N064E1XX48N513AXAEHDTG8ZT7ANWQK5HGCAXGEWN7TCBTYVG3RDPBTAS5HEP608KQ40R" + ), + PaymentSignatureVector( + "6WC3MPYM5XKPKRA2Z6SYB81CPFV3E7EC6S2GVE095X8XH63QTZCG", + "Z6H76JXPJFP3JBGSF54XBF0BVXDJ0CJBK4YT9GVR1AT916ZD57KP53YZN5G67A4YN95WGMZKQW7744483P5JDF06B6S7TMK195QGP20", + "T2Y4KJJPZ0F2DMNF5S81V042T20VHB5VRXQYX4RF8KRH6H2Z4JRBD05CCDJ6C625MHM5FQET00RDX2NF5QX63S9YDXEP0710VBYHY10" + ), + PaymentSignatureVector( + "2X3PSPT7D6TEM97R98C0DHZREFVAVA3XTH11D5A2Z2K7GBKQ7AEG", + "JSNG99MX5W4AS7AEA8D4ADCHYTHFER0GX1N064E1XX48N513AXAEHDTG8ZT7ANWQK5HGCAXGEWN7TCBTYVG3RDPBTAS5HEP608KQ40R", + "W0783H1KZ6GX58T5ZEH5VYMTXP7P7EA3KBKQ4Y8CN8M20GY8RNA4RX1AZG6TQ70NR4XG4EZ9D606P4RDAARD2SXTKA90BJMN9VEAC00" + ) + ) + for (v in vectors) { + // verification succeeds as expected + assertTrue(signature.verifyPayment(v.signature, v.hash, v.publicKey)) + // verification fails which different signature + val size = Base32Crockford.calculateDecodedDataLength(v.signature.length) + val sig = Base32Crockford.encode(Random.nextBytes(size)) + assertFalse(signature.verifyPayment(sig, v.hash, v.publicKey)) + // verification fails which different hash + val hash = Base32Crockford.encode(Random.nextBytes(64)) + assertFalse(signature.verifyPayment(v.signature, hash, v.publicKey)) + // verification fails which different public key + val publicKey = Base32Crockford.encode(Random.nextBytes(32)) + assertFalse(signature.verifyPayment(v.signature, v.hash, publicKey)) + } + } + + private class WireFeeSignatureVector(val wireFee: WireFee, val masterPub: String) + + @Test + fun testVerifyWireFeeSignature() { + val type = "x-taler-bank" + val vectors = listOf( + WireFeeSignatureVector( + WireFee( + wireFee = Amount("TESTKUDOS", 0, 1000000), + closingFee = Amount("TESTKUDOS", 0, 1000000), + startStamp = Timestamp(1609470000000), + endStamp = Timestamp(1641006000000), + signature = "C77EZ56ZT4ACNPGP3PX881S42413N37NJVTRPNMM9BWRVGQBK2C157HHRF61RMYPAWW6QVWV5RVKEVBD3XAVJFXAEBM2HPNS5AMPY18" + ), "Y8JGNCPMM7XTF44FT7V1JRNNJQ6F4R7DZPXAKYYCJZJVEX2C45QG" + ), + WireFeeSignatureVector( + WireFee( + wireFee = Amount("TESTKUDOS", 0, 1000000), + closingFee = Amount("TESTKUDOS", 0, 1000000), + startStamp = Timestamp(1577847600000), + endStamp = Timestamp(1609470000000), + signature = "4F87Z12WQM7JXEKPRPGFZVGCZPNN6Q9RVP2NA0PZ57CYQP4QXV38EQW59X3K5WAQN82BX95X57775ZJGA5EB3VA5GV9S3JBA3RGX41G" + ), "0BJ4SX13W4Q64QDDSRBVTYSWT8C0X2HVB61QYSZM1B1W9JVCE0C0" + ), + WireFeeSignatureVector( + WireFee( + wireFee = Amount("TESTKUDOS", 0, 1000000), + closingFee = Amount("TESTKUDOS", 0, 1000000), + startStamp = Timestamp(1672542000000), + endStamp = Timestamp(1704078000000), + signature = "9YWES9YPR2W5ACCYE4ZE4S3PE62CVX01AXXSC9KT9PZ6B52D5GSBP4DG95Y024EDE5HFGP6FEZVPKQM8PA6J9WD2NXW3QBA34MEV61R" + ), "FWPD4E312RB8XZ22FGHCZGV2YPT7AX02XVRZEC7F53QKZJHY7H50" + ) + ) + for (v in vectors) { + // verification succeeds as expected + assertTrue(signature.verifyWireFee(type, v.wireFee, v.masterPub)) + // different type fails verification + assertFalse(signature.verifyWireFee("foo", v.wireFee, v.masterPub)) + // different WireFee wireFee fails verification + var wireFee = v.wireFee.copy(wireFee = Amount("TESTKUDOS", 0, 100)) + assertFalse(signature.verifyWireFee(type, wireFee, v.masterPub)) + // different WireFee closingFee fails verification + wireFee = v.wireFee.copy(closingFee = Amount("TESTKUDOS", 0, 100)) + assertFalse(signature.verifyWireFee(type, wireFee, v.masterPub)) + // different WireFee startStamp fails verification + wireFee = v.wireFee.copy(startStamp = Timestamp(v.wireFee.startStamp.ms + 1000)) + assertFalse(signature.verifyWireFee(type, wireFee, v.masterPub)) + // different WireFee endStamp fails verification + wireFee = v.wireFee.copy(endStamp = Timestamp(v.wireFee.endStamp.ms + 1000)) + assertFalse(signature.verifyWireFee(type, wireFee, v.masterPub)) + // different WireFee signature fails verification + val size = Base32Crockford.calculateDecodedDataLength(v.wireFee.signature.length) + wireFee = v.wireFee.copy(signature = Base32Crockford.encode(Random.nextBytes(size))) + assertFalse(signature.verifyWireFee(type, wireFee, v.masterPub)) + // startStamp changes below one second don't affect verification + wireFee = v.wireFee.copy(startStamp = Timestamp(v.wireFee.startStamp.ms + 999)) + assertTrue(signature.verifyWireFee(type, wireFee, v.masterPub)) + // startStamp changes below one second don't affect verification + wireFee = v.wireFee.copy(endStamp = Timestamp(v.wireFee.endStamp.ms + 999)) + assertTrue(signature.verifyWireFee(type, wireFee, v.masterPub)) + // different masterPub fails verification + val masterPub = Base32Crockford.encode(Random.nextBytes(32)) + assertFalse(signature.verifyWireFee(type, v.wireFee, masterPub)) + } + } + + private class DenominationRecordSignatureVector(val denominationRecord: DenominationRecord, val masterPub: String) + + @Test + fun testVerifyDenominationRecordSignature() { + val vectors = listOf( + DenominationRecordSignatureVector( + DenominationRecord( + value = Amount("TESTKUDOS", 4, 0), + denomPub = "020000XX26WN5X7ECKERVGBWTFM3KJ7AT0N8T7RQB7W7G4Q5K0W1BT8QFQBMMTR925TC6RX4QGVVVXH2ZMJVWDRR58BRXNDCFBZTH7RNS1KVWZQGEWME1D6QX79R0V6V9S0NC9H8YP0W6MJD7YSV5VQZWCR1JXRTSS0HPHDV4V6AVX34TSMHGRDQXZYVTEBGVWNF8TNR2P5296TCZR0G2001", + denomPubHash = "MY54RXQ1WTZPFD3VZ4QJH6RHFPTYE78Y4DQ3GANTWK2Z0SQ99AK90K5H0P419EY4QWV6S4Q6QG52HYFCVRCPEQNG22RM3E7XW2YFJ9R", + feeWithdraw = Amount("TESTKUDOS", 0, 3000000), + feeDeposit = Amount("TESTKUDOS", 0, 3000000), + feeRefresh = Amount("TESTKUDOS", 0, 4000000), + feeRefund = Amount("TESTKUDOS", 0, 2000000), + stampStart = Timestamp(1593449948000), + stampExpireWithdraw = Timestamp(1594054748000), + stampExpireLegal = Timestamp(1688057948000), + stampExpireDeposit = Timestamp(1656521948000), + masterSig = "2PEF4EHTKE2R89KHQBZ6NAQW34YEMAP044DTJXJ9TFX224YW07BB2X1VHEYH425N2RNDZS9XFEAQK54GV6Q6CTNSE1Z038TZ4V3MM1R", + status = Unverified, isOffered = false, isRevoked = false, exchangeBaseUrl = "example.org" + ), + "C5K3YXPHSDVYPCB79D5AC4FYCKQ9D0DN3N3MBA5D54BG42SX0ZRG" + ), + DenominationRecordSignatureVector( + DenominationRecord( + value = Amount("TESTKUDOS", 0, 10000000), + denomPub = "020000XWWS2DFXM1G8YR6NXVF07R0DH7GRE1J7ZC2YENN7Q60ZHX9S7FEVQDBFP1041DN0GFASZR6A7RSJ3EYRV1YD5ACAZRDQNH5P5KEBTSK5XA76YYWTW5VA7N1V1VRVF0CF7VZ8GSJNQ91FSJGNKGR82DB7H82TPAGMR1B5DG5SY2WP3NB5YJD90H64E09C0YC8FCCWRGC09HFNPG2001", + denomPubHash = "J73HZJTR76GRZKBEKQC4X289056V5RMY4JS932XM8BAENQ83YAF9W2WYKRBN87TN8ENXP61JC7HMC7PYK9MZ8S289FCD07EM95AJGMR", + feeWithdraw = Amount("TESTKUDOS", 0, 1000000), + feeDeposit = Amount("TESTKUDOS", 0, 1000000), + feeRefresh = Amount("TESTKUDOS", 0, 3000000), + feeRefund = Amount("TESTKUDOS", 0, 1000000), + stampStart = Timestamp(1593450307000), + stampExpireWithdraw = Timestamp(1594055107000), + stampExpireLegal = Timestamp(1688058307000), + stampExpireDeposit = Timestamp(1656522307000), + masterSig = "HPBAY19C1B5H3FAYWKRQFS8VC693658SNSK3BB304BNAG950BS881GMVPVN0YBG67K9J9E4A9BFN7VAQEY1D5G6YAGVPFQWX0GRQ62G", + status = Unverified, isOffered = false, isRevoked = false, exchangeBaseUrl = "example.org" + ), + "NF8G14QFVWJSQPNFC3M2JKNMGJESS8ZWZX9V43PG40YXWRWA88VG" + ) + ) + for (v in vectors) { + // verification succeeds as expected + assertTrue(signature.verifyDenominationRecord(v.denominationRecord, v.masterPub)) + // different masterPub fails verification + val masterPub = Base32Crockford.encode(Random.nextBytes(32)) + assertFalse(signature.verifyDenominationRecord(v.denominationRecord, masterPub)) + // different value fails verification + val value = v.denominationRecord.value + Amount.min(v.denominationRecord.value.currency) + assertFalse( + signature.verifyDenominationRecord( + v.denominationRecord.copy(value = value), + v.masterPub + ) + ) + // different denomPubHash fails verification + val calculatedDenomPubHash = crypto.sha512(Base32Crockford.decode(v.denominationRecord.denomPub)) + assertEquals(v.denominationRecord.denomPubHash, Base32Crockford.encode(calculatedDenomPubHash)) + val denomPubHash = Base32Crockford.encode(Random.nextBytes(calculatedDenomPubHash.size)) + assertFalse( + signature.verifyDenominationRecord( + v.denominationRecord.copy(denomPubHash = denomPubHash), + v.masterPub + ) + ) + // different feeWithdraw fails verification + val feeWithdraw = v.denominationRecord.feeWithdraw + Amount.min(v.denominationRecord.feeWithdraw.currency) + assertFalse( + signature.verifyDenominationRecord( + v.denominationRecord.copy(feeWithdraw = feeWithdraw), + v.masterPub + ) + ) + // different feeDeposit fails verification + val feeDeposit = v.denominationRecord.feeDeposit + Amount.min(v.denominationRecord.feeDeposit.currency) + assertFalse( + signature.verifyDenominationRecord( + v.denominationRecord.copy(feeDeposit = feeDeposit), + v.masterPub + ) + ) + // different feeRefresh fails verification + val feeRefresh = v.denominationRecord.feeRefresh + Amount.min(v.denominationRecord.feeRefresh.currency) + assertFalse( + signature.verifyDenominationRecord( + v.denominationRecord.copy(feeRefresh = feeRefresh), + v.masterPub + ) + ) + // different feeRefund fails verification + val feeRefund = v.denominationRecord.feeRefund + Amount.min(v.denominationRecord.feeRefund.currency) + assertFalse( + signature.verifyDenominationRecord( + v.denominationRecord.copy(feeRefund = feeRefund), + v.masterPub + ) + ) + // different stampStart fails verification + val stampStart = Timestamp(v.denominationRecord.stampStart.ms + 1000) + assertFalse( + signature.verifyDenominationRecord( + v.denominationRecord.copy(stampStart = stampStart), + v.masterPub + ) + ) + // different stampExpireWithdraw fails verification + val stampExpireWithdraw = Timestamp(v.denominationRecord.stampExpireWithdraw.ms + 1000) + assertFalse( + signature.verifyDenominationRecord( + v.denominationRecord.copy(stampExpireWithdraw = stampExpireWithdraw), + v.masterPub + ) + ) + // different stampExpireLegal fails verification + val stampExpireLegal = Timestamp(v.denominationRecord.stampExpireLegal.ms + 1000) + assertFalse( + signature.verifyDenominationRecord( + v.denominationRecord.copy(stampExpireLegal = stampExpireLegal), + v.masterPub + ) + ) + // different stampExpireDeposit fails verification + val stampExpireDeposit = Timestamp(v.denominationRecord.stampExpireDeposit.ms + 1000) + assertFalse( + signature.verifyDenominationRecord( + v.denominationRecord.copy(stampExpireDeposit = stampExpireDeposit), + v.masterPub + ) + ) + // different masterPub fails verification + val size = Base32Crockford.calculateDecodedDataLength(v.denominationRecord.masterSig.length) + val masterSig = Base32Crockford.encode(Random.nextBytes(size)) + assertFalse( + signature.verifyDenominationRecord( + v.denominationRecord.copy(masterSig = masterSig), + v.masterPub + ) + ) + // different status does not affect verification + assertTrue( + signature.verifyDenominationRecord( + v.denominationRecord.copy(status = VerifiedBad), + v.masterPub + ) + ) + // different exchangeBaseUrl does not affect verification + assertTrue( + signature.verifyDenominationRecord( + v.denominationRecord.copy(exchangeBaseUrl = "foo.bar"), + v.masterPub + ) + ) + } + } + + private class WireAccountSignatureVector(val paytoUri: String, val signature: String, val masterPub: String) + + @Test + fun testVerifyWireAccount() { + val paytoUri = "payto://x-taler-bank/localhost/Exchange" + val vectors = listOf( + WireAccountSignatureVector( + paytoUri, + "7THNYN5G12GXZABHP187XJZ3ACTDKAWGHWYM2ERA1VGE4JMGPADXT37ZM8D4DVAQTSP8CR61VAD4ZZSWKRPP5KQ12JCTVHCKDD3KA3R", + "QPJSEA4SM8E67106C6MN2TMG4308J20C1T0D411WED1FJ00VF8ZG" + ), + WireAccountSignatureVector( + paytoUri, + "6JJDJ0HG3AGRTEY1FFGHR89367GPE4FS7TC8Z26N34PHFAMSRXQ4FA7P96CDS6625SRNAN4DY1NK4TNBQXKRXQ9QR82HEVCB2FF1E18", + "8QBE0SRR84GWJ1FX2QCGGNRZTWA2FCV6YQC98Q26DRRJ0QBQE930" + ), + WireAccountSignatureVector( + paytoUri, + "X5EYQJ388P8AH1PPYD2GFQ9Y1NGA7HV0TXAW6GJ7C0D6R6GAY0059HCDBE98TKJJT6MB1S660FV13DV46JDKJ622MR961XVGP6DG618", + "802M037TN8GHBXEGBPC7J41HJC06TWBWXYH36AYQHRJ7NVHJBQQ0" + ) + ) + for (v in vectors) { + // verification succeeds as expected + assertTrue(signature.verifyWireAccount(v.paytoUri, v.signature, v.masterPub)) + // different paytoUri fails verification + assertFalse(signature.verifyWireAccount("foo", v.signature, v.masterPub)) + // different paytoUri fails verification + val size = Base32Crockford.calculateDecodedDataLength(v.signature.length) + val sig = Base32Crockford.encode(Random.nextBytes(size)) + assertFalse(signature.verifyWireAccount(v.paytoUri, sig, v.masterPub)) + // different paytoUri fails verification + val masterPub = Base32Crockford.encode(Random.nextBytes(32)) + assertFalse(signature.verifyWireAccount(v.paytoUri, sig, masterPub)) + } + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/exchange/DenominationTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/exchange/DenominationTest.kt new file mode 100644 index 0000000..f48c97d --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/exchange/DenominationTest.kt @@ -0,0 +1,91 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.exchange + +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.Timestamp +import net.taler.wallet.kotlin.Timestamp.Companion.NEVER +import net.taler.wallet.kotlin.exchange.DenominationStatus.VerifiedBad +import net.taler.wallet.kotlin.exchange.DenominationStatus.VerifiedGood +import net.taler.wallet.kotlin.exchange.Denominations.denomination1 +import net.taler.wallet.kotlin.exchange.Denominations.denomination10 +import net.taler.wallet.kotlin.exchange.Denominations.denomination2 +import net.taler.wallet.kotlin.exchange.Denominations.denomination5 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DenominationTest { + + @Test + fun testGetEarliestDepositExpiry() { + // empty selection info never expires + val infoEmpty = DenominationSelectionInfo( + totalCoinValue = Amount.zero("TESTKUDOS"), + totalWithdrawCost = Amount.zero("TESTKUDOS"), + selectedDenominations = emptyList() + ) + assertEquals(Timestamp(NEVER), infoEmpty.getEarliestDepositExpiry()) + + // earliest expiry of single denomination is that of the denomination + val info1 = infoEmpty.copy( + selectedDenominations = listOf(SelectedDenomination(1, denomination10)) + ) + assertEquals(denomination10.stampExpireDeposit, info1.getEarliestDepositExpiry()) + + // denomination that expires earlier gets selected + val info2 = infoEmpty.copy( + selectedDenominations = listOf( + SelectedDenomination(3, denomination5.copy(stampExpireDeposit = Timestamp(42))), + SelectedDenomination(2, denomination2.copy(stampExpireDeposit = Timestamp(2))), + SelectedDenomination(1, denomination1.copy(stampExpireDeposit = Timestamp(1))) + ) + ) + assertEquals(Timestamp(1), info2.getEarliestDepositExpiry()) + + // denomination that expires at all is earlier than the one that never expires + val info3 = infoEmpty.copy( + selectedDenominations = listOf( + SelectedDenomination(2, denomination2.copy(stampExpireDeposit = Timestamp(NEVER))), + SelectedDenomination(1, denomination1.copy(stampExpireDeposit = Timestamp(1))) + ) + ) + assertEquals(Timestamp(1), info3.getEarliestDepositExpiry()) + } + + @Test + fun testIsWithdrawableDenomination() { + // denomination is withdrawable + assertTrue(denomination1.isWithdrawable()) + // denomination is withdrawable when VerifiedGood + assertTrue(denomination1.copy(status = VerifiedGood).isWithdrawable()) + // fails with VerifiedBad + assertFalse(denomination1.copy(status = VerifiedBad).isWithdrawable()) + // fails when revoked + assertFalse(denomination1.copy(isRevoked = true).isWithdrawable()) + // fails when not started + assertFalse(denomination1.copy(stampStart = Timestamp(Timestamp.now().ms + 9999)).isWithdrawable()) + // fails when expired + assertFalse(denomination1.copy(stampExpireWithdraw = Timestamp.now()).isWithdrawable()) + // fails when almost expired + assertFalse(denomination1.copy(stampExpireWithdraw = Timestamp(Timestamp.now().ms + 5000)).isWithdrawable()) + // succeeds when not quite expired + assertTrue(denomination1.copy(stampExpireWithdraw = Timestamp(Timestamp.now().ms + 51000)).isWithdrawable()) + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/exchange/Denominations.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/exchange/Denominations.kt new file mode 100644 index 0000000..8cfd7fe --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/exchange/Denominations.kt @@ -0,0 +1,119 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.exchange + +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.Timestamp +import net.taler.wallet.kotlin.exchange.DenominationStatus.Unverified +import net.taler.wallet.kotlin.exchange.DenominationStatus.VerifiedGood + +object Denominations { + + private val validStart = Timestamp.now() + private val validExpireWithdraw = + Timestamp(Timestamp.now().ms + 1000L * 60L * 60L * 24L * 365L) + val denomination10 = DenominationRecord( + denomPub = "020000X0X3G1EBB22XJ4HD6R8545R294TMCMA13ZRW7R101KJENFGTNTZSPGA0XP898FJEVHY4SJTC0SM264K0Y7Q6E24S35JSFZXD6VAJDJX8FCERBTNFV5DZR8V4GV7DAD062CPZBEVGNDEJQCTHVFJP84QWVPYJFNZSS3EJEK3WKJVG5EM3X2JPM1C97AB26VSZXWNYNC2CNJN7KG2001", + denomPubHash = "7GB2YKDWKQ3DS2GA9XCVXVPMPJQA9M7Q0DFDHCX5M71J4E2PEHAJK3QF3KTJTWJA33KG0BX6XX0TTRMMZ8CEBM4GSE2N5FSV7GYRGH0", + exchangeBaseUrl = "https://example.org", + feeDeposit = Amount(currency = "TESTKUDOS", fraction = 1000000, value = 0), + feeRefresh = Amount(currency = "TESTKUDOS", fraction = 3000000, value = 0), + feeRefund = Amount(currency = "TESTKUDOS", fraction = 1000000, value = 0), + feeWithdraw = Amount(currency = "TESTKUDOS", fraction = 1000000, value = 0), + isOffered = true, + isRevoked = false, + masterSig = "N9ADAGY43VTDA000NMW9NFKY8HBTTNPEWP9W1A1ATCAFVA1A9F1MD7HAJ6M4QQ3SVYJXCV1S9Z1ZMKXP9YKD3PFGNK0VQD1ZZ502420", + stampExpireDeposit = validExpireWithdraw, + stampExpireLegal = validExpireWithdraw, + stampExpireWithdraw = validExpireWithdraw, + stampStart = validStart, + status = Unverified, + value = Amount(currency = "TESTKUDOS", fraction = 0, value = 10) + ) + val denomination8 = denomination10.copy( + denomPub = "020000X61XTJPQHC9Z3GEF8BD0YTPGH819V6YQDW7MZ74PH9GC2WZGH1YPBHB6F6AJG7C91G19KTSSP9GA64SFZ26T06QK6XEQNREEFV6EMVRCQRYXV3YVA5ANJQRFJVMQG3DQG6Q0ANQWRBZGZSX43MNJQ0NGZY2X1VHJ0351RC83RMPYV1JFSZ2J1JZ2MN5AJF6QMCBBJN0V5TF3EG2001", + denomPubHash = "M496WWEBC8KN4MYB73V78F4DCXXMFWR2X33GWDA92X98MC04E120H9NVNKN70J3NP7ZZ91BE7CX65NYHQEG5EQK5Y78E7ZE3YV8E868", + feeDeposit = Amount(currency = "TESTKUDOS", fraction = 2000000, value = 0), + feeRefresh = Amount(currency = "TESTKUDOS", fraction = 3000000, value = 0), + feeRefund = Amount(currency = "TESTKUDOS", fraction = 4000000, value = 0), + feeWithdraw = Amount(currency = "TESTKUDOS", fraction = 5000000, value = 0), + masterSig = "GNWDZNQHFECF2SPVWZ5D0KV7YEGJ0J86ND815B512ZSBNKSCSN7PCKE5GJTXV0WZNS3AYTDJYER3W1HXSST4TMBMAR3FY3ETRNRS20G", + value = Amount(currency = "TESTKUDOS", fraction = 0, value = 8) + ) + val denomination5 = denomination10.copy( + denomPub = "020000XFF9HD3GJXVA9ARQD76BW2G9K65F6CVDPWSRYVE5HY7EVVBK1PK1XX2HE2P3BA3A9MJT9ESY1XNKK7TTF8DRE33C22EHPNNBPPQC1D1MHEE3YJHNF8PG0W6DTE406R6VHCZ0VHEE5HNTEPWMAHJ5J0VVY1ESGAXWE1SGSY82FCQARWV45MNGYZMBN2M55CG3SQXJ8STRPHEM1G2001", + denomPubHash = "4HKTYHDDCJXJW7Z215E3TFQWBRYKZA4VS41CZKBE8HH1T97FV4X9WSM62Q7WEZTZX3Y60T4Y8M4R0YYA3PVSEND1MZCJBTD4QZDMCJR", + feeDeposit = Amount(currency = "TESTKUDOS", fraction = 1000000, value = 0), + feeRefresh = Amount(currency = "TESTKUDOS", fraction = 3000000, value = 0), + feeRefund = Amount(currency = "TESTKUDOS", fraction = 1000000, value = 0), + feeWithdraw = Amount(currency = "TESTKUDOS", fraction = 1000000, value = 0), + masterSig = "0TBJSTZTWMKBT71ET1B7RVFB1VT84E10G3SENPSMPRYTFBH07WSDS5VA9PW5T0EYEABDKGAYDRQ0XA62GYQXZ4SBV2CTHHFY0TFNC18", + value = Amount(currency = "TESTKUDOS", fraction = 0, value = 5) + ) + val denomination4 = denomination10.copy( + denomPub = "020000Y3JVXB3XTC7JTK3CYEMSXHEX8956DN9B4Z6WB3J1H8D5Y3BVTY8EE5BC5JSRJKM0VAK6BXSHVRGQ6N43BF132DPNSJDG4CD8JA4A856HVCNFSNP0DY21TJYN8GJ36R1T0VKTVH2SRMT4QN1QQZC0VQ5ZV2EJJMCSVYVKV8MZC9NG5K9PGNQKBV64E34H1K9RSFEJE7306ZH07G2001", + denomPubHash = "XRKJ5750TW2ZNQYGQ87TESYF2X942RBCG7EKXB8QKMFGB433EK3567SDSE9EFNBYTEH3PHPTK22V8XNSJ090DHYX8EW9BE1Q8JCZW7G", + feeDeposit = Amount(currency = "TESTKUDOS", fraction = 3000000, value = 0), + feeRefresh = Amount(currency = "TESTKUDOS", fraction = 4000000, value = 0), + feeRefund = Amount(currency = "TESTKUDOS", fraction = 2000000, value = 0), + feeWithdraw = Amount(currency = "TESTKUDOS", fraction = 3000000, value = 0), + masterSig = "VFKWHPFNEKD36A0A0TWVTEN98P4TCVBTRMJFJF1E1Q2578J9DXAY53Y7Q2D3BX01WKPM7QBCVF9WBMB4GTXVFP08QPZP086RV9PAP2G", + value = Amount(currency = "TESTKUDOS", fraction = 0, value = 4) + ) + val denomination2 = denomination10.copy( + denomPub = "020000XB8Y8WVSBM0WBY6NMS0MTNN71PEA6KGKTV5FF27RRR8V1V0GPWYAFDMD2XT6E265QPNH079KM25ZZ97NGFRDVHW0Y7JPWA9C8MJY8DB7APYBRMD9XYA0N1569VFW2NPP4FGQJ865RVE94F35NSG0M4W80CG6WXXWW1ERRM7F2AGRZE9FS049183ANEDFB7QN4H62GDWS7039GG2001", + denomPubHash = "CTH34KGWJKQ3C264DQJCRPX97G5SWGWQQ8EMBXVH4DKGTPJ78ZA5CWZB9SDT538Z6S118VQ3RNX3CVC1YXEFN0PXR0D896MDNCGZW3G", + feeDeposit = Amount(currency = "TESTKUDOS", fraction = 3000000, value = 0), + feeRefresh = Amount(currency = "TESTKUDOS", fraction = 4000000, value = 0), + feeRefund = Amount(currency = "TESTKUDOS", fraction = 2000000, value = 0), + feeWithdraw = Amount(currency = "TESTKUDOS", fraction = 3000000, value = 0), + masterSig = "BTZYYEYBZK1EJJ47MQHEY7Q8ARBK4PT0N92TYEEPEZ54P0NTN6FT50AGKCCQCWQ8J74D8MTZFAX3PRDWSH30JPVREAQKGVGEVYXN00G", + value = Amount(currency = "TESTKUDOS", fraction = 0, value = 2) + ) + val denomination1 = denomination10.copy( + denomPub = "020000XBEVGYETENRYN30AMTT006NPFG5QNHHJXESR9YRZ0J1Y0V00ASMSK7SGKBZJW1GKW05AGNXZDGHAT8RP5M87H2QZTCZHNWW0W4SFWSHWEMRK57DQ3Z7EYQ3XMFX8E2QNZNT9TB4J0MMDP833Y0K7RCQ3X5A584ZBQ9M0T03KTF1QTS2Z7J0BRCEVMK7CCM5WYCAVDPP44Z23HG2001", + denomPubHash = "KHRCTNBZHY653V3WZBNMTGGM3MSS471EZ4F6X32HJMN17A47WBBM5WHCRNK8F27KB6Q45YMEN832BKYNKBK1GCRXKDP0XTYC3CYTRWR", + feeDeposit = Amount(currency = "TESTKUDOS", fraction = 2000000, value = 0), + feeRefresh = Amount(currency = "TESTKUDOS", fraction = 3000000, value = 0), + feeRefund = Amount(currency = "TESTKUDOS", fraction = 1000000, value = 0), + feeWithdraw = Amount(currency = "TESTKUDOS", fraction = 2000000, value = 0), + masterSig = "X029BKSB9PRKE831VDV1X3ND441E0NNQCDJ2S9NKPG7F17R0Y6QCGKWCSHCHE96NW5XS13RK880H90ZSFY7HNJ17F4NSR1EM5FR2M10", + value = Amount(currency = "TESTKUDOS", fraction = 0, value = 1) + ) + val denomination0d1 = denomination10.copy( + denomPub = "020000YVVE91KXE9S8JV5P4PWZJWBJD39Y6S00FA3CV6RP77A8PTKJWNE3W0W8ZHDR3CSKA17FT56PMC97RWV3RNG753B1WQYXEWNJA76GD5T2PA33BN08CQ07KP4M2K9R6A3N9VD6W8D3DK55W18Y7TBKAHCJBQ3AZ50VHSF1ZPM2XVJ238SKK1379WNHMK4VDJQ35H20QSF3GPWPKG2001", + denomPubHash = "C26V15X3BESS1CZCSP4BNSRJ8BK8DSGFHNB0WG1GZ6ZF6FR37WGSVEQ85A61X6Z103P8MY7XGQZ60VAX78V3E5GERWJTJAP0Q5QB7A8", + feeDeposit = Amount(currency = "TESTKUDOS", fraction = 1000000, value = 0), + feeRefresh = Amount(currency = "TESTKUDOS", fraction = 3000000, value = 0), + feeRefund = Amount(currency = "TESTKUDOS", fraction = 1000000, value = 0), + feeWithdraw = Amount(currency = "TESTKUDOS", fraction = 1000000, value = 0), + masterSig = "JSJXWJXN4532C07CBH4DZZ6YC5S3TPH3H8FG8RMGX0Z647GX2NYEK02NRN3C4GDS9Q0ZT6QE7T8EGYGEF9RZ9FCV65TZWC1ZP83DT1R", + value = Amount(currency = "TESTKUDOS", fraction = 10000000, value = 0) + ) + val denomination0d01 = denomination10.copy( + denomPub = "020000X67SMNYMCR1HFZW4KEATXGXRA983JE5VW1JE4108XB7Z40BTJC1MV59Y9K4Y35E3MPPF73BJQA3KVT0FBT89R6ZYNZ77TSC5DMFV5E55DT4JB4S9K4C2V7GM8Z8QZ0KMCH0YK4PAX1WSKCEQFNRVKD3VH9WTVQ0CNV7Z1JVRHBKCSZNX62TRQ6JRZ05JEANT3C41SQ6MKSQZKG2001", + denomPubHash = "VS0E7ABRZ9NZMTDAHVKQ4QH1S1Q2PYWXFXKF0P84VSHCDBM4S6QK1G1D495TABN4AXVX049P7JESNRRQVHW37BNER4XKZSQT3XA61DG", + feeDeposit = Amount(currency = "TESTKUDOS", fraction = 1000000, value = 0), + feeRefresh = Amount(currency = "TESTKUDOS", fraction = 1000000, value = 0), + feeRefund = Amount(currency = "TESTKUDOS", fraction = 1000000, value = 0), + feeWithdraw = Amount(currency = "TESTKUDOS", fraction = 1000000, value = 0), + masterSig = "CF3638K8QBG91D66JZRRGWV8J53A0ZGBYJDMKYK293DNAA2ASM1M346C0YG9V7HWH8E2A7FPVR0HH2QE7DHD9GW0EN19VSER4S5F23R", + status = VerifiedGood, + value = Amount(currency = "TESTKUDOS", fraction = 1000000, value = 0) + ) + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/exchange/KeysTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/exchange/KeysTest.kt new file mode 100644 index 0000000..a6b0c98 --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/exchange/KeysTest.kt @@ -0,0 +1,316 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.exchange + +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.Timestamp +import net.taler.wallet.kotlin.getMockHttpClient +import net.taler.wallet.kotlin.giveJsonResponse +import net.taler.wallet.kotlin.runCoroutine +import kotlin.test.Test +import kotlin.test.assertEquals + +class KeysTest { + + private val httpClient = getMockHttpClient() + + @Test + fun testFetchKeys() { + val expectedKeys = Keys( + denoms = listOf( + Denomination( + value = Amount.fromJSONString("TESTKUDOS:5"), + denom_pub = "040000Z9TH9RPTA1BXF6Z89HM7JGXTPD5G8NNBWQWF7RWQGNAATN84QBWME1TGSWZ79WPQ62S2W2VHG2XBH66JDJ0KM8Q2FQ3FGBZQGNJVFNA9F66E6S3P36KTMWMKWDXWM9EX1YHSHQ841AHRR8JVDY96CZ13AJF6JW95K59AE8CSTH5ZS9NVS0102X92GK8JW2QX2S4EE25QNHK6XMXH3944QMXPFS7SFCMV623BM62VNPVX8JM424YXPJ09TXZAH2CF3QM5HDVRSTDRDGVBF6KZVRFM852TMVMYPVGFA9YQF6HWNJ8H5VCQ3Z9WWNMQ3T76X4F1P6W2J266K8B3W9HKW2WJNK3XHRAVC4GCF07TC0ZNAT0EDAAKV429YAXWSK952BPTY98GVP5XZQG2SE0Q5CF3PV04002", + fee_withdraw = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_deposit = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_refresh = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_refund = Amount.fromJSONString("TESTKUDOS:0.01"), + stamp_start = Timestamp(1582484881000), + stamp_expire_withdraw = Timestamp(1677092881000), + stamp_expire_legal = Timestamp(1897844881000), + stamp_expire_deposit = Timestamp(1740164881000), + master_sig = "2SDD44VVBD52XEV0A9R878BC60J51VKK0H5ZS6CPJ7Z738A8V4KPXCF70KFZAY2567400C2GEWNNVXF6PYD7HKX3D2M63WCNPJSE010" + ), + Denomination( + value = Amount.fromJSONString("TESTKUDOS:2"), + denom_pub = "040000XV91V0M7H906Y7R371YX2XAK1V5B2TRFD8ZM9WYJ495TP08NCVEDNFXS2KZBJR4808VZ52PNNQSYVQ2T3J7867MZQY1QZ9N8YQWQWCKSYAY8A07E5SYAK0G0KRTCN5VZ7JXE2YCNT7Q3RT9TGAZBSK5V1ZRRK6HX4C1YFKPWWP4TBVJ8DJMS43WKR4CR4S9T02YXVGR6GSDMR7GHBD89JHCEQ8V2K58Y5XVDGRRQYNBG9Q5XWDMV7GKN24JCPCEKSZZP5XYPXYJX2Z2JZ179M9FQV0PEWFJ4DP7AP14XE54FH97YP9398KA31ECVDY7PHMKMZ6E79Z9FSCXH3WSCXMHBWFGWPRG5ZG1P6HR71VKH4F0Y998JNE4G40JH2VSXP3035AR7HAMJGB56CYHH60EWD904002", + fee_withdraw = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_deposit = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_refresh = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_refund = Amount.fromJSONString("TESTKUDOS:0.01"), + stamp_start = Timestamp(1582484881000), + stamp_expire_withdraw = Timestamp(1677092881000), + stamp_expire_legal = Timestamp(1897844881000), + stamp_expire_deposit = Timestamp(1740164881000), + master_sig = "WX1RDTW1W1K7FPFQQ9MCJ8V5CJP8ZX2SGHQG2YS34BY8AMSR3YQ92HE2HT1JMP4W06J63RZ0BR2MKDBX54GV6QKD4R00N9HXN2S283R" + ), + Denomination( + value = Amount.fromJSONString("TESTKUDOS:1"), + denom_pub = "040000XCBJE9TDDZATYSDR51D0DKMY5NW8FMJ8YQ1Y4F40SPPTKFMD3FWH38NSQZ1YB621TCFH5RBN5J3SFR5SG4789G27FA90E605GG9AXYTXXPJ9HYPAVAVS6V4XCSC17HKX2M2NSX5D0PPETDGKQD04G498VS36YY4WTB5SYG4SV9MKPVZ5WG2WNP3MA77TFZSHK5HBHZBEW0S1TRKGSCDNBRHYB240M84YM1Y7EJ7BXKJK4GRR1GS16DJ2RA1YEQ1AAXH0GP6RRAEJE8D2JFSH05P3KR1GB97NMX6VD8DCAX45416F888EYQR4M6R820FJVZ6FYV9CCMZ3M10B64N6G4QFNKFNAV2ENPVVG4A3R0AAA6STJ7E5Z05GEKW35SHM14HY9CEGM7D1ZEKHZJYA9P6WH504002", + fee_withdraw = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_deposit = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_refresh = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_refund = Amount.fromJSONString("TESTKUDOS:0.01"), + stamp_start = Timestamp(1582484881000), + stamp_expire_withdraw = Timestamp(1677092881000), + stamp_expire_legal = Timestamp(1897844881000), + stamp_expire_deposit = Timestamp(1740164881000), + master_sig = "65G9FWQPA4YKJEM7D37079D4MY81D47KD1280RG7BRH85XZQ2N13FJPV9N8AEASK9CTGNX1HKX0GTRBJ5C49H4YRY0E4CYVPNH06W18" + ), + Denomination( + value = Amount.fromJSONString("TESTKUDOS:10"), + denom_pub = "040000XZDZK4BPPPXR7MYKK2AF4WF95EH3VF8WEX7WDX4HEWXSB5XX10N4V5RHFSK0TSBKNC9CRNVGK3WJ42S3Z9SB4Q3M4DQQ7DKCGKED6WBKENHT8JX51K1VR5JKCMAFBNM6DR5MNRGKFC2MDRQ0Y4BCXHKEMRD65C6JPBKYW9HJH66FGT22WMBV0AV7P60CKR13MQG6FKWW3TZW3XXHVY2VX9MJN6VQFPS6NQGGTNXZV2SK2X5MJAJME7RN9BNZ5ZBTW1CYMVCHBSVGBFPRC68W78PW44VP402VD12KG2AWKPD4DRBAA85HM1DN1KADYQ498QHYGEB3T3HH990HRV8PSNBGYCHB87JTVYMJ4N2PSP2FCX0H6FRTW1FQY05EB7D8BFXM95DNRCHVQSHBZ9RP7NZFA304002", + fee_withdraw = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_deposit = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_refresh = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_refund = Amount.fromJSONString("TESTKUDOS:0.01"), + stamp_start = Timestamp(1582484881000), + stamp_expire_withdraw = Timestamp(1677092881000), + stamp_expire_legal = Timestamp(1897844881000), + stamp_expire_deposit = Timestamp(1740164881000), + master_sig = "M423J7CJPACTPBYCFVR87B44JAJKAB2ME8C263WGHJSA8V8444SX428MVC9NF4GD08CKS9HY0WB4B8SEZ3HJFWKXNSH80RBJXQC822G" + ), + Denomination( + value = Amount.fromJSONString("TESTKUDOS:0.1"), + denom_pub = "040000YKYFF6GX979JS10MEZ16BQ7TT6XBTE0TBX6VJ9KSG7K4D91SWJVDETNKQJXAFK9SAB3S31FZFA0Y0X22TKRXKCT7Z4GZCCRJJ12T1A5M4DWRTZDFRD3FE495NXHVPFM96KXMKH1HABTDDFZN0NWQ3NBJ6GNXD40NJ95E955X948JHBDJZWM3TEAK4XFJX8056XFDHVNXSF4VN14RR1WD1J5K7JPS61SKRNF3HT6NZA823PZW2KPV2KVBMMP615A922ZNJGVQDTW5TYWTK5DCBGG1YEKQRYF39NX9X722FZK98BTMHHH6WZFCKBT096G9BKSHSJW3VE8KKPCN8XGWYYPD3158HRKSA28BJQ9XJVVB6FDCGZ154WWGGSGW82BDYDH7ZHJBMS046AG0ND4ZCVR2JQ04002", + fee_withdraw = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_deposit = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_refresh = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_refund = Amount.fromJSONString("TESTKUDOS:0.01"), + stamp_start = Timestamp(1582484881000), + stamp_expire_withdraw = Timestamp(1677092881000), + stamp_expire_legal = Timestamp(1897844881000), + stamp_expire_deposit = Timestamp(1740164881000), + master_sig = "RKZKGR0KYKY0VZ26339DZKV8EZJ2HRRQMFSAJDHBG3YHEQNZFHKM8WPYCH9WHXTWBB10GQN9QJKFDJJF2H6D5FT801GF87G153PTJ18" + ), + Denomination( + value = Amount.fromJSONString("TESTKUDOS:1000"), + denom_pub = "040000Y9PBY1HPBDD4KSK9PBA86TRY13JQ4284T4E0G4SEREQ8YM88PZHKW1ACKT1RTWVTBXX83G54NFVYRJQX9PTDXDJ1CXSS42G8NYMW97NA6NNNASV69W1JX39NTS1NVKXPW4WMBASATSNBTXHRT92FFN2NAJFGK876BNN3TPTH57C76ADAQV43VFF7CYAWWNYZAYGQQ1XY1NK34FJD778VFGYCZ1G9J8XPNB92ZKJBZEZKSNBRNH27GM5A736AFSGP7B4JSCGD0F4FMD1PDVB26MM9ZK8C1TDKXQ5DJ09AQQ55P7Q3A133ASPGBH6SCJTJYH8C9A451B0SP4GDX2ZFRSX5FP93PY4VKEB36KCAQ5E2MRZNWFB6T0JK0W7Z7NXP5FW2VQ4PNV7B2NQ3WFMCVRSDSV04002", + fee_withdraw = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_deposit = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_refresh = Amount.fromJSONString("TESTKUDOS:0.01"), + fee_refund = Amount.fromJSONString("TESTKUDOS:0.01"), + stamp_start = Timestamp(1582484881000), + stamp_expire_withdraw = Timestamp(1677092881000), + stamp_expire_legal = Timestamp(1897844881000), + stamp_expire_deposit = Timestamp(1740164881000), + master_sig = "FJPQKECRKVQSTB9Y983KDGD65Z1JHQKNSCC6YPMBN3Z4VW0AGC5MQM9BPB0YYD1SCMETPD6QB4X80HWE0ZDGWNZB1KND5TP567T4G3G" + ) + ), + master_public_key = "DY95EXAHQ2BKM2WK9YHZHYG1R7PPMMJPY14FNGP662DAKE35AKQG", + auditors = emptyList(), + list_issue_date = Timestamp(1592161681000), + recoup = emptyList(), + signkeys = listOf( + SigningKey( + stamp_start = Timestamp(1592161681000), + stamp_expire = Timestamp(1594580881000), + stamp_end = Timestamp(1655233681000), + key = "0FMRBH8FZYYMSQ2RHTYYGK2BV33JVSW6MTYCV7Y833GVNXFDYK10", + master_sig = "368HV41Z4FNDXQ7EP6TNAMBSKP44PJAZW27EPH7XJNVG2A6HZQM7ZPMCB6B30HG50S95YD1K2BAJVPEYMGF2DR7EEY0NFBQZZ1B8P1G" + ), + SigningKey( + stamp_start = Timestamp(1594580881000), + stamp_expire = Timestamp(1597000081000), + stamp_end = Timestamp(1657652881000), + key = "XMNYM62DQW0XDQACCYDMFTM5GY7SZST60NH7XS9GY18H8Q9N7QN0", + master_sig = "4HRJN36VVJ87ZC2HZXP7QDSZN30YQE8FCNWZS3RCA1HGNY9Q0JPMVJZ79RDHKS4GYXV29PM27DGCN0VB0BCZFF2FC6FMF3A6ZNKC238" + ) + ), + version = "7:0:0" + ) + + httpClient.giveJsonResponse("https://exchange.test.taler.net/keys") { + """{ + "version": "7:0:0", + "master_public_key": "DY95EXAHQ2BKM2WK9YHZHYG1R7PPMMJPY14FNGP662DAKE35AKQG", + "reserve_closing_delay": { + "d_ms": 2419200000 + }, + "signkeys": [ + { + "stamp_start": { + "t_ms": 1592161681000 + }, + "stamp_expire": { + "t_ms": 1594580881000 + }, + "stamp_end": { + "t_ms": 1655233681000 + }, + "master_sig": "368HV41Z4FNDXQ7EP6TNAMBSKP44PJAZW27EPH7XJNVG2A6HZQM7ZPMCB6B30HG50S95YD1K2BAJVPEYMGF2DR7EEY0NFBQZZ1B8P1G", + "key": "0FMRBH8FZYYMSQ2RHTYYGK2BV33JVSW6MTYCV7Y833GVNXFDYK10" + }, + { + "stamp_start": { + "t_ms": 1594580881000 + }, + "stamp_expire": { + "t_ms": 1597000081000 + }, + "stamp_end": { + "t_ms": 1657652881000 + }, + "master_sig": "4HRJN36VVJ87ZC2HZXP7QDSZN30YQE8FCNWZS3RCA1HGNY9Q0JPMVJZ79RDHKS4GYXV29PM27DGCN0VB0BCZFF2FC6FMF3A6ZNKC238", + "key": "XMNYM62DQW0XDQACCYDMFTM5GY7SZST60NH7XS9GY18H8Q9N7QN0" + } + ], + "recoup": [], + "denoms": [ + { + "master_sig": "2SDD44VVBD52XEV0A9R878BC60J51VKK0H5ZS6CPJ7Z738A8V4KPXCF70KFZAY2567400C2GEWNNVXF6PYD7HKX3D2M63WCNPJSE010", + "stamp_start": { + "t_ms": 1582484881000 + }, + "stamp_expire_withdraw": { + "t_ms": 1677092881000 + }, + "stamp_expire_deposit": { + "t_ms": 1740164881000 + }, + "stamp_expire_legal": { + "t_ms": 1897844881000 + }, + "denom_pub": "040000Z9TH9RPTA1BXF6Z89HM7JGXTPD5G8NNBWQWF7RWQGNAATN84QBWME1TGSWZ79WPQ62S2W2VHG2XBH66JDJ0KM8Q2FQ3FGBZQGNJVFNA9F66E6S3P36KTMWMKWDXWM9EX1YHSHQ841AHRR8JVDY96CZ13AJF6JW95K59AE8CSTH5ZS9NVS0102X92GK8JW2QX2S4EE25QNHK6XMXH3944QMXPFS7SFCMV623BM62VNPVX8JM424YXPJ09TXZAH2CF3QM5HDVRSTDRDGVBF6KZVRFM852TMVMYPVGFA9YQF6HWNJ8H5VCQ3Z9WWNMQ3T76X4F1P6W2J266K8B3W9HKW2WJNK3XHRAVC4GCF07TC0ZNAT0EDAAKV429YAXWSK952BPTY98GVP5XZQG2SE0Q5CF3PV04002", + "value": "TESTKUDOS:5", + "fee_withdraw": "TESTKUDOS:0.01", + "fee_deposit": "TESTKUDOS:0.01", + "fee_refresh": "TESTKUDOS:0.01", + "fee_refund": "TESTKUDOS:0.01" + }, + { + "master_sig": "WX1RDTW1W1K7FPFQQ9MCJ8V5CJP8ZX2SGHQG2YS34BY8AMSR3YQ92HE2HT1JMP4W06J63RZ0BR2MKDBX54GV6QKD4R00N9HXN2S283R", + "stamp_start": { + "t_ms": 1582484881000 + }, + "stamp_expire_withdraw": { + "t_ms": 1677092881000 + }, + "stamp_expire_deposit": { + "t_ms": 1740164881000 + }, + "stamp_expire_legal": { + "t_ms": 1897844881000 + }, + "denom_pub": "040000XV91V0M7H906Y7R371YX2XAK1V5B2TRFD8ZM9WYJ495TP08NCVEDNFXS2KZBJR4808VZ52PNNQSYVQ2T3J7867MZQY1QZ9N8YQWQWCKSYAY8A07E5SYAK0G0KRTCN5VZ7JXE2YCNT7Q3RT9TGAZBSK5V1ZRRK6HX4C1YFKPWWP4TBVJ8DJMS43WKR4CR4S9T02YXVGR6GSDMR7GHBD89JHCEQ8V2K58Y5XVDGRRQYNBG9Q5XWDMV7GKN24JCPCEKSZZP5XYPXYJX2Z2JZ179M9FQV0PEWFJ4DP7AP14XE54FH97YP9398KA31ECVDY7PHMKMZ6E79Z9FSCXH3WSCXMHBWFGWPRG5ZG1P6HR71VKH4F0Y998JNE4G40JH2VSXP3035AR7HAMJGB56CYHH60EWD904002", + "value": "TESTKUDOS:2", + "fee_withdraw": "TESTKUDOS:0.01", + "fee_deposit": "TESTKUDOS:0.01", + "fee_refresh": "TESTKUDOS:0.01", + "fee_refund": "TESTKUDOS:0.01" + }, + { + "master_sig": "65G9FWQPA4YKJEM7D37079D4MY81D47KD1280RG7BRH85XZQ2N13FJPV9N8AEASK9CTGNX1HKX0GTRBJ5C49H4YRY0E4CYVPNH06W18", + "stamp_start": { + "t_ms": 1582484881000 + }, + "stamp_expire_withdraw": { + "t_ms": 1677092881000 + }, + "stamp_expire_deposit": { + "t_ms": 1740164881000 + }, + "stamp_expire_legal": { + "t_ms": 1897844881000 + }, + "denom_pub": "040000XCBJE9TDDZATYSDR51D0DKMY5NW8FMJ8YQ1Y4F40SPPTKFMD3FWH38NSQZ1YB621TCFH5RBN5J3SFR5SG4789G27FA90E605GG9AXYTXXPJ9HYPAVAVS6V4XCSC17HKX2M2NSX5D0PPETDGKQD04G498VS36YY4WTB5SYG4SV9MKPVZ5WG2WNP3MA77TFZSHK5HBHZBEW0S1TRKGSCDNBRHYB240M84YM1Y7EJ7BXKJK4GRR1GS16DJ2RA1YEQ1AAXH0GP6RRAEJE8D2JFSH05P3KR1GB97NMX6VD8DCAX45416F888EYQR4M6R820FJVZ6FYV9CCMZ3M10B64N6G4QFNKFNAV2ENPVVG4A3R0AAA6STJ7E5Z05GEKW35SHM14HY9CEGM7D1ZEKHZJYA9P6WH504002", + "value": "TESTKUDOS:1", + "fee_withdraw": "TESTKUDOS:0.01", + "fee_deposit": "TESTKUDOS:0.01", + "fee_refresh": "TESTKUDOS:0.01", + "fee_refund": "TESTKUDOS:0.01" + }, + { + "master_sig": "M423J7CJPACTPBYCFVR87B44JAJKAB2ME8C263WGHJSA8V8444SX428MVC9NF4GD08CKS9HY0WB4B8SEZ3HJFWKXNSH80RBJXQC822G", + "stamp_start": { + "t_ms": 1582484881000 + }, + "stamp_expire_withdraw": { + "t_ms": 1677092881000 + }, + "stamp_expire_deposit": { + "t_ms": 1740164881000 + }, + "stamp_expire_legal": { + "t_ms": 1897844881000 + }, + "denom_pub": "040000XZDZK4BPPPXR7MYKK2AF4WF95EH3VF8WEX7WDX4HEWXSB5XX10N4V5RHFSK0TSBKNC9CRNVGK3WJ42S3Z9SB4Q3M4DQQ7DKCGKED6WBKENHT8JX51K1VR5JKCMAFBNM6DR5MNRGKFC2MDRQ0Y4BCXHKEMRD65C6JPBKYW9HJH66FGT22WMBV0AV7P60CKR13MQG6FKWW3TZW3XXHVY2VX9MJN6VQFPS6NQGGTNXZV2SK2X5MJAJME7RN9BNZ5ZBTW1CYMVCHBSVGBFPRC68W78PW44VP402VD12KG2AWKPD4DRBAA85HM1DN1KADYQ498QHYGEB3T3HH990HRV8PSNBGYCHB87JTVYMJ4N2PSP2FCX0H6FRTW1FQY05EB7D8BFXM95DNRCHVQSHBZ9RP7NZFA304002", + "value": "TESTKUDOS:10", + "fee_withdraw": "TESTKUDOS:0.01", + "fee_deposit": "TESTKUDOS:0.01", + "fee_refresh": "TESTKUDOS:0.01", + "fee_refund": "TESTKUDOS:0.01" + }, + { + "master_sig": "RKZKGR0KYKY0VZ26339DZKV8EZJ2HRRQMFSAJDHBG3YHEQNZFHKM8WPYCH9WHXTWBB10GQN9QJKFDJJF2H6D5FT801GF87G153PTJ18", + "stamp_start": { + "t_ms": 1582484881000 + }, + "stamp_expire_withdraw": { + "t_ms": 1677092881000 + }, + "stamp_expire_deposit": { + "t_ms": 1740164881000 + }, + "stamp_expire_legal": { + "t_ms": 1897844881000 + }, + "denom_pub": "040000YKYFF6GX979JS10MEZ16BQ7TT6XBTE0TBX6VJ9KSG7K4D91SWJVDETNKQJXAFK9SAB3S31FZFA0Y0X22TKRXKCT7Z4GZCCRJJ12T1A5M4DWRTZDFRD3FE495NXHVPFM96KXMKH1HABTDDFZN0NWQ3NBJ6GNXD40NJ95E955X948JHBDJZWM3TEAK4XFJX8056XFDHVNXSF4VN14RR1WD1J5K7JPS61SKRNF3HT6NZA823PZW2KPV2KVBMMP615A922ZNJGVQDTW5TYWTK5DCBGG1YEKQRYF39NX9X722FZK98BTMHHH6WZFCKBT096G9BKSHSJW3VE8KKPCN8XGWYYPD3158HRKSA28BJQ9XJVVB6FDCGZ154WWGGSGW82BDYDH7ZHJBMS046AG0ND4ZCVR2JQ04002", + "value": "TESTKUDOS:0.1", + "fee_withdraw": "TESTKUDOS:0.01", + "fee_deposit": "TESTKUDOS:0.01", + "fee_refresh": "TESTKUDOS:0.01", + "fee_refund": "TESTKUDOS:0.01" + }, + { + "master_sig": "FJPQKECRKVQSTB9Y983KDGD65Z1JHQKNSCC6YPMBN3Z4VW0AGC5MQM9BPB0YYD1SCMETPD6QB4X80HWE0ZDGWNZB1KND5TP567T4G3G", + "stamp_start": { + "t_ms": 1582484881000 + }, + "stamp_expire_withdraw": { + "t_ms": 1677092881000 + }, + "stamp_expire_deposit": { + "t_ms": 1740164881000 + }, + "stamp_expire_legal": { + "t_ms": 1897844881000 + }, + "denom_pub": "040000Y9PBY1HPBDD4KSK9PBA86TRY13JQ4284T4E0G4SEREQ8YM88PZHKW1ACKT1RTWVTBXX83G54NFVYRJQX9PTDXDJ1CXSS42G8NYMW97NA6NNNASV69W1JX39NTS1NVKXPW4WMBASATSNBTXHRT92FFN2NAJFGK876BNN3TPTH57C76ADAQV43VFF7CYAWWNYZAYGQQ1XY1NK34FJD778VFGYCZ1G9J8XPNB92ZKJBZEZKSNBRNH27GM5A736AFSGP7B4JSCGD0F4FMD1PDVB26MM9ZK8C1TDKXQ5DJ09AQQ55P7Q3A133ASPGBH6SCJTJYH8C9A451B0SP4GDX2ZFRSX5FP93PY4VKEB36KCAQ5E2MRZNWFB6T0JK0W7Z7NXP5FW2VQ4PNV7B2NQ3WFMCVRSDSV04002", + "value": "TESTKUDOS:1000", + "fee_withdraw": "TESTKUDOS:0.01", + "fee_deposit": "TESTKUDOS:0.01", + "fee_refresh": "TESTKUDOS:0.01", + "fee_refund": "TESTKUDOS:0.01" + } + ], + "auditors": [], + "list_issue_date": { + "t_ms": 1592161681000 + }, + "eddsa_pub": "0FMRBH8FZYYMSQ2RHTYYGK2BV33JVSW6MTYCV7Y833GVNXFDYK10", + "eddsa_sig": "2GB384567SZM9CM7RJT51N04D2ZK7NAHWZRT6BA0FFNXTAB71D4T1WVQTXZEPDM07X1MJ46ZBC189SCM4EG4V8TQJRP2WAZCKPAJJ2R" + }""".trimIndent() + } + runCoroutine { + val keys = Keys.fetch(httpClient, "https://exchange.test.taler.net/") + assertEquals(expectedKeys, keys) + } + } + + +}
\ No newline at end of file diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/exchange/UpdateTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/exchange/UpdateTest.kt new file mode 100644 index 0000000..271dc09 --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/exchange/UpdateTest.kt @@ -0,0 +1,37 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.exchange + +import net.taler.wallet.kotlin.runCoroutine +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertTrue + +class UpdateTest { + + private val exchange = Exchange() + + @Ignore // live test that requires internet connectivity + @Test + fun testLiveUpdate() { + runCoroutine { + val record = exchange.updateFromUrl("http://exchange.test.taler.net/") + assertTrue(record.addComplete) + } + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/exchange/WireTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/exchange/WireTest.kt new file mode 100644 index 0000000..d09b44b --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/exchange/WireTest.kt @@ -0,0 +1,175 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.exchange + +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.Timestamp +import net.taler.wallet.kotlin.getMockHttpClient +import net.taler.wallet.kotlin.giveJsonResponse +import net.taler.wallet.kotlin.runCoroutine +import kotlin.test.Test +import kotlin.test.assertEquals + +class WireTest { + + private val httpClient = getMockHttpClient() + + @Test + fun testFetchWire() { + val expectedWire = Wire( + accounts = listOf( + AccountInfo( + paytoUri = "payto://x-taler-bank/bank.test.taler.net/Exchange", + masterSig = "5DMYMQCEFWG7B21RAX8XQ585V689K8DSSR065F04E2JK6G9AF1WM8EVDCHHBMVWRY3P02EWEE4M6YVKPY6B43H2CPCWHDP13RM1WR10" + ) + ), + fees = mapOf( + "x-taler-bank" to listOf( + WireFee( + wireFee = Amount.fromJSONString("TESTKUDOS:0.04"), + closingFee = Amount.fromJSONString("TESTKUDOS:0.01"), + startStamp = Timestamp(1577833200000), + endStamp = Timestamp(1609455600000), + signature = "9DS6TXPTM8ZBBTJS9VCRSD9FVS56ZY9EVWCF4HDA3Y2DNWSVMGS7XXPWE295EZ3E98KVV1SWDJ11CP0A0VDSRDZTM6RD2RPRG19ZA2G" + ), + WireFee( + wireFee = Amount.fromJSONString("TESTKUDOS:0.04"), + closingFee = Amount.fromJSONString("TESTKUDOS:0.01"), + startStamp = Timestamp(1609455600000), + endStamp = Timestamp(1640991600000), + signature = "81852REBNR3ZRQHKQ2FPT6CPACED0MA0CW4V9CPDS3NP2JX6X8BE5YE5W1AKR9XPASEXSKQH6FEXHP7VJB64XWA7FDGH5DKCD3Q700G" + ), + WireFee( + wireFee = Amount.fromJSONString("TESTKUDOS:0.05"), + closingFee = Amount.fromJSONString("TESTKUDOS:0.01"), + startStamp = Timestamp(1640991600000), + endStamp = Timestamp(1672527600000), + signature = "REYMSGH4QBNF339Q8TD5VJMMY6BV7KFTC1Y69YD69Y9E8Z5HXGNAKCQKT490MHBSF48894YADT1ATGDMSRZAQJJFVXF6HX9JEYDT61G" + ), + WireFee( + wireFee = Amount.fromJSONString("TESTKUDOS:0.06"), + closingFee = Amount.fromJSONString("TESTKUDOS:0.01"), + startStamp = Timestamp(1672527600000), + endStamp = Timestamp(1704063600000), + signature = "BXB47D936XT7XDHGA3VA3461CY1GMQWFPVMSBY01N5SN6PBCGYRS8HSY19FJ0P5HVX3TGS9TAHY9X7RP4BQHPM4DMMS30TJ0EKG5A3G" + ), + WireFee( + wireFee = Amount.fromJSONString("TESTKUDOS:0.07"), + closingFee = Amount.fromJSONString("TESTKUDOS:0.01"), + startStamp = Timestamp(1704063600000), + endStamp = Timestamp(1735686000000), + signature = "RFF1KV54BH9TJ8KBE8YEY8DM0R468PZYGW82G16P97EDHNN3XZVN4KK5E9CBZQ730WPJT0RKR3TTYPBWGTR0YQ064XZZDHJHHZN1418" + ), + WireFee( + wireFee = Amount.fromJSONString("TESTKUDOS:0.08"), + closingFee = Amount.fromJSONString("TESTKUDOS:0.01"), + startStamp = Timestamp(1735686000000), + endStamp = Timestamp(1767222000000), + signature = "Q89VKJ54MF3DVG0NKK4N6VB96NCT0PRSTNBJ0SSB42SQTHB10JC68XJSDM6PRBBPEJ8CHDE9VVRZWW20VFSZFDTRA332JKDSBBFWY1G" + ) + ) + ) + ) + httpClient.giveJsonResponse("https://exchange.test.taler.net/wire") { + """{ + "accounts": [ + { + "payto_uri": "payto://x-taler-bank/bank.test.taler.net/Exchange", + "master_sig": "5DMYMQCEFWG7B21RAX8XQ585V689K8DSSR065F04E2JK6G9AF1WM8EVDCHHBMVWRY3P02EWEE4M6YVKPY6B43H2CPCWHDP13RM1WR10" + } + ], + "fees": { + "x-taler-bank": [ + { + "wire_fee": "TESTKUDOS:0.04", + "closing_fee": "TESTKUDOS:0.01", + "start_date": { + "t_ms": 1577833200000 + }, + "end_date": { + "t_ms": 1609455600000 + }, + "sig": "9DS6TXPTM8ZBBTJS9VCRSD9FVS56ZY9EVWCF4HDA3Y2DNWSVMGS7XXPWE295EZ3E98KVV1SWDJ11CP0A0VDSRDZTM6RD2RPRG19ZA2G" + }, + { + "wire_fee": "TESTKUDOS:0.04", + "closing_fee": "TESTKUDOS:0.01", + "start_date": { + "t_ms": 1609455600000 + }, + "end_date": { + "t_ms": 1640991600000 + }, + "sig": "81852REBNR3ZRQHKQ2FPT6CPACED0MA0CW4V9CPDS3NP2JX6X8BE5YE5W1AKR9XPASEXSKQH6FEXHP7VJB64XWA7FDGH5DKCD3Q700G" + }, + { + "wire_fee": "TESTKUDOS:0.05", + "closing_fee": "TESTKUDOS:0.01", + "start_date": { + "t_ms": 1640991600000 + }, + "end_date": { + "t_ms": 1672527600000 + }, + "sig": "REYMSGH4QBNF339Q8TD5VJMMY6BV7KFTC1Y69YD69Y9E8Z5HXGNAKCQKT490MHBSF48894YADT1ATGDMSRZAQJJFVXF6HX9JEYDT61G" + }, + { + "wire_fee": "TESTKUDOS:0.06", + "closing_fee": "TESTKUDOS:0.01", + "start_date": { + "t_ms": 1672527600000 + }, + "end_date": { + "t_ms": 1704063600000 + }, + "sig": "BXB47D936XT7XDHGA3VA3461CY1GMQWFPVMSBY01N5SN6PBCGYRS8HSY19FJ0P5HVX3TGS9TAHY9X7RP4BQHPM4DMMS30TJ0EKG5A3G" + }, + { + "wire_fee": "TESTKUDOS:0.07", + "closing_fee": "TESTKUDOS:0.01", + "start_date": { + "t_ms": 1704063600000 + }, + "end_date": { + "t_ms": 1735686000000 + }, + "sig": "RFF1KV54BH9TJ8KBE8YEY8DM0R468PZYGW82G16P97EDHNN3XZVN4KK5E9CBZQ730WPJT0RKR3TTYPBWGTR0YQ064XZZDHJHHZN1418" + }, + { + "wire_fee": "TESTKUDOS:0.08", + "closing_fee": "TESTKUDOS:0.01", + "start_date": { + "t_ms": 1735686000000 + }, + "end_date": { + "t_ms": 1767222000000 + }, + "sig": "Q89VKJ54MF3DVG0NKK4N6VB96NCT0PRSTNBJ0SSB42SQTHB10JC68XJSDM6PRBBPEJ8CHDE9VVRZWW20VFSZFDTRA332JKDSBBFWY1G" + } + ] + }, + "master_public_key": "DY95EXAHQ2BKM2WK9YHZHYG1R7PPMMJPY14FNGP662DAKE35AKQG" + }""".trimIndent() + } + + runCoroutine { + val wire = Wire.fetch(httpClient, "https://exchange.test.taler.net/") + assertEquals(expectedWire, wire) + } + } + +} diff --git a/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/operations/WithdrawTest.kt b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/operations/WithdrawTest.kt new file mode 100644 index 0000000..541f24f --- /dev/null +++ b/wallet/src/commonTest/kotlin/net/taler/wallet/kotlin/operations/WithdrawTest.kt @@ -0,0 +1,140 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.operations + +import net.taler.wallet.kotlin.Amount +import net.taler.wallet.kotlin.exchange.DenominationSelectionInfo +import net.taler.wallet.kotlin.exchange.Denominations.denomination0d01 +import net.taler.wallet.kotlin.exchange.Denominations.denomination0d1 +import net.taler.wallet.kotlin.exchange.Denominations.denomination1 +import net.taler.wallet.kotlin.exchange.Denominations.denomination10 +import net.taler.wallet.kotlin.exchange.Denominations.denomination2 +import net.taler.wallet.kotlin.exchange.Denominations.denomination4 +import net.taler.wallet.kotlin.exchange.Denominations.denomination5 +import net.taler.wallet.kotlin.exchange.Denominations.denomination8 +import net.taler.wallet.kotlin.exchange.SelectedDenomination +import net.taler.wallet.kotlin.getMockHttpClient +import net.taler.wallet.kotlin.giveJsonResponse +import net.taler.wallet.kotlin.operations.Withdraw.BankDetails +import net.taler.wallet.kotlin.runCoroutine +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class WithdrawTest { + + private val httpClient = getMockHttpClient() + private val withdraw = Withdraw(httpClient) + + @Ignore // live test that requires internet connectivity and a working exchange + @Test + fun testLiveUpdate() { + runCoroutine { + val withdraw = Withdraw() // use own instance without mocked HTTP client + val url = "http://exchange.test.taler.net/" + val amount = Amount("TESTKUDOS", 5, 0) + val details = withdraw.getWithdrawalDetails(url, amount) + assertEquals(url, details.exchange.baseUrl) + } + } + + @Test + fun getBankWithdrawalInfo() { + val bankDetails = BankDetails( + amount = Amount.fromJSONString("TESTKUDOS:5"), + confirmTransferUrl = "https://bank.test.taler.net/confirm-withdrawal/9b51c1dd-db41-4b5f-97d9-1071d5dd8091", + extractedStatusUrl = "https://bank.test.taler.net/api/withdraw-operation/9b51c1dd-db41-4b5f-97d9-1071d5dd8091", + selectionDone = false, + senderPaytoUri = "payto://x-taler-bank/bank.test.taler.net/test", + suggestedExchange = "https://exchange.test.taler.net/", + transferDone = false, + wireTypes = listOf("x-taler-bank") + ) + httpClient.giveJsonResponse(bankDetails.extractedStatusUrl) { + """{ + "selection_done": ${bankDetails.selectionDone}, + "transfer_done": ${bankDetails.transferDone}, + "amount": "${bankDetails.amount.toJSONString()}", + "wire_types": ["${bankDetails.wireTypes[0]}"], + "sender_wire": "${bankDetails.senderPaytoUri}", + "suggested_exchange": "${bankDetails.suggestedExchange}", + "confirm_transfer_url": "${bankDetails.confirmTransferUrl}" + }""".trimIndent() + } + runCoroutine { + val details = + withdraw.getBankInfo("taler://withdraw/bank.test.taler.net/9b51c1dd-db41-4b5f-97d9-1071d5dd8091") + assertEquals(bankDetails, details) + } + } + + @Test + fun testGetDenominationSelection() { + val allDenominations = listOf( + denomination0d1, + denomination0d01, // unsorted list to test sort as well + denomination10, + denomination8, + denomination5, + denomination4, + denomination2, + denomination1 + ) + // select denominations for 10 TESTKUDOS + val value10 = Amount(currency = "TESTKUDOS", value = 10, fraction = 0) + val expectedSelectionInfo10 = DenominationSelectionInfo( + totalCoinValue = Amount(currency = "TESTKUDOS", value = 9, fraction = 82000000), + totalWithdrawCost = Amount(currency = "TESTKUDOS", value = 9, fraction = 99000000), + selectedDenominations = listOf( + SelectedDenomination(1, denomination8), + SelectedDenomination(1, denomination1), + SelectedDenomination(8, denomination0d1), + SelectedDenomination(2, denomination0d01) + ) + ) + assertEquals(expectedSelectionInfo10, withdraw.getDenominationSelection(value10, allDenominations)) + + // select denominations for 5.5 TESTKUDOS + val value5d5 = Amount(currency = "TESTKUDOS", value = 5, fraction = 50000000) + val expectedSelectionInfo5d5 = DenominationSelectionInfo( + totalCoinValue = Amount(currency = "TESTKUDOS", value = 5, fraction = 42000000), + totalWithdrawCost = Amount(currency = "TESTKUDOS", value = 5, fraction = 49000000), + selectedDenominations = listOf( + SelectedDenomination(1, denomination5), + SelectedDenomination(4, denomination0d1), + SelectedDenomination(2, denomination0d01) + ) + ) + assertEquals(expectedSelectionInfo5d5, withdraw.getDenominationSelection(value5d5, allDenominations)) + + // select denominations for 23.42 TESTKUDOS + val value23d42 = Amount(currency = "TESTKUDOS", value = 23, fraction = 42000000) + val expectedSelectionInfo23d42 = DenominationSelectionInfo( + totalCoinValue = Amount(currency = "TESTKUDOS", value = 23, fraction = 31000000), + totalWithdrawCost = Amount(currency = "TESTKUDOS", value = 23, fraction = 42000000), + selectedDenominations = listOf( + SelectedDenomination(2, denomination10), + SelectedDenomination(1, denomination2), + SelectedDenomination(1, denomination1), + SelectedDenomination(3, denomination0d1), + SelectedDenomination(1, denomination0d01) + ) + ) + assertEquals(expectedSelectionInfo23d42, withdraw.getDenominationSelection(value23d42, allDenominations)) + } + +} diff --git a/wallet/src/jsMain/kotlin/net/taler/wallet/kotlin/Db.kt b/wallet/src/jsMain/kotlin/net/taler/wallet/kotlin/Db.kt new file mode 100644 index 0000000..45cbfc3 --- /dev/null +++ b/wallet/src/jsMain/kotlin/net/taler/wallet/kotlin/Db.kt @@ -0,0 +1,23 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +internal actual class DbFactory { + actual fun openDb(): Db { + return FakeDb() + } +} diff --git a/wallet/src/jsMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoFactory.kt b/wallet/src/jsMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoFactory.kt new file mode 100644 index 0000000..e428f6a --- /dev/null +++ b/wallet/src/jsMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoFactory.kt @@ -0,0 +1,185 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import org.khronos.webgl.Uint8Array +import org.khronos.webgl.get + +internal actual object CryptoFactory { + internal actual fun getCrypto(): Crypto = CryptoJsImpl +} + +internal object CryptoJsImpl : CryptoImpl() { + + override fun sha256(input: ByteArray): ByteArray { + return hash.sha256().update(input.toUint8Array()).digest().toByteArray() + } + + override fun sha512(input: ByteArray): ByteArray { + return nacl.hash(input.toUint8Array()).toByteArray() + } + + override fun getHashSha512State(): HashSha512State { + return JsHashSha512State() + } + + override fun getRandomBytes(num: Int): ByteArray { + return nacl.randomBytes(num).toByteArray() + } + + override fun eddsaGetPublic(eddsaPrivateKey: ByteArray): ByteArray { + val pair = nacl.sign.keyPair.fromSeed(eddsaPrivateKey.toUint8Array()) + return pair.publicKey.toByteArray() + } + + override fun ecdheGetPublic(ecdhePrivateKey: ByteArray): ByteArray { + return nacl.scalarMult.base(ecdhePrivateKey.toUint8Array()).toByteArray() + } + + override fun createEddsaKeyPair(): EddsaKeyPair { + val privateKey = nacl.randomBytes(32).toByteArray() + val publicKey = eddsaGetPublic(privateKey) + return EddsaKeyPair(privateKey, publicKey) + } + + override fun createEcdheKeyPair(): EcdheKeyPair { + val privateKey = nacl.randomBytes(32).toByteArray() + val publicKey = ecdheGetPublic(privateKey) + return EcdheKeyPair(privateKey, publicKey) + } + + override fun eddsaSign(msg: ByteArray, eddsaPrivateKey: ByteArray): ByteArray { + val privateKey = nacl.sign.keyPair.fromSeed(eddsaPrivateKey.toUint8Array()).secretKey + return nacl.sign.detached(msg.toUint8Array(), privateKey).toByteArray() + } + + override fun eddsaVerify(msg: ByteArray, sig: ByteArray, eddsaPub: ByteArray): Boolean { + return nacl.sign.detached.verify(msg.toUint8Array(), sig.toUint8Array(), eddsaPub.toUint8Array()) + } + + override fun keyExchangeEddsaEcdhe(eddsaPrivateKey: ByteArray, ecdhePublicKey: ByteArray): ByteArray { + val ph = sha512(eddsaPrivateKey) + val a = ph.copyOfRange(0, 32) + val x = nacl.scalarMult(a.toUint8Array(), ecdhePublicKey.toUint8Array()).toByteArray() + return sha512(x) + } + + override fun keyExchangeEcdheEddsa(ecdhePrivateKey: ByteArray, eddsaPublicKey: ByteArray): ByteArray { + val curve25519Pub = + ed2curve.convertPublicKey(eddsaPublicKey.toUint8Array()) ?: throw Error("invalid public key") + val x = nacl.scalarMult(ecdhePrivateKey.toUint8Array(), curve25519Pub).toByteArray() + return sha512(x) + } + + override fun rsaBlind(hm: ByteArray, bks: ByteArray, rsaPubEnc: ByteArray): ByteArray { + TODO("Not yet implemented") + } + + override fun rsaUnblind(sig: ByteArray, rsaPubEnc: ByteArray, bks: ByteArray): ByteArray { + TODO("Not yet implemented") + } + + override fun rsaVerify(hm: ByteArray, rsaSig: ByteArray, rsaPubEnc: ByteArray): Boolean { + TODO("Not yet implemented") + } + + private class JsHashSha512State : HashSha512State { + private val state = hash.sha512() + + override fun update(data: ByteArray): HashSha512State { + state.update(data.toUint8Array()) + return this + } + + override fun final(): ByteArray { + return state.digest().toByteArray() + } + } + + private fun Uint8Array.toByteArray(): ByteArray { + val result = ByteArray(this.length) + for (i in 0 until this.length) result[i] = this[i] + return result + } + + private fun ByteArray.toUint8Array(): Uint8Array { + return Uint8Array(this.toTypedArray()) + } + +} + +@Suppress("ClassName") +@JsModule("tweetnacl") +@JsNonModule +private external class nacl { + + companion object { + fun hash(input: Uint8Array): Uint8Array + fun scalarMult(n: Uint8Array, p: Uint8Array): Uint8Array + fun randomBytes(n: Int): Uint8Array + } + + class scalarMult { + companion object { + fun base(n: Uint8Array): Uint8Array + } + } + + class sign { + companion object { + fun detached(msg: Uint8Array, secretKey: Uint8Array): Uint8Array + } + + class detached { + companion object { + fun verify(msg: Uint8Array, sig: Uint8Array, publicKey: Uint8Array): Boolean + } + } + + class keyPair { + companion object { + fun fromSeed(seed: Uint8Array): KeyPair + } + } + } +} + +private class KeyPair(val publicKey: Uint8Array, @Suppress("unused") val secretKey: Uint8Array) + +@Suppress("ClassName") +@JsModule("ed2curve") +@JsNonModule +private external class ed2curve { + companion object { + fun convertPublicKey(pk: Uint8Array): Uint8Array? + } +} + +@Suppress("ClassName") +@JsModule("hash.js") +@JsNonModule +private external class hash { + class sha256 { + fun update(message: Uint8Array): sha256 + fun digest(): Uint8Array + } + + class sha512 { + fun update(message: Uint8Array): sha512 + fun digest(): Uint8Array + } +} diff --git a/wallet/src/jsTest/kotlin/net/taler/wallet/kotlin/TestUtils.kt b/wallet/src/jsTest/kotlin/net/taler/wallet/kotlin/TestUtils.kt new file mode 100644 index 0000000..49466e0 --- /dev/null +++ b/wallet/src/jsTest/kotlin/net/taler/wallet/kotlin/TestUtils.kt @@ -0,0 +1,25 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.promise + +actual fun runCoroutine(block: suspend (scope : CoroutineScope) -> Unit): dynamic = GlobalScope.promise { block(this) } + +actual fun getPlatformTarget(): PlatformTarget = PlatformTarget.JS diff --git a/wallet/src/nativeInterop/cinterop/sodium-static.def b/wallet/src/nativeInterop/cinterop/sodium-static.def new file mode 100644 index 0000000..7000cbb --- /dev/null +++ b/wallet/src/nativeInterop/cinterop/sodium-static.def @@ -0,0 +1,6 @@ +headers = sodium.h +staticLibraries = libsodium.a +libraryPaths = /usr/local/lib /usr/lib64 /usr/lib/x86_64-linux-gnu +headerFilter = sodium.h sodium/** +compilerOpts = -I/usr/include -I/usr/local/include -I/usr/include/x86_64-linux-gnu/ +linkerOpts = -lsodium -L/usr/lib/x86_64-linux-gnu -L/usr/lib64/ -L/usr/local/lib diff --git a/wallet/src/nativeInterop/cinterop/sodium.def b/wallet/src/nativeInterop/cinterop/sodium.def new file mode 100644 index 0000000..1c90295 --- /dev/null +++ b/wallet/src/nativeInterop/cinterop/sodium.def @@ -0,0 +1,4 @@ +headers = sodium.h +headerFilter = sodium.h sodium/** +compilerOpts = -I/usr/include -I/usr/local/include -I/usr/include/x86_64-linux-gnu/ +linkerOpts = -lsodium -L/usr/lib/x86_64-linux-gnu -L/usr/lib64/ -L/usr/local/lib diff --git a/wallet/src/nativeMain/kotlin/net/taler/wallet/kotlin/Db.kt b/wallet/src/nativeMain/kotlin/net/taler/wallet/kotlin/Db.kt new file mode 100644 index 0000000..45cbfc3 --- /dev/null +++ b/wallet/src/nativeMain/kotlin/net/taler/wallet/kotlin/Db.kt @@ -0,0 +1,23 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +internal actual class DbFactory { + actual fun openDb(): Db { + return FakeDb() + } +} diff --git a/wallet/src/nativeMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoFactory.kt b/wallet/src/nativeMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoFactory.kt new file mode 100644 index 0000000..7860607 --- /dev/null +++ b/wallet/src/nativeMain/kotlin/net/taler/wallet/kotlin/crypto/CryptoFactory.kt @@ -0,0 +1,187 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin.crypto + +import kotlinx.cinterop.CValuesRef +import kotlinx.cinterop.UByteVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.free +import kotlinx.cinterop.nativeHeap +import kotlinx.cinterop.ptr +import kotlinx.cinterop.refTo +import org.libsodium.crypto_hash_sha256 +import org.libsodium.crypto_hash_sha256_bytes +import org.libsodium.crypto_hash_sha512 +import org.libsodium.crypto_hash_sha512_bytes +import org.libsodium.crypto_hash_sha512_final +import org.libsodium.crypto_hash_sha512_init +import org.libsodium.crypto_hash_sha512_state +import org.libsodium.crypto_hash_sha512_update +import org.libsodium.crypto_scalarmult +import org.libsodium.crypto_scalarmult_BYTES +import org.libsodium.crypto_scalarmult_base +import org.libsodium.crypto_scalarmult_curve25519_BYTES +import org.libsodium.crypto_sign_BYTES +import org.libsodium.crypto_sign_PUBLICKEYBYTES +import org.libsodium.crypto_sign_SECRETKEYBYTES +import org.libsodium.crypto_sign_detached +import org.libsodium.crypto_sign_ed25519_pk_to_curve25519 +import org.libsodium.crypto_sign_seed_keypair +import org.libsodium.crypto_sign_verify_detached +import org.libsodium.randombytes + +internal actual object CryptoFactory { + internal actual fun getCrypto(): Crypto = CryptoNativeImpl +} + +@OptIn(ExperimentalUnsignedTypes::class) +internal object CryptoNativeImpl : CryptoImpl() { + + override fun sha256(input: ByteArray): ByteArray { + val output = ByteArray(crypto_hash_sha256_bytes().toInt()) + val cInput = if (input.isEmpty()) null else input.toCValuesRef() + crypto_hash_sha256(output.toCValuesRef(), cInput, input.size.toULong()) + return output + } + + override fun sha512(input: ByteArray): ByteArray { + val output = ByteArray(crypto_hash_sha512_bytes().toInt()) + val cInput = if (input.isEmpty()) null else input.toCValuesRef() + crypto_hash_sha512(output.toCValuesRef(), cInput, input.size.toULong()) + return output + } + + override fun getHashSha512State(): HashSha512State { + return NativeHashSha512State() + } + + override fun getRandomBytes(num: Int): ByteArray { + val bytes = ByteArray(num) + randombytes(bytes.toCValuesRef(), num.toULong()) + return bytes + } + + override fun eddsaGetPublic(eddsaPrivateKey: ByteArray): ByteArray { + val publicKey = ByteArray(crypto_sign_PUBLICKEYBYTES.toInt()) + val privateKey = ByteArray(crypto_sign_SECRETKEYBYTES.toInt()) + crypto_sign_seed_keypair(publicKey.toCValuesRef(), privateKey.toCValuesRef(), eddsaPrivateKey.toCValuesRef()) + return publicKey + } + + override fun ecdheGetPublic(ecdhePrivateKey: ByteArray): ByteArray { + val publicKey = ByteArray(crypto_scalarmult_BYTES.toInt()) + crypto_scalarmult_base(publicKey.toCValuesRef(), ecdhePrivateKey.toCValuesRef()) + return publicKey + } + + override fun createEddsaKeyPair(): EddsaKeyPair { + val privateKey = ByteArray(crypto_sign_SECRETKEYBYTES.toInt()) + randombytes(privateKey.toCValuesRef(), crypto_sign_SECRETKEYBYTES.toULong()) + val publicKey = eddsaGetPublic(privateKey) + return EddsaKeyPair(privateKey, publicKey) + } + + override fun createEcdheKeyPair(): EcdheKeyPair { + val privateKey = ByteArray(crypto_scalarmult_BYTES.toInt()) + randombytes(privateKey.toCValuesRef(), crypto_scalarmult_BYTES.toULong()) + val publicKey = ecdheGetPublic(privateKey) + return EcdheKeyPair(privateKey, publicKey) + } + + override fun eddsaSign(msg: ByteArray, eddsaPrivateKey: ByteArray): ByteArray { + val publicKey = ByteArray(crypto_sign_PUBLICKEYBYTES.toInt()) + val privateKey = ByteArray(crypto_sign_SECRETKEYBYTES.toInt()) + crypto_sign_seed_keypair(publicKey.toCValuesRef(), privateKey.toCValuesRef(), eddsaPrivateKey.toCValuesRef()) + + val signatureBytes = ByteArray(crypto_sign_BYTES.toInt()) + crypto_sign_detached( + signatureBytes.toCValuesRef(), + null, + msg.toCValuesRef(), + msg.size.toULong(), + privateKey.toCValuesRef() + ) + return signatureBytes + } + + override fun eddsaVerify(msg: ByteArray, sig: ByteArray, eddsaPub: ByteArray): Boolean { + return crypto_sign_verify_detached( + sig.toCValuesRef(), + msg.toCValuesRef(), + msg.size.toULong(), + eddsaPub.toCValuesRef() + ) == 0 + } + + override fun keyExchangeEddsaEcdhe(eddsaPrivateKey: ByteArray, ecdhePublicKey: ByteArray): ByteArray { + val privateKey = sha512(eddsaPrivateKey).copyOfRange(0, 32) + val sharedKey = ByteArray(crypto_scalarmult_BYTES.toInt()) + crypto_scalarmult(sharedKey.toCValuesRef(), privateKey.toCValuesRef(), ecdhePublicKey.toCValuesRef()) + return sha512(sharedKey) + } + + override fun keyExchangeEcdheEddsa(ecdhePrivateKey: ByteArray, eddsaPublicKey: ByteArray): ByteArray { + val curve25519Pub = ByteArray(crypto_scalarmult_curve25519_BYTES.toInt()) + val cCurve25519Pub = curve25519Pub.toCValuesRef() + crypto_sign_ed25519_pk_to_curve25519(cCurve25519Pub, eddsaPublicKey.toCValuesRef()) + + val sharedKey = ByteArray(crypto_scalarmult_BYTES.toInt()) + crypto_scalarmult(sharedKey.toCValuesRef(), ecdhePrivateKey.toCValuesRef(), cCurve25519Pub) + return sha512(sharedKey) + } + + override fun rsaBlind(hm: ByteArray, bks: ByteArray, rsaPubEnc: ByteArray): ByteArray { + TODO("Not yet implemented") + } + + override fun rsaUnblind(sig: ByteArray, rsaPubEnc: ByteArray, bks: ByteArray): ByteArray { + TODO("Not yet implemented") + } + + override fun rsaVerify(hm: ByteArray, rsaSig: ByteArray, rsaPubEnc: ByteArray): Boolean { + TODO("Not yet implemented") + } + + private class NativeHashSha512State : HashSha512State { + private val state = nativeHeap.alloc<crypto_hash_sha512_state>() + private val statePointer = state.ptr + + init { + check(crypto_hash_sha512_init(statePointer) == 0) { "Error doing crypto_hash_sha512_init" } + } + + override fun update(data: ByteArray): HashSha512State { + val cInput = if (data.isEmpty()) null else data.toCValuesRef() + crypto_hash_sha512_update(statePointer, cInput, data.size.toULong()) + return this + } + + override fun final(): ByteArray { + val output = ByteArray(crypto_hash_sha512_bytes().toInt()) + crypto_hash_sha512_final(statePointer, output.toCValuesRef()) + nativeHeap.free(statePointer) + return output + } + + } + + private fun ByteArray.toCValuesRef(): CValuesRef<UByteVar> { + @Suppress("UNCHECKED_CAST") + return this.refTo(0) as CValuesRef<UByteVar> + } + +} diff --git a/wallet/src/nativeTest/kotlin/net/taler/wallet/kotlin/TestUtils.kt b/wallet/src/nativeTest/kotlin/net/taler/wallet/kotlin/TestUtils.kt new file mode 100644 index 0000000..c44a846 --- /dev/null +++ b/wallet/src/nativeTest/kotlin/net/taler/wallet/kotlin/TestUtils.kt @@ -0,0 +1,24 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.kotlin + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking + +actual fun runCoroutine(block: suspend (scope : CoroutineScope) -> Unit) = runBlocking { block(this) } + +actual fun getPlatformTarget(): PlatformTarget = PlatformTarget.NATIVE |