commit 2f5ef047a4a0c045c6e4c81438b8a18e045778b5
parent 5ba63cca00d8b907a04ca8df0a1bb1799ced497a
Author: Antoine A <>
Date: Tue, 18 Jun 2024 23:38:48 +0200
common: rewrite base32 encoding logic and add tests
Diffstat:
4 files changed, 186 insertions(+), 154 deletions(-)
diff --git a/common/src/main/kotlin/Encoding.kt b/common/src/main/kotlin/Encoding.kt
@@ -19,107 +19,66 @@
package tech.libeufin.common
-import java.io.ByteArrayOutputStream
-
-class EncodingException : Exception("Invalid encoding")
-
+/** Crockford's Base32 implementation */
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 = buildString {
- var inputChunkBuffer = 0
- var pendingBitsCount = 0
- var inputCursor = 0
- var inputChunkNumber = 0
-
- while (inputCursor < data.size) {
+ /** Crockford's Base32 alphabet */
+ private const val ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
+ /** Base32 mark to extract 5 bits chunks */
+ private const val MASK = 0b11111
+ /** Crockford's Base32 inversed alphabet */
+ private val INV = intArrayOf(
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, -1, -1, -1, -1, -1, -1, 10, 11, 12,
+ 13, 14, 15, 16, 17, 1, 18, 19, 1, 20, 21, 0, 22, 23, 24, 25, 26, 27, 27, 28,
+ 29, 30, 31, -1, -1, -1, -1, -1, -1, 10, 11, 12, 13, 14, 15, 16, 17, 1, 18, 19,
+ 1, 20, 21, 0, 22, 23, 24, 25, 26, 27, 27, 28, 29, 30, 31,
+ )
+
+ fun encode(data: ByteArray): String = buildString(encodedSize(data.size)) {
+ var buffer = 0
+ var bitsLeft = 0
+
+ for (byte in data) {
// Read input
- inputChunkNumber = data.getIntAt(inputCursor++)
- inputChunkBuffer = (inputChunkBuffer shl 8) or inputChunkNumber
- pendingBitsCount += 8
+ buffer = (buffer shl 8) or (byte.toInt() and 0xFF)
+ bitsLeft += 8
// Write symbols
- while (pendingBitsCount >= 5) {
- val symbolIndex = inputChunkBuffer.ushr(pendingBitsCount - 5) and 31
- append(encTable[symbolIndex])
- pendingBitsCount -= 5
+ while (bitsLeft >= 5) {
+ append(ALPHABET[(buffer shr (bitsLeft - 5)) and MASK])
+ bitsLeft -= 5
}
}
- if (pendingBitsCount >= 5)
- throw Exception("base32 encoder did not write all the symbols")
- if (pendingBitsCount > 0) {
- val symbolIndex = (inputChunkNumber shl (5 - pendingBitsCount)) and 31
- append(encTable[symbolIndex])
- }
- val oneMore = ((data.size * 8) % 5) > 0
- val expectedLength = if (oneMore) {
- ((data.size * 8) / 5) + 1
- } else {
- (data.size * 8) / 5
+ if (bitsLeft > 0) {
+ // Write remaining bits
+ append(ALPHABET[(buffer shl (5 - bitsLeft)) and MASK])
}
- if (this.length != expectedLength)
- throw Exception("base32 encoding has wrong length")
}
- /**
- * Decodes the input to its binary representation, throws
- * net.taler.wallet.crypto.EncodingException on invalid encodings.
- */
- fun decode(
- encoded: String,
- out: ByteArrayOutputStream
- ) {
- var outBitsCount = 0
- var bitsBuffer = 0
- var inputCursor = 0
+ fun decode(encoded: String): ByteArray {
+ val out = ByteArray(decodedSize(encoded.length))
+
+ var bitsLeft = 0
+ var buffer = 0
+ var cursor = 0
- while (inputCursor < encoded.length) {
- val decodedNumber = getValue(encoded[inputCursor++])
- bitsBuffer = (bitsBuffer shl 5) or decodedNumber
- outBitsCount += 5
- while (outBitsCount >= 8) {
- val outputChunk = (bitsBuffer ushr (outBitsCount - 8)) and 0xFF
- out.write(outputChunk)
- outBitsCount -= 8 // decrease of written bits.
+ for (char in encoded) {
+ // Read input
+ val index = char - '0'
+ if (index < 0 || index > INV.size)
+ throw IllegalArgumentException("invalid Base32 character: $char")
+ val decoded = INV[index]
+ if (decoded == -1)
+ throw IllegalArgumentException("invalid Base32 character: $char")
+ buffer = (buffer shl 5) or decoded
+ bitsLeft += 5
+ // Write bytes
+ if (bitsLeft >= 8) {
+ out[cursor++] = (buffer shr (bitsLeft - 8)).toByte()
+ bitsLeft -= 8 // decrease of written bits.
}
}
- if ((encoded.length * 5) / 8 != out.size())
- throw Exception("base32 decoder: wrong output size")
- }
-
- fun decode(encoded: String): ByteArray {
- val out = ByteArrayOutputStream()
- decode(encoded, out)
- val blob = out.toByteArray()
- return blob
- }
- 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 = Character.toUpperCase(a)
- 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()
+ return out
}
/**
@@ -129,8 +88,7 @@ object Base32Crockford {
* @param dataSize size of the data to encode in bytes
* @return size of the string that would result from encoding
*/
- @Suppress("unused")
- fun calculateEncodedStringLength(dataSize: Int): Int {
+ fun encodedSize(dataSize: Int): Int {
return (dataSize * 8 + 4) / 5
}
@@ -141,9 +99,9 @@ object Base32Crockford {
* @param stringSize size of the string to decode
* @return size of the resulting data in bytes
*/
- @Suppress("unused")
- fun calculateDecodedDataLength(stringSize: Int): Int {
- return stringSize * 5 / 8
+ fun decodedSize(stringSize: Int): Int {
+ return (stringSize * 5) / 8
}
}
-
+
+
+\ No newline at end of file
diff --git a/common/src/main/kotlin/TalerCommon.kt b/common/src/main/kotlin/TalerCommon.kt
@@ -462,7 +462,7 @@ class Base32Crockford32B {
constructor(encoded: String) {
val decoded = try {
Base32Crockford.decode(encoded)
- } catch (e: EncodingException) {
+ } catch (e: IllegalArgumentException) {
null
}
require(decoded != null && decoded.size == 32) {
@@ -515,7 +515,7 @@ class Base32Crockford64B {
constructor(encoded: String) {
val decoded = try {
Base32Crockford.decode(encoded)
- } catch (e: EncodingException) {
+ } catch (e: IllegalArgumentException) {
null
}
diff --git a/common/src/test/kotlin/CryptoUtilTest.kt b/common/src/test/kotlin/CryptoUtilTest.kt
@@ -113,64 +113,6 @@ class CryptoUtilTest {
}
@Test
- fun base32Test() {
- val enc = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"
- val obj = Base32Crockford.decode(enc)
- assertTrue(obj.size == 32)
- val roundTrip = Base32Crockford.encode(obj)
- assertEquals(enc, roundTrip)
- val invalidShorterKey = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCE"
- val shorterBlob = Base32Crockford.decode(invalidShorterKey)
- assertTrue(shorterBlob.size < 32) // See #7980
- }
-
- @Test
- fun blobRoundTrip() {
- val blob = ByteArray(30)
- Random().nextBytes(blob)
- val enc = Base32Crockford.encode(blob)
- val blobAgain = Base32Crockford.decode(enc)
- assertTrue(blob.contentEquals(blobAgain))
- }
-
- /**
- * Manual test: tests that gnunet-base32 and
- * libeufin encode to the same string.
- */
- @Ignore
- fun gnunetEncodeCheck() {
- val blob = ByteArray(30)
- Random().nextBytes(blob)
- val b = Path("/tmp/libeufin-blob.bin")
- b.writeBytes(blob)
- val enc = Base32Crockford.encode(blob)
- // The following output needs to match the one from
- // "gnunet-base32 /tmp/libeufin-blob.bin"
- println(enc)
- }
-
- /**
- * Manual test: tests that gnunet-base32 and
- * libeufin decode to the same value
- */
- @Ignore
- fun gnunetDecodeCheck() {
- // condition: "gnunet-base32 -d /tmp/blob.enc" needs to decode to /tmp/blob.bin
- val blob = Path("/tmp/blob.bin").readBytes()
- val blobEnc = Path("/tmp/blob.enc").readText(Charsets.UTF_8)
- val dec = Base32Crockford.decode(blobEnc)
- assertTrue(blob.contentEquals(dec))
- }
-
- @Test
- fun emptyBase32Test() {
- val enc = Base32Crockford.encode(ByteArray(0))
- assert(enc.isEmpty())
- val blob = Base32Crockford.decode("")
- assert(blob.isEmpty())
- }
-
- @Test
fun passwordHashing() {
val x = PwCrypto.hashpw("myinsecurepw")
assertTrue(PwCrypto.checkpw("myinsecurepw", x))
diff --git a/common/src/test/kotlin/EncodingTest.kt b/common/src/test/kotlin/EncodingTest.kt
@@ -0,0 +1,131 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING. If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+import org.junit.Ignore
+import org.junit.Test
+import tech.libeufin.common.Base32Crockford
+import tech.libeufin.common.crypto.CryptoUtil
+import tech.libeufin.common.crypto.PwCrypto
+import tech.libeufin.common.decodeUpHex
+import tech.libeufin.common.encodeHex
+import tech.libeufin.common.encodeUpHex
+import java.util.*
+import kotlin.io.path.Path
+import kotlin.io.path.readBytes
+import kotlin.io.path.readText
+import kotlin.io.path.writeBytes
+import kotlin.test.*
+
+class EncodingTest {
+ @Test
+ fun base32() {
+ fun roundTripBytes(data: ByteArray) {
+ val encoded = Base32Crockford.encode(data)
+ val decoded = Base32Crockford.decode(encoded)
+ assertContentEquals(data, decoded)
+ }
+
+ // Empty
+ assert(Base32Crockford.encode(ByteArray(0)).isEmpty())
+ assert(Base32Crockford.decode("").isEmpty())
+ roundTripBytes(ByteArray(0))
+
+ // Many size
+ for (size in 0..100) {
+ val blob = ByteArray(size)
+ Random().nextBytes(blob)
+ roundTripBytes(blob)
+ }
+
+ val ORIGINAL = "00111VVBASE32TESTXRST7M8J4H2VY3E0N561BAFWDCPKQG9ZNTG"
+ val LOWERCASE = "00111vvbase32testxrst7m8j4h2vy3e0n561bafwdcpkqg9zntg"
+ val ALT = "0O1ILVUBASE32TESTXRST7M8J4H2VY3E0N561BAFWDCPKQG9ZNTG"
+ val ALT_LOWER = "0o1ilvubase32testxrst7m8j4h2vy3e0n561bafwdcpkqg9zntg"
+ val specialCases = listOf(LOWERCASE, ALT, ALT_LOWER)
+
+ // Common case
+ val decoded = Base32Crockford.decode(ORIGINAL)
+ assertEquals(ORIGINAL, Base32Crockford.encode(decoded))
+
+ // Special cases
+ for (case in specialCases) {
+ assertContentEquals(decoded, Base32Crockford.decode(case))
+ }
+
+ // Bad cases
+ for (case in listOf('(', '\n', '@')) {
+ val err = assertFailsWith<IllegalArgumentException> {
+ Base32Crockford.decode("CROCKFORDBASE32${case}TESTDATA")
+ }
+ assertEquals("invalid Base32 character: $case", err.message)
+ }
+ assertFailsWith<IllegalArgumentException> {
+ Base32Crockford.decode("CROCKFORDBASE32🤯TESTDATA")
+ }
+
+ // Gnunet check
+ val gnunetInstalled = try {
+ val exitValue = ProcessBuilder("gnunet-base32", "-v").start().waitFor()
+ exitValue == 0
+ } catch (e: java.io.IOException) {
+ false
+ }
+ if (gnunetInstalled) {
+ for (size in 0..100) {
+ // Generate random blob
+ val blob = ByteArray(size)
+ Random().nextBytes(blob)
+
+ // Encode with kotlin
+ val encoded = Base32Crockford.encode(blob)
+ // Encode with gnunet
+ val gnunetEncoded = ProcessBuilder("gnunet-base32").start().run {
+ outputStream.use { it.write(blob) }
+ waitFor()
+ inputStream.readBytes().decodeToString()
+ }
+ // Check match
+ assertEquals(encoded, gnunetEncoded)
+
+ // Decode with kotlin
+ val decoded = Base32Crockford.decode(encoded)
+ // Decode with gnunet
+ val gnunetDecoded = ProcessBuilder("gnunet-base32", "-d").start().run {
+ outputStream.use { it.write(encoded.toByteArray()) }
+ waitFor()
+ inputStream.readBytes()
+ }
+ // Check match
+ assertContentEquals(decoded, gnunetDecoded)
+ }
+ for (case in specialCases) {
+ // Decode with kotlin
+ val decoded = Base32Crockford.decode(case)
+ // Decode with gnunet
+ val gnunetDecoded = ProcessBuilder("gnunet-base32", "-d").start().run {
+ outputStream.use { it.write(case.toByteArray()) }
+ waitFor()
+ inputStream.readBytes()
+ }
+ // Check match
+ assertContentEquals(decoded, gnunetDecoded)
+ }
+ }
+ }
+}