libeufin

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

TalerCommon.kt (27038B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2024, 2025, 2026 Taler Systems S.A.
      4  *
      5  * LibEuFin is free software; you can redistribute it and/or modify
      6  * it under the terms of the GNU Affero General Public License as
      7  * published by the Free Software Foundation; either version 3, or
      8  * (at your option) any later version.
      9  *
     10  * LibEuFin is distributed in the hope that it will be useful, but
     11  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12  * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13  * Public License for more details.
     14  *
     15  * You should have received a copy of the GNU Affero General Public
     16  * License along with LibEuFin; see the file COPYING.  If not, see
     17  * <http://www.gnu.org/licenses/>
     18  */
     19 
     20 package tech.libeufin.common
     21 
     22 import io.ktor.http.*
     23 import io.ktor.server.plugins.*
     24 import kotlinx.serialization.*
     25 import kotlinx.serialization.descriptors.*
     26 import kotlinx.serialization.encoding.*
     27 import kotlinx.serialization.json.*
     28 import java.net.URI
     29 import java.net.URL
     30 import java.time.Instant
     31 import java.time.Duration
     32 import java.time.temporal.ChronoUnit
     33 import java.util.concurrent.TimeUnit
     34 import org.bouncycastle.math.ec.rfc8032.Ed25519
     35 
     36 sealed class CommonError(msg: String) : Exception(msg) {
     37     class AmountFormat(msg: String) : CommonError(msg)
     38     class AmountNumberTooBig(msg: String) : CommonError(msg)
     39     class Payto(msg: String) : CommonError(msg)
     40 }
     41 
     42 /**
     43  * Internal representation of relative times.  The
     44  * "forever" case is represented with Long.MAX_VALUE.
     45  */
     46 @JvmInline
     47 @Serializable(with = RelativeTime.Serializer::class)
     48 value class RelativeTime(val duration: Duration) {
     49     private object Serializer : KSerializer<RelativeTime> {
     50         override val descriptor: SerialDescriptor =
     51             buildClassSerialDescriptor("RelativeTime") {
     52                 element<JsonElement>("d_us")
     53             }
     54 
     55         override fun serialize(encoder: Encoder, value: RelativeTime) {
     56             val composite = encoder.beginStructure(descriptor)
     57             if (value.duration == ChronoUnit.FOREVER.duration) {
     58                 composite.encodeStringElement(descriptor, 0, "forever")
     59             } else {
     60                 composite.encodeLongElement(descriptor, 0, TimeUnit.MICROSECONDS.convert(value.duration))
     61             }
     62             composite.endStructure(descriptor)
     63         }
     64 
     65         override fun deserialize(decoder: Decoder): RelativeTime {
     66             val dec = decoder.beginStructure(descriptor)
     67             val jsonInput = dec as? JsonDecoder ?: error("Can be deserialized only by JSON")
     68             lateinit var maybeDUs: JsonPrimitive
     69             loop@ while (true) {
     70                 when (val index = dec.decodeElementIndex(descriptor)) {
     71                     0 -> maybeDUs = jsonInput.decodeJsonElement().jsonPrimitive
     72                     CompositeDecoder.DECODE_DONE -> break@loop
     73                     else -> throw SerializationException("Unexpected index: $index")
     74                 }
     75             }
     76             dec.endStructure(descriptor)
     77             if (maybeDUs.isString) {
     78                 if (maybeDUs.content != "forever") throw badRequest("Only 'forever' allowed for d_us as string, but '${maybeDUs.content}' was found")
     79                 return RelativeTime(ChronoUnit.FOREVER.duration)
     80             }
     81             val dUs: Long = maybeDUs.longOrNull
     82                 ?: throw badRequest("Could not convert d_us: '${maybeDUs.content}' to a number")
     83             when {
     84                 dUs < 0 -> throw badRequest("Negative duration specified.")
     85                 dUs > MAX_SAFE_INTEGER -> throw badRequest("d_us value $dUs exceed cap (2^53-1)")
     86                 else -> return RelativeTime(Duration.of(dUs, ChronoUnit.MICROS))
     87             }
     88         }
     89     }
     90 
     91     companion object {
     92         const val MAX_SAFE_INTEGER = 9007199254740991L // 2^53 - 1
     93     }
     94 }
     95 
     96 /** Timestamp containing the number of seconds since epoch */
     97 @JvmInline
     98 @Serializable(with = TalerTimestamp.Serializer::class)
     99 value class TalerTimestamp constructor(val instant: Instant) {
    100     private object Serializer : KSerializer<TalerTimestamp> {
    101         override val descriptor: SerialDescriptor =
    102             buildClassSerialDescriptor("Timestamp") {
    103                 element<JsonElement>("t_s")
    104             }
    105 
    106         override fun serialize(encoder: Encoder, value: TalerTimestamp) {
    107             val composite = encoder.beginStructure(descriptor)
    108             if (value.instant == Instant.MAX) {
    109                 composite.encodeStringElement(descriptor, 0, "never")
    110             } else {
    111                 composite.encodeLongElement(descriptor, 0, value.instant.epochSecond)
    112             }
    113             composite.endStructure(descriptor)
    114         }
    115 
    116         override fun deserialize(decoder: Decoder): TalerTimestamp {
    117             val dec = decoder.beginStructure(descriptor)
    118             val jsonInput = dec as? JsonDecoder ?: error("Can be deserialized only by JSON")
    119             lateinit var maybeTs: JsonPrimitive
    120             loop@ while (true) {
    121                 when (val index = dec.decodeElementIndex(descriptor)) {
    122                     0 -> maybeTs = jsonInput.decodeJsonElement().jsonPrimitive
    123                     CompositeDecoder.DECODE_DONE -> break@loop
    124                     else -> throw SerializationException("Unexpected index: $index")
    125                 }
    126             }
    127             dec.endStructure(descriptor)
    128             if (maybeTs.isString) {
    129                 if (maybeTs.content != "never") throw badRequest("Only 'never' allowed for t_s as string, but '${maybeTs.content}' was found")
    130                 return TalerTimestamp(Instant.MAX)
    131             }
    132             val ts: Long = maybeTs.longOrNull
    133                 ?: throw badRequest("Could not convert t_s '${maybeTs.content}' to a number")
    134             when {
    135                 ts < 0 -> throw badRequest("Negative timestamp not allowed")
    136                 ts > Instant.MAX.epochSecond -> throw badRequest("Timestamp $ts too big to be represented in Kotlin")
    137                 else -> return TalerTimestamp(Instant.ofEpochSecond(ts))
    138             }
    139         }
    140     }
    141     
    142     companion object {
    143         fun never(): TalerTimestamp = TalerTimestamp(Instant.MAX)
    144     }
    145 }
    146 
    147 @JvmInline
    148 @Serializable(with = BaseURL.Serializer::class)
    149 value class BaseURL private constructor(val url: URL) {
    150     companion object {
    151         fun parse(raw: String): BaseURL {
    152             val url = URI(raw).toURL()
    153             if (url.protocol !in setOf("http", "https")) {
    154                 throw badRequest("only 'http' and 'https' are accepted for baseURL got '${url.protocol}'")
    155             } else if (url.host.isNullOrBlank()) {
    156                 throw badRequest("missing host in baseURL got '${url}'")
    157             } else if (url.query != null) {
    158                 throw badRequest("require no query in baseURL got '${url.query}'")
    159             } else if (url.ref != null) {
    160                 throw badRequest("require no fragments in baseURL got '${url.ref}'")
    161             } else if (!url.path.endsWith('/')) {
    162                 throw badRequest("baseURL path must end with / got '${url.path}'")
    163             }
    164             return BaseURL(url)
    165         }
    166     }
    167 
    168     override fun toString(): String = url.toString()
    169 
    170     private object Serializer : KSerializer<BaseURL> {
    171         override val descriptor: SerialDescriptor =
    172             PrimitiveSerialDescriptor("BaseURL", PrimitiveKind.STRING)
    173 
    174         override fun serialize(encoder: Encoder, value: BaseURL) {
    175             encoder.encodeString(value.url.toString())
    176         }
    177 
    178         override fun deserialize(decoder: Decoder): BaseURL {
    179             return BaseURL.parse(decoder.decodeString())
    180         }
    181     }
    182 }
    183 
    184 @Serializable(with = DecimalNumber.Serializer::class)
    185 class DecimalNumber {
    186     val value: Long
    187     val frac: Int
    188 
    189     constructor(value: Long, frac: Int) {
    190         this.value = value
    191         this.frac = frac
    192     }
    193 
    194     constructor(encoded: String) {
    195         val match = PATTERN.matchEntire(encoded) ?: throw badRequest("Invalid decimal number format")
    196         val (value, frac) = match.destructured
    197         this.value = value.toLongOrNull() ?: throw badRequest("Invalid value")
    198         if (this.value > TalerAmount.MAX_VALUE)
    199             throw badRequest("Value specified in decimal number is too large")
    200         this.frac = if (frac.isEmpty()) {
    201             0
    202         } else {
    203             var tmp = frac.toIntOrNull() ?: throw badRequest("Invalid fractional value")
    204             if (tmp > TalerAmount.FRACTION_BASE)
    205                 throw badRequest("Fractional value specified in decimal number is too large")
    206             repeat(8 - frac.length) {
    207                 tmp *= 10
    208             }
    209             tmp
    210         }
    211     }
    212 
    213     fun isZero(): Boolean = value == 0L && frac == 0
    214 
    215     override fun equals(other: Any?): Boolean {
    216         return other is DecimalNumber &&
    217                 other.value == this.value &&
    218                 other.frac == this.frac
    219     }
    220 
    221     override fun toString(): String {
    222         return if (frac == 0) {
    223             "$value"
    224         } else {
    225             "$value.${frac.toString().padStart(8, '0')}"
    226                 .dropLastWhile { it == '0' } // Trim useless fractional trailing 0
    227         }
    228     }
    229 
    230     private object Serializer : KSerializer<DecimalNumber> {
    231         override val descriptor: SerialDescriptor =
    232             PrimitiveSerialDescriptor("DecimalNumber", PrimitiveKind.STRING)
    233 
    234         override fun serialize(encoder: Encoder, value: DecimalNumber) {
    235             encoder.encodeString(value.toString())
    236         }
    237 
    238         override fun deserialize(decoder: Decoder): DecimalNumber {
    239             return DecimalNumber(decoder.decodeString())
    240         }
    241     }
    242 
    243     companion object {
    244         val ZERO = DecimalNumber(0, 0)
    245         private val PATTERN = Regex("([0-9]+)(?:\\.([0-9]{1,8}))?")
    246     }
    247 }
    248 
    249 @Serializable(with = TalerAmount.Serializer::class)
    250 class TalerAmount : Comparable<TalerAmount> {
    251     val value: Long
    252     val frac: Int
    253     val currency: String
    254 
    255     constructor(value: Long, frac: Int, currency: String) {
    256         this.value = value
    257         this.frac = frac
    258         this.currency = currency
    259     }
    260 
    261     constructor(encoded: String) {
    262         val match = PATTERN.matchEntire(encoded) ?: throw CommonError.AmountFormat("Invalid amount format")
    263         val (currency, value, frac) = match.destructured
    264         this.currency = currency
    265         this.value = value.toLongOrNull() ?: throw CommonError.AmountFormat("Invalid value")
    266         if (this.value > MAX_VALUE)
    267             throw CommonError.AmountNumberTooBig("Value specified in amount is too large")
    268         this.frac = if (frac.isEmpty()) {
    269             0
    270         } else {
    271             var tmp = frac.toIntOrNull() ?: throw CommonError.AmountFormat("Invalid fractional value")
    272             if (tmp > FRACTION_BASE)
    273                 throw CommonError.AmountFormat("Fractional value specified in amount is too large")
    274             repeat(8 - frac.length) {
    275                 tmp *= 10
    276             }
    277 
    278             tmp
    279         }
    280     }
    281 
    282     fun number(): DecimalNumber = DecimalNumber(value, frac)
    283 
    284     /* Check if zero */
    285     fun isZero(): Boolean = value == 0L && frac == 0
    286 
    287     fun notZeroOrNull(): TalerAmount? = if (isZero()) null else this
    288 
    289     /* Check is amount has fractional amount < 0.01 */
    290     fun isSubCent(): Boolean = (frac % CENT_FRACTION) > 0
    291 
    292     override fun equals(other: Any?): Boolean {
    293         return other is TalerAmount &&
    294                 other.value == this.value &&
    295                 other.frac == this.frac &&
    296                 other.currency == this.currency
    297     }
    298 
    299     override fun toString(): String {
    300         return if (frac == 0) {
    301             "$currency:$value"
    302         } else {
    303             "$currency:$value.${frac.toString().padStart(8, '0')}"
    304                 .dropLastWhile { it == '0' } // Trim useless fractional trailing 0
    305         }
    306     }
    307 
    308     fun normalize(): TalerAmount {
    309         val value = Math.addExact(this.value, (this.frac / FRACTION_BASE).toLong())
    310         val frac = this.frac % FRACTION_BASE
    311         if (value > MAX_VALUE) throw ArithmeticException("amount value overflowed")
    312         return TalerAmount(value, frac, currency)
    313     }
    314 
    315     override operator fun compareTo(other: TalerAmount) = compareValuesBy(this, other, { it.value }, { it.frac })
    316 
    317     operator fun plus(increment: TalerAmount): TalerAmount {
    318         require(this.currency == increment.currency) { "currency mismatch ${this.currency} != ${increment.currency}" }
    319         val value = Math.addExact(this.value, increment.value)
    320         val frac = Math.addExact(this.frac, increment.frac)
    321         return TalerAmount(value, frac, currency).normalize()
    322     }
    323 
    324     operator fun minus(decrement: TalerAmount): TalerAmount {
    325         require(this.currency == decrement.currency) { "currency mismatch ${this.currency} != ${decrement.currency}" }
    326         var frac = this.frac
    327         var value = this.value
    328         if (frac < decrement.frac) {
    329             if (value <= 0) {
    330                 throw ArithmeticException("negative result")
    331             }
    332             frac += FRACTION_BASE
    333             value -= 1
    334         }
    335         if (value < decrement.value) {
    336             throw ArithmeticException("negative result")
    337         }
    338         return TalerAmount(value - decrement.value, frac - decrement.frac, currency).normalize()
    339     }
    340 
    341     private object Serializer : KSerializer<TalerAmount> {
    342         override val descriptor: SerialDescriptor =
    343             PrimitiveSerialDescriptor("TalerAmount", PrimitiveKind.STRING)
    344 
    345         override fun serialize(encoder: Encoder, value: TalerAmount) {
    346             encoder.encodeString(value.toString())
    347         }
    348 
    349         override fun deserialize(decoder: Decoder): TalerAmount =
    350             TalerAmount(decoder.decodeString())
    351 
    352     }
    353 
    354     companion object {
    355         const val FRACTION_BASE = 100000000
    356         const val CENT_FRACTION = 1000000
    357         const val MAX_VALUE = 4503599627370496L // 2^52
    358         private val PATTERN = Regex("([A-Z]{1,11}):([0-9]+)(?:\\.([0-9]{1,8}))?")
    359 
    360         fun zero(currency: String) = TalerAmount(0, 0, currency)
    361         fun max(currency: String) = TalerAmount(MAX_VALUE, FRACTION_BASE - 1, currency)
    362     }
    363 }
    364 
    365 @Serializable(with = Payto.Serializer::class)
    366 sealed class Payto {
    367     abstract val parsed: URI
    368     abstract val canonical: String
    369     abstract val amount: TalerAmount?
    370     abstract val message: String?
    371     abstract val receiverName: String?
    372 
    373     /** Transform a payto URI to its bank form, using [name] as the receiver-name and the bank [ctx] */
    374     fun bank(name: String?, ctx: BankPaytoCtx): String = when (this) {
    375         is IbanPayto -> IbanPayto.build(iban.toString(), ctx.bic, name)
    376         is XTalerBankPayto -> {
    377             val name = if (name != null) "?receiver-name=${name.encodeURLParameter()}" else ""
    378             "payto://x-taler-bank/${ctx.hostname}/$username$name"
    379         }
    380     }
    381 
    382     fun expectIbanFull(): IbanPayto {
    383         val payto = expectIban()
    384         if (payto.receiverName == null) {
    385             throw CommonError.Payto("expected a full IBAN payto got no receiver-name")
    386         }
    387         return payto
    388     }
    389 
    390     fun expectIban(): IbanPayto {
    391         return when (this) {
    392             is IbanPayto -> this
    393             else -> throw CommonError.Payto("expected an IBAN payto URI got '${parsed.host}'")
    394         }
    395     }
    396 
    397     fun expectXTalerBank(): XTalerBankPayto {
    398         return when (this) {
    399             is XTalerBankPayto -> this
    400             else -> throw CommonError.Payto("expected a x-taler-bank payto URI got '${parsed.host}'")
    401         }
    402     }
    403 
    404     override fun equals(other: Any?): Boolean {
    405         if (this === other) return true
    406         if (other !is Payto) return false
    407         return this.parsed == other.parsed
    408     }
    409 
    410     private object Serializer : KSerializer<Payto> {
    411         override val descriptor: SerialDescriptor =
    412             PrimitiveSerialDescriptor("Payto", PrimitiveKind.STRING)
    413 
    414         override fun serialize(encoder: Encoder, value: Payto) {
    415             encoder.encodeString(value.toString())
    416         }
    417 
    418         override fun deserialize(decoder: Decoder): Payto {
    419             return parse(decoder.decodeString())
    420         }
    421     }
    422 
    423     companion object {
    424         fun parse(raw: String): Payto {
    425             val parsed = try {
    426                 URI(raw)
    427             } catch (e: Exception) {
    428                 throw CommonError.Payto("expected a valid URI")
    429             }
    430             if (parsed.scheme != "payto") throw CommonError.Payto("expect a payto URI got '${parsed.scheme}'")
    431 
    432             val params = parseQueryString(parsed.query ?: "")
    433             val amount = params["amount"]?.run { TalerAmount(this) }
    434             val message = params["message"]
    435             val receiverName = params["receiver-name"]
    436 
    437             return when (parsed.host) {
    438                 "iban" -> {
    439                     val splitPath = parsed.path.split("/", limit = 3).filter { it.isNotEmpty() }
    440                     val (bic, rawIban) = when (splitPath.size) {
    441                         1 -> Pair(null, splitPath[0])
    442                         2 -> Pair(splitPath[0], splitPath[1])
    443                         else -> throw CommonError.Payto("too many path segments for an IBAN payto URI")
    444                     }
    445                     val iban = IBAN.parse(rawIban)
    446                     IbanPayto(
    447                         parsed,
    448                         "payto://iban/$iban",
    449                         amount,
    450                         message,
    451                         receiverName,
    452                         bic,
    453                         iban
    454                     )
    455                 }
    456 
    457                 "x-taler-bank" -> {
    458                     val splitPath = parsed.path.split("/", limit = 3).filter { it.isNotEmpty() }
    459                     if (splitPath.size != 2)
    460                         throw CommonError.Payto("bad number of path segments for a x-taler-bank payto URI")
    461                     val username = splitPath[1]
    462                     XTalerBankPayto(
    463                         parsed,
    464                         "payto://x-taler-bank/localhost/$username",
    465                         amount,
    466                         message,
    467                         receiverName,
    468                         username
    469                     )
    470                 }
    471 
    472                 else -> throw CommonError.Payto("unsupported payto URI kind '${parsed.host}'")
    473             }
    474         }
    475     }
    476 }
    477 
    478 @Serializable(with = IbanPayto.Serializer::class)
    479 class IbanPayto internal constructor(
    480     override val parsed: URI,
    481     override val canonical: String,
    482     override val amount: TalerAmount?,
    483     override val message: String?,
    484     override val receiverName: String?,
    485     val bic: String?,
    486     val iban: IBAN
    487 ) : Payto() {
    488     override fun toString(): String = parsed.toString()
    489 
    490     /** Format an IbanPayto in a more human readable way */
    491     fun fmt(): String = buildString {
    492         append('(')
    493         append(iban)
    494         if (bic != null) {
    495             append(' ')
    496             append(bic)
    497         }
    498         if (receiverName != null) {
    499             append(' ')
    500             append(receiverName)
    501         }
    502         append(')')
    503     }
    504 
    505     /** Transform an IBAN payto URI to its simple form without any query */
    506     fun simple(): String = build(iban.toString(), bic, null)
    507 
    508     /** Transform an IBAN payto URI to its full form, using [name] as its receiver-name */
    509     fun full(name: String): String = build(iban.toString(), bic, name)
    510 
    511     internal object Serializer : KSerializer<IbanPayto> {
    512         override val descriptor: SerialDescriptor =
    513             PrimitiveSerialDescriptor("IbanPayto", PrimitiveKind.STRING)
    514 
    515         override fun serialize(encoder: Encoder, value: IbanPayto) {
    516             encoder.encodeString(value.toString())
    517         }
    518 
    519         override fun deserialize(decoder: Decoder): IbanPayto {
    520             return parse(decoder.decodeString()).expectIban()
    521         }
    522     }
    523 
    524     companion object {
    525         fun build(iban: String, bic: String?, name: String?): String {
    526             val bic = if (bic != null) "$bic/" else ""
    527             val name = if (name != null) "?receiver-name=${name.encodeURLParameter()}" else ""
    528             return "payto://iban/$bic$iban$name"
    529         }
    530 
    531         fun rand(name: String? = null): IbanPayto = parse(
    532             "payto://iban/SANDBOXX/${IBAN.rand(Country.DE)}${
    533                 if (name != null) {
    534                     "?receiver-name=${name.encodeURLParameter()}"
    535                 } else {
    536                     ""
    537                 }
    538             }"
    539         ).expectIban()
    540     }
    541 }
    542 
    543 class XTalerBankPayto internal constructor(
    544     override val parsed: URI,
    545     override val canonical: String,
    546     override val amount: TalerAmount?,
    547     override val message: String?,
    548     override val receiverName: String?,
    549     val username: String
    550 ) : Payto() {
    551     override fun toString(): String = parsed.toString()
    552 
    553     companion object {
    554         fun forUsername(username: String): XTalerBankPayto {
    555             return parse("payto://x-taler-bank/hostname/$username").expectXTalerBank()
    556         }
    557     }
    558 }
    559 
    560 /** Context specific data necessary to create a bank payto URI from a canonical payto URI */
    561 data class BankPaytoCtx(
    562     val bic: String?,
    563     val hostname: String
    564 )
    565 
    566 
    567 /** 16-byte Crockford's Base32 encoded data */
    568 @Serializable(with = Base32Crockford16B.Serializer::class)
    569 class Base32Crockford16B {
    570     private var encoded: String? = null
    571     val raw: ByteArray
    572 
    573     constructor(encoded: String) {
    574         val decoded = try {
    575             Base32Crockford.decode(encoded)
    576         } catch (e: IllegalArgumentException) {
    577             null
    578         }
    579         require(decoded != null && decoded.size == 16) {
    580             "expected 16 bytes encoded in Crockford's base32"
    581         }
    582         this.raw = decoded
    583         this.encoded = encoded
    584     }
    585 
    586     constructor(raw: ByteArray) {
    587         require(raw.size == 16) {
    588             "encoded data should be 16 bytes long"
    589         }
    590         this.raw = raw
    591     }
    592 
    593     fun encoded(): String {
    594         val tmp = encoded ?: Base32Crockford.encode(raw)
    595         encoded = tmp
    596         return tmp
    597     }
    598 
    599     override fun toString(): String {
    600         return encoded()
    601     }
    602 
    603     override fun equals(other: Any?) = (other is Base32Crockford16B) && raw.contentEquals(other.raw)
    604 
    605     internal object Serializer : KSerializer<Base32Crockford16B> {
    606         override val descriptor: SerialDescriptor =
    607             PrimitiveSerialDescriptor("Base32Crockford16B", PrimitiveKind.STRING)
    608 
    609         override fun serialize(encoder: Encoder, value: Base32Crockford16B) {
    610             encoder.encodeString(value.encoded())
    611         }
    612 
    613         override fun deserialize(decoder: Decoder): Base32Crockford16B {
    614             return Base32Crockford16B(decoder.decodeString())
    615         }
    616     }
    617 
    618     companion object {
    619         fun rand(): Base32Crockford16B = Base32Crockford16B(ByteArray(16).rand())
    620         fun secureRand(): Base32Crockford16B = Base32Crockford16B(ByteArray(16).secureRand())
    621     }
    622 }
    623 
    624 /** 32-byte Crockford's Base32 encoded data */
    625 @Serializable(with = Base32Crockford32B.Serializer::class)
    626 class Base32Crockford32B {
    627     private var encoded: String? = null
    628     val raw: ByteArray
    629 
    630     constructor(encoded: String) {
    631         val decoded = try {
    632             Base32Crockford.decode(encoded)
    633         } catch (e: IllegalArgumentException) {
    634             null
    635         }
    636         require(decoded != null && decoded.size == 32) {
    637             "expected 32 bytes encoded in Crockford's base32"
    638         }
    639         this.raw = decoded
    640         this.encoded = encoded
    641     }
    642 
    643     constructor(raw: ByteArray) {
    644         require(raw.size == 32) {
    645             "encoded data should be 32 bytes long"
    646         }
    647         this.raw = raw
    648     }
    649 
    650     fun encoded(): String {
    651         val tmp = encoded ?: Base32Crockford.encode(raw)
    652         encoded = tmp
    653         return tmp
    654     }
    655 
    656     override fun toString(): String {
    657         return encoded()
    658     }
    659 
    660     override fun equals(other: Any?) = (other is Base32Crockford32B) && raw.contentEquals(other.raw)
    661 
    662     internal object Serializer : KSerializer<Base32Crockford32B> {
    663         override val descriptor: SerialDescriptor =
    664             PrimitiveSerialDescriptor("Base32Crockford32B", PrimitiveKind.STRING)
    665 
    666         override fun serialize(encoder: Encoder, value: Base32Crockford32B) {
    667             encoder.encodeString(value.encoded())
    668         }
    669 
    670         override fun deserialize(decoder: Decoder): Base32Crockford32B {
    671             return Base32Crockford32B(decoder.decodeString())
    672         }
    673     }
    674 
    675     companion object {
    676         fun rand(): Base32Crockford32B = Base32Crockford32B(ByteArray(32).rand())
    677         fun secureRand(): Base32Crockford32B = Base32Crockford32B(ByteArray(32).secureRand())
    678         fun randEdsaKey(): EddsaPublicKey = randEdsaKeyPair().second
    679         fun randEdsaKeyPair(): Pair<ByteArray, EddsaPublicKey> {
    680             val secretKey = ByteArray(32)
    681             Ed25519.generatePrivateKey(SECURE_RNG.get(), secretKey)
    682             val publicKey = ByteArray(32)
    683             Ed25519.generatePublicKey(secretKey, 0, publicKey, 0)
    684             return Pair(secretKey, Base32Crockford32B(publicKey))
    685         }
    686     }
    687 }
    688 
    689 /** 64-byte Crockford's Base32 encoded data */
    690 @Serializable(with = Base32Crockford64B.Serializer::class)
    691 class Base32Crockford64B {
    692     private var encoded: String? = null
    693     val raw: ByteArray
    694 
    695     constructor(encoded: String) {
    696         val decoded = try {
    697             Base32Crockford.decode(encoded)
    698         } catch (e: IllegalArgumentException) {
    699             null
    700         }
    701 
    702         require(decoded != null && decoded.size == 64) {
    703             "expected 64 bytes encoded in Crockford's base32"
    704         }
    705         this.raw = decoded
    706         this.encoded = encoded
    707     }
    708 
    709     constructor(raw: ByteArray) {
    710         require(raw.size == 64) {
    711             "encoded data should be 64 bytes long"
    712         }
    713         this.raw = raw
    714     }
    715 
    716     fun encoded(): String {
    717         val tmp = encoded ?: Base32Crockford.encode(raw)
    718         encoded = tmp
    719         return tmp
    720     }
    721 
    722     override fun toString(): String {
    723         return encoded()
    724     }
    725 
    726     override fun equals(other: Any?) = (other is Base32Crockford64B) && raw.contentEquals(other.raw)
    727 
    728     private object Serializer : KSerializer<Base32Crockford64B> {
    729         override val descriptor: SerialDescriptor =
    730             PrimitiveSerialDescriptor("Base32Crockford64B", PrimitiveKind.STRING)
    731 
    732         override fun serialize(encoder: Encoder, value: Base32Crockford64B) {
    733             encoder.encodeString(value.encoded())
    734         }
    735 
    736         override fun deserialize(decoder: Decoder): Base32Crockford64B {
    737             return Base32Crockford64B(decoder.decodeString())
    738         }
    739     }
    740 
    741     companion object {
    742         fun rand(): Base32Crockford64B = Base32Crockford64B(ByteArray(64).rand())
    743     }
    744 }
    745 
    746 /** 32-byte hash code */
    747 typealias ShortHashCode = Base32Crockford32B
    748 /** 64-byte hash code */
    749 typealias HashCode = Base32Crockford64B
    750 
    751 typealias EddsaSignature = Base32Crockford64B
    752 /**
    753  * EdDSA and ECDHE public keys always point on Curve25519
    754  * and represented  using the standard 256 bits Ed25519 compact format,
    755  * converted to Crockford Base32.
    756  */
    757 typealias EddsaPublicKey = Base32Crockford32B