From 7e073f17930cdbe5763988ae996e2f804af628ed Mon Sep 17 00:00:00 2001 From: Sean Gilligan Date: Sun, 22 Oct 2023 16:00:17 -0700 Subject: [PATCH] Bech32: refactor, support arbitrary byte[] encodings * Move/refactor utility methods from `SegwitAddress` to `Bech32` * Add `Bech32.Bech32Bytes` class for wrapping `Bech32` 5-bit byte arrays * Have existing `Bech32.Bech32Data` class extend `Bech32.Bech32Bytes` * Add `encodeBytes` and `decodeBytes` to `Bech32` for encoding/decoding arbitrary `byte[]` * Add tests for Nostr NIP-19 test vectors --- .../main/java/org/bitcoinj/base/Bech32.java | 209 ++++++++++++++++-- .../base/DefaultAddressParserProvider.java | 7 +- .../java/org/bitcoinj/base/SegwitAddress.java | 67 +----- .../java/org/bitcoinj/base/Bech32Test.java | 42 +++- 4 files changed, 239 insertions(+), 86 deletions(-) diff --git a/core/src/main/java/org/bitcoinj/base/Bech32.java b/core/src/main/java/org/bitcoinj/base/Bech32.java index 7168b062a..e706134f2 100644 --- a/core/src/main/java/org/bitcoinj/base/Bech32.java +++ b/core/src/main/java/org/bitcoinj/base/Bech32.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 Coinomi Ltd + * Copyright by the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,17 +17,24 @@ package org.bitcoinj.base; import org.bitcoinj.base.exceptions.AddressFormatException; +import org.bitcoinj.base.internal.ByteArray; import javax.annotation.Nullable; +import java.io.ByteArrayOutputStream; import java.util.Arrays; import java.util.Locale; import static org.bitcoinj.base.internal.Preconditions.checkArgument; /** - * Implementation of the Bech32 encoding. - *

See BIP350 and - * BIP173 for details. + * Implementation of the Bech32 encoding. Used in the implementation of {@link SegwitAddress} and + * also provides an API for encoding/decoding arbitrary Bech32 data. To parse Bech32 Bitcoin addresses, + * use {@link AddressParser}. To encode arbitrary Bech32 data, see {@link #encodeBytes(Encoding, String, byte[])}. + * To decode arbitrary Bech32 strings, see {@link #decodeBytes(String, String, Encoding)} or {@link #decode(String)}. + *

+ * Based on the original Coinomi implementation. + * @see BIP173 + * @see BIP350 */ public class Bech32 { /** The Bech32 character set for encoding. */ @@ -54,17 +61,103 @@ public class Bech32 { public enum Encoding { BECH32, BECH32M } /** - * Bech32 decoded data in 5-bit byte format. Typically, the result of {@link #decode(String)}. + * Binary data in 5-bits-per-byte format as used in Bech32 encoding/decoding. */ - public static class Bech32Data { + public static class Bech32Bytes extends ByteArray { + /** + * Wrapper for a {@code byte[]} array. + * + * @param bytes bytes to be copied (5-bits per byte format) + */ + protected Bech32Bytes(byte[] bytes) { + super(bytes); + } + + /** + * Construct an instance, from two parts. Useful for the Segwit implementation, + * see {@link #ofSegwit(short, byte[])}. + * @param first first byte (5-bits per byte format) + * @param rest remaining bytes (5-bits per byte format) + */ + private Bech32Bytes(byte first, byte[] rest) { + super(concat(first, rest)); + } + + private static byte[] concat(byte first, byte[] rest) { + byte[] bytes = new byte[rest.length + 1]; + bytes[0] = first; + System.arraycopy(rest, 0, bytes, 1, rest.length); + return bytes; + } + + /** + * Create an instance from arbitrary data, converts from 8-bits per byte + * format to 5-bits per byte format before construction. + * @param data arbitrary byte array (8-bits of data per byte) + * @return Bech32 instance containing 5-bit encoding + */ + static Bech32Bytes ofBytes(byte[] data) { + return new Bech32Bytes(encode8to5(data)); + } + + /** + * Create an instance from Segwit address binary data. + * @param witnessVersion A short containing (5-bit) witness version information + * @param witnessProgram a witness program (8-bits-per byte) + * @return Bech32 instance containing 5-bit encoding + */ + static Bech32Bytes ofSegwit(short witnessVersion, byte[] witnessProgram) { + // convert witnessVersion, witnessProgram to 5-bit Bech32Bytes + return new Bech32Bytes((byte) (witnessVersion & 0xff), encode8to5(witnessProgram)); + } + + private static byte[] encode8to5(byte[] data) { + return convertBits(data, 0, data.length, 8, 5, true); + } + + /** + * Return the data, fully-decoded with 8-bits per byte. + * @return The data, fully-decoded as a byte array. + */ + public byte[] decode5to8() { + return convertBits(bytes, 0, bytes.length, 5, 8, false); + } + + /** + * @return the first byte (witness version if instance is a Segwit address) + */ + short witnessVersion() { + return bytes[0]; + } + + // Trim the version byte and return the witness program only + private Bech32Bytes stripFirst() { + byte[] program = new byte[bytes.length - 1]; + System.arraycopy(bytes, 1, program, 0, program.length); + return new Bech32Bytes(program); + } + + /** + * Assuming this instance contains a Segwit address, return the witness program portion of the data. + * @return The witness program as a byte array + */ + byte[] witnessProgram() { + return stripFirst().decode5to8(); + } + } + + /** + * Bech32 data in 5-bit byte format with {@link Encoding} and human-readable part (HRP) information. + * Typically, the result of {@link #decode(String)}. + */ + public static class Bech32Data extends Bech32Bytes { public final Encoding encoding; public final String hrp; - public final byte[] data; private Bech32Data(final Encoding encoding, final String hrp, final byte[] data) { + super(data); this.encoding = encoding; this.hrp = hrp; - this.data = data; } } @@ -97,8 +190,8 @@ public class Bech32 { } /** Verify a checksum. */ - private static @Nullable - Encoding verifyChecksum(final String hrp, final byte[] values) { + @Nullable + private static Encoding verifyChecksum(final String hrp, final byte[] values) { byte[] hrpExpanded = expandHrp(hrp); byte[] combined = new byte[hrpExpanded.length + values.length]; System.arraycopy(hrpExpanded, 0, combined, 0, hrpExpanded.length); @@ -126,20 +219,57 @@ public class Bech32 { return ret; } - /** Encode a Bech32 string. */ - public static String encode(final Bech32Data bech32) { - return encode(bech32.encoding, bech32.hrp, bech32.data); + /** + * Encode a byte array to a Bech32 string + * @param encoding Desired encoding Bech32 or Bech32m + * @param hrp human-readable part to use for encoding + * @param bytes Arbitrary binary data (8-bits per byte) + * @return A Bech32 string + */ + public static String encodeBytes(Encoding encoding, String hrp, byte[] bytes) { + return encode(encoding, hrp, Bech32Bytes.ofBytes(bytes)); } - /** Encode a Bech32 string. */ - public static String encode(Encoding encoding, final String hrp, final byte[] values) { + /** + * Decode a Bech32 string to a byte array. + * @param bech32 A Bech32 format string + * @param expectedHrp Expected value for the human-readable part + * @param expectedEncoding Expected encoding + * @return Decoded value as byte array (8-bits per byte) + * @throws AddressFormatException if unexpected hrp or encoding + */ + public static byte[] decodeBytes(String bech32, String expectedHrp, Encoding expectedEncoding) { + Bech32.Bech32Data decoded = decode(bech32); + if (!decoded.hrp.equals(expectedHrp) || decoded.encoding != expectedEncoding) { + throw new AddressFormatException("unexpected hrp or encoding"); + } + return decoded.decode5to8(); + } + + /** + * Encode a Bech32 string. + * @param bech32 Contains 5-bits/byte data, desired encoding and human-readable part + * @return A string containing the Bech32-encoded data + */ + public static String encode(final Bech32Data bech32) { + return encode(bech32.encoding, bech32.hrp, bech32); + } + + /** + * Encode a Bech32 string. + * @param encoding The requested encoding + * @param hrp The requested human-readable part + * @param values Binary data in 5-bit per byte format + * @return A string containing the Bech32-encoded data + */ + public static String encode(Encoding encoding, String hrp, Bech32Bytes values) { checkArgument(hrp.length() >= 1, () -> "human-readable part is too short: " + hrp.length()); checkArgument(hrp.length() <= 83, () -> "human-readable part is too long: " + hrp.length()); String lcHrp = hrp.toLowerCase(Locale.ROOT); - byte[] checksum = createChecksum(encoding, lcHrp, values); - byte[] combined = new byte[values.length + checksum.length]; - System.arraycopy(values, 0, combined, 0, values.length); - System.arraycopy(checksum, 0, combined, values.length, checksum.length); + byte[] checksum = createChecksum(encoding, lcHrp, values.bytes()); + byte[] combined = new byte[values.bytes().length + checksum.length]; + System.arraycopy(values.bytes(), 0, combined, 0, values.bytes().length); + System.arraycopy(checksum, 0, combined, values.bytes().length, checksum.length); StringBuilder sb = new StringBuilder(lcHrp.length() + 1 + combined.length); sb.append(lcHrp); sb.append('1'); @@ -149,7 +279,14 @@ public class Bech32 { return sb.toString(); } - /** Decode a Bech32 string. */ + /** + * Decode a Bech32 string. + *

+ * To get the fully-decoded data, call {@link Bech32Bytes#decode5to8()} on the returned {@code Bech32Data}. + * @param str A string containing Bech32-encoded data + * @return An object with the detected encoding, hrp, and decoded data (in 5-bit per byte format) + * @throws AddressFormatException if the string is invalid + */ public static Bech32Data decode(final String str) throws AddressFormatException { boolean lower = false, upper = false; if (str.length() < 8) @@ -185,4 +322,36 @@ public class Bech32 { if (encoding == null) throw new AddressFormatException.InvalidChecksum(); return new Bech32Data(encoding, hrp, Arrays.copyOfRange(values, 0, values.length - 6)); } + + /** + * Helper for re-arranging bits into groups. + */ + private static byte[] convertBits(final byte[] in, final int inStart, final int inLen, final int fromBits, + final int toBits, final boolean pad) throws AddressFormatException { + int acc = 0; + int bits = 0; + ByteArrayOutputStream out = new ByteArrayOutputStream(64); + final int maxv = (1 << toBits) - 1; + final int max_acc = (1 << (fromBits + toBits - 1)) - 1; + for (int i = 0; i < inLen; i++) { + int value = in[i + inStart] & 0xff; + if ((value >>> fromBits) != 0) { + throw new AddressFormatException( + String.format("Input value '%X' exceeds '%d' bit size", value, fromBits)); + } + acc = ((acc << fromBits) | value) & max_acc; + bits += fromBits; + while (bits >= toBits) { + bits -= toBits; + out.write((acc >>> bits) & maxv); + } + } + if (pad) { + if (bits > 0) + out.write((acc << (toBits - bits)) & maxv); + } else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv) != 0) { + throw new AddressFormatException("Could not convert bits, invalid padding"); + } + return out.toByteArray(); + } } diff --git a/core/src/main/java/org/bitcoinj/base/DefaultAddressParserProvider.java b/core/src/main/java/org/bitcoinj/base/DefaultAddressParserProvider.java index e7c74bd3c..1afe998c2 100644 --- a/core/src/main/java/org/bitcoinj/base/DefaultAddressParserProvider.java +++ b/core/src/main/java/org/bitcoinj/base/DefaultAddressParserProvider.java @@ -129,12 +129,13 @@ class DefaultAddressParserProvider implements AddressParser.AddressParserProvide */ private SegwitAddress parseBech32AnyNetwork(String bech32) throws AddressFormatException { - String hrp = Bech32.decode(bech32).hrp; - return segwitNetworks.stream() + Bech32.Bech32Data bechData = Bech32.decode(bech32); + String hrp = bechData.hrp; + Network network = segwitNetworks.stream() .filter(n -> hrp.equals(n.segwitAddressHrp())) .findFirst() - .map(n -> SegwitAddress.fromBech32(bech32, n)) .orElseThrow(() -> new AddressFormatException.InvalidPrefix("No network found for " + bech32)); + return SegwitAddress.fromBechData(network, bechData); } /** diff --git a/core/src/main/java/org/bitcoinj/base/SegwitAddress.java b/core/src/main/java/org/bitcoinj/base/SegwitAddress.java index bd91217db..c643e24c3 100644 --- a/core/src/main/java/org/bitcoinj/base/SegwitAddress.java +++ b/core/src/main/java/org/bitcoinj/base/SegwitAddress.java @@ -23,7 +23,6 @@ import org.bitcoinj.core.NetworkParameters; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import java.io.ByteArrayOutputStream; import java.util.Arrays; import java.util.Comparator; import java.util.EnumSet; @@ -135,14 +134,6 @@ public class SegwitAddress implements Address { return network; } - private static byte[] encode8to5(byte[] data) { - return convertBits(data, 0, data.length, 8, 5, true); - } - - private static byte[] decode5to8(byte[] data) { - return convertBits(data, 0, data.length, 5, 8, false); - } - /** * Private constructor. Use {@link #fromBech32(String, Network)}, * {@link #fromHash(Network, byte[])} or {@link ECKey#toAddress(ScriptType, Network)}. @@ -276,13 +267,12 @@ public class SegwitAddress implements Address { throw new AddressFormatException.WrongNetwork(bechData.hrp); } - private static SegwitAddress fromBechData(Network network, Bech32.Bech32Data bechData) { - if (bechData.data.length < 1) { + static SegwitAddress fromBechData(@Nonnull Network network, Bech32.Bech32Data bechData) { + if (bechData.bytes().length < 1) { throw new AddressFormatException.InvalidDataLength("invalid address length (0)"); } - final int witnessVersion = bechData.data[0]; - byte[] witnessProgram = decode5to8(trimVersion(bechData.data)); - final SegwitAddress address = new SegwitAddress(network, witnessVersion, witnessProgram); + final int witnessVersion = bechData.witnessVersion(); + final SegwitAddress address = new SegwitAddress(network, witnessVersion, bechData.witnessProgram()); if ((witnessVersion == 0 && bechData.encoding != Bech32.Encoding.BECH32) || (witnessVersion != 0 && bechData.encoding != Bech32.Encoding.BECH32M)) throw new AddressFormatException.UnexpectedWitnessVersion("Unexpected witness version: " + witnessVersion); @@ -396,54 +386,7 @@ public class SegwitAddress implements Address { */ public String toBech32() { Bech32.Encoding encoding = (witnessVersion == 0) ? Bech32.Encoding.BECH32 : Bech32.Encoding.BECH32M; - return Bech32.encode(encoding, network.segwitAddressHrp(), appendVersion(witnessVersion, encode8to5(witnessProgram))); - } - - // Trim the version byte and return the witness program only - private static byte[] trimVersion(byte[] data) { - byte[] program = new byte[data.length - 1]; - System.arraycopy(data, 1, program, 0, program.length); - return program; - } - - // concatenate the witnessVersion and witnessProgram - private static byte[] appendVersion(short version, byte[] program) { - byte[] data = new byte[program.length + 1]; - data[0] = (byte) version; - System.arraycopy(program, 0, data, 1, program.length); - return data; - } - - /** - * Helper for re-arranging bits into groups. - */ - private static byte[] convertBits(final byte[] in, final int inStart, final int inLen, final int fromBits, - final int toBits, final boolean pad) throws AddressFormatException { - int acc = 0; - int bits = 0; - ByteArrayOutputStream out = new ByteArrayOutputStream(64); - final int maxv = (1 << toBits) - 1; - final int max_acc = (1 << (fromBits + toBits - 1)) - 1; - for (int i = 0; i < inLen; i++) { - int value = in[i + inStart] & 0xff; - if ((value >>> fromBits) != 0) { - throw new AddressFormatException( - String.format("Input value '%X' exceeds '%d' bit size", value, fromBits)); - } - acc = ((acc << fromBits) | value) & max_acc; - bits += fromBits; - while (bits >= toBits) { - bits -= toBits; - out.write((acc >>> bits) & maxv); - } - } - if (pad) { - if (bits > 0) - out.write((acc << (toBits - bits)) & maxv); - } else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv) != 0) { - throw new AddressFormatException("Could not convert bits, invalid padding"); - } - return out.toByteArray(); + return Bech32.encode(encoding, network.segwitAddressHrp(), Bech32.Bech32Bytes.ofSegwit(witnessVersion, witnessProgram)); } // Comparator for SegwitAddress, left argument must be SegwitAddress, right argument can be any Address diff --git a/core/src/test/java/org/bitcoinj/base/Bech32Test.java b/core/src/test/java/org/bitcoinj/base/Bech32Test.java index adfd932ab..c4a61630a 100644 --- a/core/src/test/java/org/bitcoinj/base/Bech32Test.java +++ b/core/src/test/java/org/bitcoinj/base/Bech32Test.java @@ -16,14 +16,19 @@ package org.bitcoinj.base; +import junitparams.JUnitParamsRunner; +import junitparams.Parameters; import org.bitcoinj.base.exceptions.AddressFormatException; +import org.bitcoinj.base.internal.ByteUtils; import org.junit.Test; +import org.junit.runner.RunWith; import java.util.Locale; import static org.junit.Assert.assertEquals; import static org.junit.Assert.fail; +@RunWith(JUnitParamsRunner.class) public class Bech32Test { @Test public void valid_bech32() { @@ -43,7 +48,7 @@ public class Bech32Test { assertEquals(String.format("Failed to roundtrip '%s' -> '%s'", valid, recode), valid.toLowerCase(Locale.ROOT), recode.toLowerCase(Locale.ROOT)); // Test encoding with an uppercase HRP - recode = Bech32.encode(bechData.encoding, bechData.hrp.toUpperCase(Locale.ROOT), bechData.data); + recode = Bech32.encode(bechData.encoding, bechData.hrp.toUpperCase(Locale.ROOT), bechData); assertEquals(String.format("Failed to roundtrip '%s' -> '%s'", valid, recode), valid.toLowerCase(Locale.ROOT), recode.toLowerCase(Locale.ROOT)); } @@ -120,6 +125,41 @@ public class Bech32Test { "1p2gdwpf", // empty HRP }; + @Test + @Parameters(method = "nip19Vectors") + public void encodeBytes(String hex, String hrp, String expectedBech32) { + String bech32 = Bech32.encodeBytes(Bech32.Encoding.BECH32, hrp, ByteUtils.parseHex(hex)); + assertEquals("incorrect encoding", expectedBech32, bech32); + } + + @Test + @Parameters(method = "nip19Vectors") + public void decodeBytes(String expectedHex, String hrp, String bech32) { + Bech32.Bech32Data decoded = Bech32.decode(bech32); + String decodedData = ByteUtils.formatHex(decoded.decode5to8()); + + assertEquals("incorrect encoding type", Bech32.Encoding.BECH32, decoded.encoding); + assertEquals("incorrect hrp", hrp, decoded.hrp); + assertEquals("incorrect decoded data", expectedHex, decodedData); + } + + @Test + @Parameters(method = "nip19Vectors") + public void decodeBytes2(String expectedHex, String hrp, String bech32) { + byte[] decoded = Bech32.decodeBytes(bech32, hrp, Bech32.Encoding.BECH32); + + assertEquals("incorrect decoded data", expectedHex, ByteUtils.formatHex(decoded)); + } + + // These vectors are from NIP-19: https://github.com/nostr-protocol/nips/blob/master/19.md + private Object[] nip19Vectors() { + return new Object[]{ + new Object[]{ "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", "npub", "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"}, + new Object[]{ "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e", "npub", "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"}, + new Object[]{ "67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa", "nsec", "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"}, + }; + } + @Test(expected = AddressFormatException.InvalidCharacter.class) public void decode_invalidCharacter_notInAlphabet() { Bech32.decode("A12OUEL5X");