From e87873163fcc098bdb5ff63f408010c368992054 Mon Sep 17 00:00:00 2001 From: OldTruckDriver Date: Wed, 17 Jun 2026 23:22:00 +1000 Subject: [PATCH] [CODEC-340] Fix Base58 custom alphabet handling Derive a matching decode table from custom Base58 encode tables and use the configured tables when encoding, decoding, and checking the alphabet. Handle leading zero bytes with the first entry of the configured alphabet instead of hard-coding '1'. Reviewed-by: OpenAI Codex Reviewed-by: Anthropic Claude Code --- src/changes/changes.xml | 1 + .../apache/commons/codec/binary/Base58.java | 92 ++++++++++++++----- .../commons/codec/binary/Base58Test.java | 53 +++++++++++ 3 files changed, 123 insertions(+), 23 deletions(-) diff --git a/src/changes/changes.xml b/src/changes/changes.xml index 3dc2f6c409..64b563fcbb 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -45,6 +45,7 @@ The type attribute can be add,update,fix,remove. + Base58.Builder.setEncodeTable(byte...) is ignored when encoding and decoding. Digest ALL reuses System.in, so only the first algorithm sees the real input (#431). diff --git a/src/main/java/org/apache/commons/codec/binary/Base58.java b/src/main/java/org/apache/commons/codec/binary/Base58.java index 5372dd8e53..354c26f607 100644 --- a/src/main/java/org/apache/commons/codec/binary/Base58.java +++ b/src/main/java/org/apache/commons/codec/binary/Base58.java @@ -18,7 +18,7 @@ package org.apache.commons.codec.binary; import java.math.BigInteger; -import java.nio.charset.StandardCharsets; +import java.util.Arrays; /** * Provides Base58 encoding and decoding as commonly used in cryptocurrency and blockchain applications. @@ -75,18 +75,23 @@ public Base58 get() { } /** - * Creates a new Base58 codec instance. + * Sets the encode table and derives the matching decode table. * - * @return a new Base58 codec. + * @param encodeTable the encode table with exactly 58 unique entries, null resets to the default. + * @return {@code this} instance. + * @throws IllegalArgumentException if the encode table does not contain exactly 58 unique entries. */ @Override public Base58.Builder setEncodeTable(final byte... encodeTable) { - super.setDecodeTableRaw(DECODE_TABLE); + super.setDecodeTableRaw(toDecodeTable(encodeTable)); return super.setEncodeTable(encodeTable); } } private static final BigInteger BASE = BigInteger.valueOf(58); + private static final int DECODING_TABLE_LENGTH = 256; + private static final int ENCODING_TABLE_LENGTH = 58; + private static final byte[] EMPTY = new byte[0]; /** @@ -138,6 +143,43 @@ public static Builder builder() { return new Builder(); } + /** + * Calculates a decode table for a given encode table. + * + * @param encodeTable that is used to determine decode lookup table. + * @return A new decode table. + * @throws IllegalArgumentException if the encode table does not contain exactly 58 unique entries. + */ + private static byte[] calculateDecodeTable(final byte[] encodeTable) { + if (encodeTable.length != ENCODING_TABLE_LENGTH) { + throw new IllegalArgumentException("encodeTable must have exactly 58 entries."); + } + final byte[] decodeTable = new byte[DECODING_TABLE_LENGTH]; + Arrays.fill(decodeTable, (byte) -1); + for (int i = 0; i < encodeTable.length; i++) { + final int encodedByte = encodeTable[i] & 0xff; + if (decodeTable[encodedByte] != -1) { + throw new IllegalArgumentException("encodeTable must not contain duplicate entries."); + } + decodeTable[encodedByte] = (byte) i; + } + return decodeTable; + } + + /** + * Gets the decode table that matches the given encode table. + * + * @param encodeTable that is used to determine decode lookup table. + * @return the matching decode table. + */ + private static byte[] toDecodeTable(final byte[] encodeTable) { + final byte[] table = encodeTable != null ? encodeTable : ENCODE_TABLE; + if (Arrays.equals(table, ENCODE_TABLE)) { + return DECODE_TABLE; + } + return calculateDecodeTable(table); + } + /** * Constructs a Base58 codec used for encoding and decoding. */ @@ -157,8 +199,8 @@ public Base58(final Builder builder) { /** * Converts Base58 encoded data to binary. *

- * Uses BigInteger arithmetic to convert the Base58 string to binary data. Leading '1' characters in the Base58 encoding represent leading zero bytes in the - * binary data. + * Uses BigInteger arithmetic to convert the Base58 string to binary data. Leading characters that match the first Base58 alphabet entry represent leading + * zero bytes in the binary data. *

* * @param base58 the Base58 encoded data. @@ -167,17 +209,18 @@ public Base58(final Builder builder) { */ private void convertFromBase58(final byte[] base58, final Context context) { BigInteger value = BigInteger.ZERO; - int leadingOnes = 0; + int leadingZeros = 0; + final int zero = encodeTable[0] & 0xff; for (final byte b : base58) { - if (b != '1') { + if ((b & 0xff) != zero) { break; } - leadingOnes++; + leadingZeros++; } BigInteger power = BigInteger.ONE; - for (int i = base58.length - 1; i >= leadingOnes; i--) { - final byte b = base58[i]; - final int digit = b < DECODE_TABLE.length ? DECODE_TABLE[b] : -1; + for (int i = base58.length - 1; i >= leadingZeros; i--) { + final int b = base58[i] & 0xff; + final int digit = b < decodeTable.length ? decodeTable[b] : -1; if (digit < 0) { throw new IllegalArgumentException(String.format("Invalid character in Base58 string: 0x%02x", b)); } @@ -190,8 +233,8 @@ private void convertFromBase58(final byte[] base58, final Context context) { System.arraycopy(decoded, 1, tmp, 0, tmp.length); decoded = tmp; } - final byte[] result = new byte[leadingOnes + decoded.length]; - System.arraycopy(decoded, 0, result, leadingOnes, decoded.length); + final byte[] result = new byte[leadingZeros + decoded.length]; + System.arraycopy(decoded, 0, result, leadingZeros, decoded.length); final byte[] buffer = ensureBufferSize(result.length, context); System.arraycopy(result, 0, buffer, context.pos, result.length); context.pos += result.length; @@ -200,8 +243,8 @@ private void convertFromBase58(final byte[] base58, final Context context) { /** * Converts accumulated binary data to Base58 encoding. *

- * Uses BigInteger arithmetic to convert the binary data to Base58. Leading zeros in the binary data are represented as '1' characters in the Base58 - * encoding. + * Uses BigInteger arithmetic to convert the binary data to Base58. Leading zeros in the binary data are represented as the first character in the Base58 + * alphabet. *

* * @param accumulate the binary data to encode. @@ -210,8 +253,10 @@ private void convertFromBase58(final byte[] base58, final Context context) { */ private byte[] convertToBase58(final byte[] accumulate, final Context context) { final StringBuilder base58 = getStringBuilder(accumulate); - final String encoded = base58.reverse().toString(); - final byte[] encodedBytes = encoded.getBytes(StandardCharsets.UTF_8); + final byte[] encodedBytes = new byte[base58.length()]; + for (int i = 0; i < encodedBytes.length; i++) { + encodedBytes[i] = (byte) base58.charAt(encodedBytes.length - 1 - i); + } final byte[] buffer = ensureBufferSize(encodedBytes.length, context); System.arraycopy(encodedBytes, 0, buffer, context.pos, encodedBytes.length); context.pos += encodedBytes.length; @@ -285,8 +330,8 @@ void encode(final byte[] array, final int offset, final int length, final Contex /** * Builds the Base58 string representation of the given binary data. *

- * Converts binary data to a BigInteger and divides by 58 repeatedly to get the Base58 digits. Handles leading zeros by counting them and appending '1' for - * each leading zero byte. + * Converts binary data to a BigInteger and divides by 58 repeatedly to get the Base58 digits. Handles leading zeros by counting them and appending the first + * character in the Base58 alphabet for each leading zero byte. *

* * @param accumulate the binary data to convert. @@ -304,11 +349,11 @@ private StringBuilder getStringBuilder(final byte[] accumulate) { final StringBuilder base58 = new StringBuilder(); while (value.signum() > 0) { final BigInteger[] divRem = value.divideAndRemainder(BASE); - base58.append((char) ENCODE_TABLE[divRem[1].intValue()]); + base58.append((char) (encodeTable[divRem[1].intValue()] & 0xff)); value = divRem[0]; } for (int i = 0; i < leadingZeros; i++) { - base58.append('1'); + base58.append((char) (encodeTable[0] & 0xff)); } return base58; } @@ -321,6 +366,7 @@ private StringBuilder getStringBuilder(final byte[] accumulate) { */ @Override protected boolean isInAlphabet(final byte value) { - return isInAlphabet(value, DECODE_TABLE); + final int octet = value & 0xff; + return octet < decodeTable.length && decodeTable[octet] != -1; } } diff --git a/src/test/java/org/apache/commons/codec/binary/Base58Test.java b/src/test/java/org/apache/commons/codec/binary/Base58Test.java index c5772e5223..5a0ffb5e79 100644 --- a/src/test/java/org/apache/commons/codec/binary/Base58Test.java +++ b/src/test/java/org/apache/commons/codec/binary/Base58Test.java @@ -46,6 +46,8 @@ public class Base58Test { private static final int BOUND = 10_000; + private static final String DEFAULT_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + private static final Charset CHARSET_UTF8 = StandardCharsets.UTF_8; private static void assertArrayEqualsAt(final byte[] data, final byte[] dec, final int i) { @@ -53,6 +55,18 @@ private static void assertArrayEqualsAt(final byte[] data, final byte[] dec, fin assertArrayEquals(data, dec, () -> String.format("Failed for length %,d: %s", counter.get(), Arrays.toString(data))); } + private static byte[] newEncodeTable() { + return DEFAULT_ALPHABET.getBytes(StandardCharsets.US_ASCII); + } + + private static byte[] newSwappedEncodeTable() { + final byte[] encodeTable = newEncodeTable(); + final byte tmp = encodeTable[0]; + encodeTable[0] = encodeTable[1]; + encodeTable[1] = tmp; + return encodeTable; + } + private final Random random = new Random(); @Test @@ -66,6 +80,45 @@ void testBase58() { assertEquals(content, decodedContent, "decoding hello world"); } + @Test + void testBuilderCustomEncodeTableAffectsEncodeAndDecode() { + final Base58 base58 = Base58.builder().setEncodeTable(newSwappedEncodeTable()).get(); + assertEquals("1", new String(base58.encode(new byte[] { 1 }), StandardCharsets.US_ASCII)); + assertArrayEquals(new byte[] { 1 }, base58.decode("1".getBytes(StandardCharsets.US_ASCII))); + } + + @Test + void testBuilderCustomEncodeTableAffectsIsInAlphabet() { + final byte[] encodeTable = newEncodeTable(); + encodeTable[0] = '0'; + final Base58 base58 = Base58.builder().setEncodeTable(encodeTable).get(); + assertTrue(base58.isInAlphabet((byte) '0')); + assertFalse(base58.isInAlphabet((byte) '1')); + assertEquals("0", new String(base58.encode(new byte[] { 0 }), StandardCharsets.US_ASCII)); + assertArrayEquals(new byte[] { 0 }, base58.decode("0".getBytes(StandardCharsets.US_ASCII))); + } + + @Test + void testBuilderCustomEncodeTableAffectsLeadingZeros() { + final Base58 base58 = Base58.builder().setEncodeTable(newSwappedEncodeTable()).get(); + final byte[] data = { 0, 0, 1 }; + final byte[] encoded = base58.encode(data); + assertEquals("221", new String(encoded, StandardCharsets.US_ASCII)); + assertArrayEquals(data, base58.decode(encoded)); + } + + @Test + void testBuilderCustomEncodeTableRejectsDuplicateEntries() { + final byte[] encodeTable = newEncodeTable(); + encodeTable[1] = encodeTable[0]; + assertThrows(IllegalArgumentException.class, () -> Base58.builder().setEncodeTable(encodeTable)); + } + + @Test + void testBuilderCustomEncodeTableRejectsInvalidLength() { + assertThrows(IllegalArgumentException.class, () -> Base58.builder().setEncodeTable(Arrays.copyOf(newEncodeTable(), DEFAULT_ALPHABET.length() - 1))); + } + @Test void testEmptyBase58() { byte[] empty = {};