diff --git a/src/api/UtilsResource.java b/src/api/UtilsResource.java
index 9016ea92..98cc72a8 100644
--- a/src/api/UtilsResource.java
+++ b/src/api/UtilsResource.java
@@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
+import qora.account.PrivateKeyAccount;
import qora.crypto.Crypto;
import utils.BIP39;
import utils.Base58;
@@ -13,23 +14,22 @@ import utils.Base58;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
-import java.util.List;
import java.util.UUID;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
+import com.google.common.hash.HashCode;
import com.google.common.primitives.Bytes;
import com.google.common.primitives.Longs;
-import globalization.BIP39WordList;
-
@Path("/utils")
@Produces({
MediaType.TEXT_PLAIN
@@ -43,9 +43,9 @@ public class UtilsResource {
HttpServletRequest request;
@POST
- @Path("/base58from64")
+ @Path("/fromBase64")
@Operation(
- summary = "Convert base64 data to base58",
+ summary = "Convert base64 data to hex",
requestBody = @RequestBody(
required = true,
content = @Content(
@@ -57,7 +57,7 @@ public class UtilsResource {
),
responses = {
@ApiResponse(
- description = "base58 data",
+ description = "hex string",
content = @Content(
schema = @Schema(
type = "string"
@@ -66,18 +66,18 @@ public class UtilsResource {
)
}
)
- public String base58from64(String base64) {
+ public String fromBase64(String base64) {
try {
- return Base58.encode(Base64.getDecoder().decode(base64.trim()));
+ return HashCode.fromBytes(Base64.getDecoder().decode(base64.trim())).toString();
} catch (IllegalArgumentException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA);
}
}
@POST
- @Path("/base64from58")
+ @Path("/fromBase58")
@Operation(
- summary = "Convert base58 data to base64",
+ summary = "Convert base58 data to hex",
requestBody = @RequestBody(
required = true,
content = @Content(
@@ -89,7 +89,7 @@ public class UtilsResource {
),
responses = {
@ApiResponse(
- description = "base64 data",
+ description = "hex string",
content = @Content(
schema = @Schema(
type = "string"
@@ -100,17 +100,55 @@ public class UtilsResource {
)
public String base64from58(String base58) {
try {
- return Base64.getEncoder().encodeToString(Base58.decode(base58.trim()));
+ return HashCode.fromBytes(Base58.decode(base58.trim())).toString();
} catch (NumberFormatException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA);
}
}
@GET
- @Path("/seed")
+ @Path("/toBase64/{hex}")
@Operation(
- summary = "Generate random seed",
- description = "Optionally pass seed length, defaults to 32 bytes.",
+ summary = "Convert hex to base64",
+ responses = {
+ @ApiResponse(
+ description = "base64",
+ content = @Content(
+ schema = @Schema(
+ type = "string"
+ )
+ )
+ )
+ }
+ )
+ public String toBase64(@PathParam("hex") String hex) {
+ return Base64.getEncoder().encodeToString(HashCode.fromString(hex).asBytes());
+ }
+
+ @GET
+ @Path("/toBase58/{hex}")
+ @Operation(
+ summary = "Convert hex to base58",
+ responses = {
+ @ApiResponse(
+ description = "base58",
+ content = @Content(
+ schema = @Schema(
+ type = "string"
+ )
+ )
+ )
+ }
+ )
+ public String toBase58(@PathParam("hex") String hex) {
+ return Base58.encode(HashCode.fromString(hex).asBytes());
+ }
+
+ @GET
+ @Path("/random")
+ @Operation(
+ summary = "Generate random data",
+ description = "Optionally pass data length, defaults to 32 bytes.",
responses = {
@ApiResponse(
description = "base58 data",
@@ -123,24 +161,24 @@ public class UtilsResource {
)
}
)
- public String seed(@QueryParam("length") Integer length) {
+ public String random(@QueryParam("length") Integer length) {
if (length == null)
length = 32;
- byte[] seed = new byte[length];
- new SecureRandom().nextBytes(seed);
- return Base58.encode(seed);
+ byte[] random = new byte[length];
+ new SecureRandom().nextBytes(random);
+ return Base58.encode(random);
}
@GET
- @Path("/seedPhrase")
+ @Path("/mnemonic")
@Operation(
- summary = "Generate random 12-word BIP39 seed phrase",
- description = "Optionally pass 16-byte, base58-encoded entropy input or entropy will be internally generated.
"
+ summary = "Generate 12-word BIP39 mnemonic",
+ description = "Optionally pass 16-byte, base58-encoded entropy or entropy will be internally generated.
"
+ "Example entropy input: YcVfxkQb6JRzqk5kF2tNLv",
responses = {
@ApiResponse(
- description = "seed phrase",
+ description = "mnemonic",
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
@@ -150,25 +188,23 @@ public class UtilsResource {
)
}
)
- public String seedPhrase(@QueryParam("entropy") String input) {
+ public String getMnemonic(@QueryParam("entropy") String suppliedEntropy) {
/*
* BIP39 word lists have 2048 entries so can be represented by 11 bits.
* UUID (128bits) and another 4 bits gives 132 bits.
* 132 bits, divided by 11, gives 12 words.
*/
- final int BITS_PER_WORD = 11;
-
- byte[] message;
- if (input != null) {
+ byte[] entropy;
+ if (suppliedEntropy != null) {
// Use caller-supplied entropy input
try {
- message = Base58.decode(input);
+ entropy = Base58.decode(suppliedEntropy);
} catch (NumberFormatException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA);
}
// Must be 16-bytes
- if (message.length != 16)
+ if (entropy.length != 16)
throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA);
} else {
// Generate entropy internally
@@ -176,26 +212,26 @@ public class UtilsResource {
byte[] uuidMSB = Longs.toByteArray(uuid.getMostSignificantBits());
byte[] uuidLSB = Longs.toByteArray(uuid.getLeastSignificantBits());
- message = Bytes.concat(uuidMSB, uuidLSB);
+ entropy = Bytes.concat(uuidMSB, uuidLSB);
}
// Use SHA256 to generate more bits
- byte[] hash = Crypto.digest(message);
+ byte[] hash = Crypto.digest(entropy);
// Append first 4 bits from hash to end. (Actually 8 bits but we only use 4).
byte checksum = (byte) (hash[0] & 0xf0);
- message = Bytes.concat(message, new byte[] {
+ entropy = Bytes.concat(entropy, new byte[] {
checksum
});
- return BIP39.encode(message, "en");
+ return BIP39.encode(entropy, "en");
}
@POST
- @Path("/seedPhrase")
+ @Path("/mnemonic")
@Operation(
- summary = "Calculate binary form of 12-word BIP39 seed phrase",
- description = "Returns the base58-encoded binary form, or \"false\" if phrase is invalid.",
+ summary = "Calculate binary entropy from 12-word BIP39 mnemonic",
+ description = "Returns the base58-encoded binary form, or \"false\" if mnemonic is invalid.",
requestBody = @RequestBody(
required = true,
content = @Content(
@@ -207,7 +243,7 @@ public class UtilsResource {
),
responses = {
@ApiResponse(
- description = "the private key",
+ description = "entropy in base58",
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
@@ -217,25 +253,94 @@ public class UtilsResource {
)
}
)
- public String getBinarySeed(String seedPhrase) {
- if (seedPhrase.isEmpty())
+ public String fromMnemonic(String mnemonic) {
+ if (mnemonic.isEmpty())
return "false";
// Strip leading/trailing whitespace if any
- seedPhrase = seedPhrase.trim();
+ mnemonic = mnemonic.trim();
- String[] phraseWords = seedPhrase.split(" ");
+ String[] phraseWords = mnemonic.split(" ");
if (phraseWords.length != 12)
return "false";
- // Convert BIP39 seed phrase to binary
+ // Convert BIP39 mnemonic to binary
byte[] binary = BIP39.decode(phraseWords, "en");
if (binary == null)
return "false";
- byte[] message = Arrays.copyOf(binary, 16); // 132 bits is 16.5 bytes, but we're discarding checksum nybble
+ byte[] entropy = Arrays.copyOf(binary, 16); // 132 bits is 16.5 bytes, but we're discarding checksum nybble
- return Base58.encode(message);
+ byte checksumNybble = (byte) (binary[16] & 0xf0);
+ byte[] checksum = Crypto.digest(entropy);
+ if ((checksum[0] & 0xf0) != checksumNybble)
+ return "false";
+
+ return Base58.encode(entropy);
}
-}
+ @GET
+ @Path("/privateKey/{entropy}")
+ @Operation(
+ summary = "Calculate private key from supplied 16-byte entropy",
+ responses = {
+ @ApiResponse(
+ description = "private key in base58",
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "string"
+ )
+ )
+ )
+ }
+ )
+ public String privateKey(@PathParam("entropy") String entropy58) {
+ byte[] entropy;
+ try {
+ entropy = Base58.decode(entropy58);
+ } catch (NumberFormatException e) {
+ throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA);
+ }
+
+ if (entropy.length != 16)
+ throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA);
+
+ byte[] privateKey = Crypto.digest(entropy);
+
+ return Base58.encode(privateKey);
+ }
+
+ @GET
+ @Path("/publicKey/{privateKey}")
+ @Operation(
+ summary = "Calculate public key from supplied 32-byte private key",
+ responses = {
+ @ApiResponse(
+ description = "public key in base58",
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "string"
+ )
+ )
+ )
+ }
+ )
+ public String publicKey(@PathParam("privateKey") String privateKey58) {
+ byte[] privateKey;
+ try {
+ privateKey = Base58.decode(privateKey58);
+ } catch (NumberFormatException e) {
+ throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA);
+ }
+
+ if (privateKey.length != 32)
+ throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA);
+
+ byte[] publicKey = new PrivateKeyAccount(null, privateKey).getPublicKey();
+
+ return Base58.encode(publicKey);
+ }
+
+}
\ No newline at end of file
diff --git a/src/utils/BIP39.java b/src/utils/BIP39.java
index 97ea7f17..6fbb8dfc 100644
--- a/src/utils/BIP39.java
+++ b/src/utils/BIP39.java
@@ -9,7 +9,7 @@ public class BIP39 {
private static final int BITS_PER_WORD = 11;
- /** Convert BIP39 seed phrase to binary form */
+ /** Convert BIP39 mnemonic to binary 'entropy' */
public static byte[] decode(String[] phraseWords, String lang) {
if (lang == null)
lang = "en";
@@ -18,7 +18,7 @@ public class BIP39 {
if (wordList == null)
throw new IllegalStateException("BIP39 word list for lang '" + lang + "' unavailable");
- byte[] output = new byte[(phraseWords.length * BITS_PER_WORD + 7) / 8];
+ byte[] entropy = new byte[(phraseWords.length * BITS_PER_WORD + 7) / 8];
int byteIndex = 0;
int bitShift = 3;
@@ -28,28 +28,28 @@ public class BIP39 {
// Word not found
return null;
- output[byteIndex++] |= (byte) (wordListIndex >> bitShift);
+ entropy[byteIndex++] |= (byte) (wordListIndex >> bitShift);
bitShift = 8 - bitShift;
if (bitShift >= 0) {
// Leftover fits inside one byte
- output[byteIndex] |= (byte) ((wordListIndex << bitShift));
+ entropy[byteIndex] |= (byte) ((wordListIndex << bitShift));
bitShift = BITS_PER_WORD - bitShift;
} else {
// Leftover spread over next two bytes
bitShift = 0 - bitShift;
- output[byteIndex++] |= (byte) (wordListIndex >> bitShift);
+ entropy[byteIndex++] |= (byte) (wordListIndex >> bitShift);
- output[byteIndex] |= (byte) ((wordListIndex << (8 - bitShift)));
+ entropy[byteIndex] |= (byte) ((wordListIndex << (8 - bitShift)));
bitShift = bitShift + BITS_PER_WORD - 8;
}
}
- return output;
+ return entropy;
}
- /** Convert binary to BIP39 seed phrase */
- public static String encode(byte[] input, String lang) {
+ /** Convert binary entropy to BIP39 mnemonic */
+ public static String encode(byte[] entropy, String lang) {
if (lang == null)
lang = "en";
@@ -66,7 +66,7 @@ public class BIP39 {
for (int bitCount = 0; bitCount < BITS_PER_WORD; ++bitCount) {
wordListIndex <<= 1;
- if ((input[byteIndex] & bitMask) != 0)
+ if ((entropy[byteIndex] & bitMask) != 0)
++wordListIndex;
bitMask >>= 1;
@@ -74,7 +74,7 @@ public class BIP39 {
bitMask = 128;
++byteIndex;
- if (byteIndex >= input.length)
+ if (byteIndex >= entropy.length)
return String.join(" ", phraseWords);
}
}