libeufin

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

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:
Mcommon/src/main/kotlin/Encoding.kt | 147+++++++++++++++++++++++++++++--------------------------------------------------
Mcommon/src/main/kotlin/TalerCommon.kt | 4++--
Mcommon/src/test/kotlin/CryptoUtilTest.kt | 58----------------------------------------------------------
Acommon/src/test/kotlin/EncodingTest.kt | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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) + } + } + } +}