diff options
Diffstat (limited to 'wallet/src/commonMain/kotlin')
25 files changed, 3041 insertions, 0 deletions
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 +) |