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
This commit is contained in:
Sean Gilligan 2023-10-22 16:00:17 -07:00 committed by Andreas Schildbach
parent 539d25b35a
commit 7e073f1793
4 changed files with 239 additions and 86 deletions

View file

@ -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.
* <p>See <a href="https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki">BIP350</a> and
* <a href="https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki">BIP173</a> 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)}.
* <p>
* Based on the original Coinomi implementation.
* @see <a href="https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki">BIP173</a>
* @see <a href="https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki">BIP350</a>
*/
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.
* <p>
* 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();
}
}

View file

@ -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);
}
/**

View file

@ -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

View file

@ -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");