Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/changes/changes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ The <action> type attribute can be add,update,fix,remove.
<body>
<release version="1.22.1" date="YYYY-MM-DD" description="This is a feature and maintenance release. Java 8 or later is required.">
<!-- FIX -->
<action type="fix" issue="CODEC-340" dev="ggregory" due-to="Ruiqi Dong, Gary Gregory">Base58.Builder.setEncodeTable(byte...) is ignored when encoding and decoding.</action>
<action type="fix" issue="CODEC-342" dev="ggregory" due-to="Ruiqi Dong, Gary Gregory">Base32.Builder.setEncodeTable(byte...) can create a codec that cannot decode its own output.</action>
<action type="fix" issue="CODEC-343" dev="ggregory" due-to="Ruiqi Dong, Gary Gregory">Base32.Builder.setHexDecodeTable(boolean) sets the encode table to a decode lookup table.</action>
<action type="fix" issue="CODEC-341" dev="ggregory" due-to="Ruiqi Dong, Gary Gregory">Base16.Builder.setEncodeTable(byte...) can create a codec that cannot decode its own output.</action>
Expand Down
92 changes: 69 additions & 23 deletions src/main/java/org/apache/commons/codec/binary/Base58.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 = {};

/**
Expand Down Expand Up @@ -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.
*/
Expand All @@ -157,8 +199,8 @@ public Base58(final Builder builder) {
/**
* Converts Base58 encoded data to binary.
* <p>
* 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.
* </p>
*
* @param base58 the Base58 encoded data.
Expand All @@ -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));
}
Expand All @@ -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;
Expand All @@ -200,8 +243,8 @@ private void convertFromBase58(final byte[] base58, final Context context) {
/**
* Converts accumulated binary data to Base58 encoding.
* <p>
* 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.
* </p>
*
* @param accumulate the binary data to encode.
Expand All @@ -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;
Expand Down Expand Up @@ -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.
* <p>
* 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.
* </p>
*
* @param accumulate the binary data to convert.
Expand All @@ -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;
}
Expand All @@ -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;
}
}
53 changes: 53 additions & 0 deletions src/test/java/org/apache/commons/codec/binary/Base58Test.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,27 @@ 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) {
final AtomicInteger counter = new AtomicInteger(i);
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
Expand All @@ -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 = {};
Expand Down
Loading