forked from Qortal/qortal
BIP39 seed phrase support
This commit is contained in:
parent
e6acc92ed0
commit
e1dbaa5597
2048
globalization/BIP39.en.txt
Normal file
2048
globalization/BIP39.en.txt
Normal file
File diff suppressed because it is too large
Load Diff
@ -225,7 +225,7 @@ public class AssetsResource {
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = "application/json",
|
||||
mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(implementation = IssueAssetRequest.class)
|
||||
)
|
||||
)
|
||||
|
@ -7,25 +7,29 @@ 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.crypto.Crypto;
|
||||
import utils.BIP39;
|
||||
import utils.Base58;
|
||||
|
||||
import java.math.BigInteger;
|
||||
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.primitives.Bytes;
|
||||
import com.google.common.primitives.Longs;
|
||||
|
||||
import globalization.BIP39WordList;
|
||||
|
||||
@Path("/utils")
|
||||
@Produces({
|
||||
MediaType.TEXT_PLAIN
|
||||
@ -38,47 +42,65 @@ public class UtilsResource {
|
||||
@Context
|
||||
HttpServletRequest request;
|
||||
|
||||
@GET
|
||||
@Path("/base58from64/{base64}")
|
||||
@POST
|
||||
@Path("/base58from64")
|
||||
@Operation(
|
||||
summary = "Convert base64 data to base58",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "base58 data",
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
implementation = String.class
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
public String base58from64(@PathParam("base64") String base64) {
|
||||
public String base58from64(String base64) {
|
||||
try {
|
||||
return Base58.encode(Base64.getDecoder().decode(base64));
|
||||
return Base58.encode(Base64.getDecoder().decode(base64.trim()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA);
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/base64from58/{base58}")
|
||||
@POST
|
||||
@Path("/base64from58")
|
||||
@Operation(
|
||||
summary = "Convert base58 data to base64",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = MediaType.TEXT_PLAIN,
|
||||
schema = @Schema(
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
),
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "base64 data",
|
||||
content = @Content(
|
||||
schema = @Schema(
|
||||
implementation = String.class
|
||||
type = "string"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
public String base64from58(@PathParam("base58") String base58) {
|
||||
public String base64from58(String base58) {
|
||||
try {
|
||||
return Base64.getEncoder().encodeToString(Base58.decode(base58));
|
||||
return Base64.getEncoder().encodeToString(Base58.decode(base58.trim()));
|
||||
} catch (NumberFormatException e) {
|
||||
throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA);
|
||||
}
|
||||
@ -87,7 +109,8 @@ public class UtilsResource {
|
||||
@GET
|
||||
@Path("/seed")
|
||||
@Operation(
|
||||
summary = "Generate random 32-byte seed",
|
||||
summary = "Generate random seed",
|
||||
description = "Optionally pass seed length, defaults to 32 bytes.",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "base58 data",
|
||||
@ -100,8 +123,11 @@ public class UtilsResource {
|
||||
)
|
||||
}
|
||||
)
|
||||
public String seed() {
|
||||
byte[] seed = new byte[32];
|
||||
public String seed(@QueryParam("length") Integer length) {
|
||||
if (length == null)
|
||||
length = 32;
|
||||
|
||||
byte[] seed = new byte[length];
|
||||
new SecureRandom().nextBytes(seed);
|
||||
return Base58.encode(seed);
|
||||
}
|
||||
@ -110,6 +136,8 @@ public class UtilsResource {
|
||||
@Path("/seedPhrase")
|
||||
@Operation(
|
||||
summary = "Generate random 12-word BIP39 seed phrase",
|
||||
description = "Optionally pass 16-byte, base58-encoded entropy input or entropy will be internally generated.<br>"
|
||||
+ "Example entropy input: YcVfxkQb6JRzqk5kF2tNLv",
|
||||
responses = {
|
||||
@ApiResponse(
|
||||
description = "seed phrase",
|
||||
@ -122,47 +150,52 @@ public class UtilsResource {
|
||||
)
|
||||
}
|
||||
)
|
||||
public String seedPhrase() {
|
||||
public String seedPhrase(@QueryParam("entropy") String input) {
|
||||
/*
|
||||
* 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 WORD_MASK = 2048 - 1;
|
||||
final int BITS_PER_WORD = 11;
|
||||
|
||||
UUID uuid = UUID.randomUUID();
|
||||
byte[] message;
|
||||
if (input != null) {
|
||||
// Use caller-supplied entropy input
|
||||
try {
|
||||
message = Base58.decode(input);
|
||||
} catch (NumberFormatException e) {
|
||||
throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA);
|
||||
}
|
||||
|
||||
System.out.println("UUID: " + uuid.toString());
|
||||
// Must be 16-bytes
|
||||
if (message.length != 16)
|
||||
throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA);
|
||||
} else {
|
||||
// Generate entropy internally
|
||||
UUID uuid = UUID.randomUUID();
|
||||
|
||||
byte[] uuidMSB = Longs.toByteArray(uuid.getMostSignificantBits());
|
||||
byte[] uuidLSB = Longs.toByteArray(uuid.getLeastSignificantBits());
|
||||
byte[] message = Bytes.concat(uuidMSB, uuidLSB);
|
||||
byte[] uuidMSB = Longs.toByteArray(uuid.getMostSignificantBits());
|
||||
byte[] uuidLSB = Longs.toByteArray(uuid.getLeastSignificantBits());
|
||||
message = Bytes.concat(uuidMSB, uuidLSB);
|
||||
}
|
||||
|
||||
// Use SHA256 to generate more bits
|
||||
byte[] hash = Crypto.digest(message);
|
||||
|
||||
// Append last 4 bits from hash to end. (Actually 8 bits but we only use 4).
|
||||
// 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[] {
|
||||
hash[hash.length - 1]
|
||||
checksum
|
||||
});
|
||||
|
||||
BigInteger wordBits = new BigInteger(message);
|
||||
|
||||
String[] phraseWords = new String[12];
|
||||
for (int i = phraseWords.length; i >= 0; --i) {
|
||||
int wordListIndex = wordBits.intValue() & WORD_MASK;
|
||||
wordBits = wordBits.shiftRight(11);
|
||||
// phraseWords[i] = wordList.get(wordListIndex);
|
||||
}
|
||||
|
||||
return String.join(" ", phraseWords);
|
||||
return BIP39.encode(message, "en");
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/privateKey")
|
||||
@Path("/seedPhrase")
|
||||
@Operation(
|
||||
summary = "Calculate private key from 12-word BIP39 seed phrase",
|
||||
description = "Returns the base58-encoded private key, or \"false\" if phrase is invalid.",
|
||||
summary = "Calculate binary form of 12-word BIP39 seed phrase",
|
||||
description = "Returns the base58-encoded binary form, or \"false\" if phrase is invalid.",
|
||||
requestBody = @RequestBody(
|
||||
required = true,
|
||||
content = @Content(
|
||||
@ -184,9 +217,25 @@ public class UtilsResource {
|
||||
)
|
||||
}
|
||||
)
|
||||
public String getPublicKey(String seedPhrase) {
|
||||
// TODO: convert BIP39 seed phrase to private key
|
||||
return seedPhrase;
|
||||
public String getBinarySeed(String seedPhrase) {
|
||||
if (seedPhrase.isEmpty())
|
||||
return "false";
|
||||
|
||||
// Strip leading/trailing whitespace if any
|
||||
seedPhrase = seedPhrase.trim();
|
||||
|
||||
String[] phraseWords = seedPhrase.split(" ");
|
||||
if (phraseWords.length != 12)
|
||||
return "false";
|
||||
|
||||
// Convert BIP39 seed phrase 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
|
||||
|
||||
return Base58.encode(message);
|
||||
}
|
||||
|
||||
}
|
||||
|
55
src/globalization/BIP39WordList.java
Normal file
55
src/globalization/BIP39WordList.java
Normal file
@ -0,0 +1,55 @@
|
||||
package globalization;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FilenameFilter;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import settings.Settings;
|
||||
|
||||
/** Providing multi-language BIP39 word lists, downloaded from https://github.com/bitcoin/bips/tree/master/bip-0039 */
|
||||
public class BIP39WordList {
|
||||
|
||||
private static BIP39WordList instance;
|
||||
|
||||
private static Map<String, List<String>> wordListsByLang;
|
||||
|
||||
private BIP39WordList() {
|
||||
wordListsByLang = new HashMap<>();
|
||||
|
||||
String path = Settings.getInstance().translationsPath();
|
||||
File dir = new File(path);
|
||||
File[] files = dir.listFiles(new FilenameFilter() {
|
||||
@Override
|
||||
public boolean accept(File dir, String name) {
|
||||
return name.startsWith("BIP39.");
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
for (File file : files) {
|
||||
String lang = file.getName().substring(6, 8);
|
||||
List<String> words = Files.readAllLines(file.toPath());
|
||||
wordListsByLang.put(lang, words);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Unable to read BIP39 word list", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static synchronized BIP39WordList getInstance() {
|
||||
if (instance == null)
|
||||
instance = new BIP39WordList();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
public List<String> getByLang(String lang) {
|
||||
return Collections.unmodifiableList(wordListsByLang.get(lang));
|
||||
}
|
||||
|
||||
}
|
86
src/utils/BIP39.java
Normal file
86
src/utils/BIP39.java
Normal file
@ -0,0 +1,86 @@
|
||||
package utils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import globalization.BIP39WordList;
|
||||
|
||||
public class BIP39 {
|
||||
|
||||
private static final int BITS_PER_WORD = 11;
|
||||
|
||||
/** Convert BIP39 seed phrase to binary form */
|
||||
public static byte[] decode(String[] phraseWords, String lang) {
|
||||
if (lang == null)
|
||||
lang = "en";
|
||||
|
||||
List<String> wordList = BIP39WordList.getInstance().getByLang(lang);
|
||||
if (wordList == null)
|
||||
throw new IllegalStateException("BIP39 word list for lang '" + lang + "' unavailable");
|
||||
|
||||
byte[] output = new byte[(phraseWords.length * BITS_PER_WORD + 7) / 8];
|
||||
int byteIndex = 0;
|
||||
int bitShift = 3;
|
||||
|
||||
for (int i = 0; i < phraseWords.length; ++i) {
|
||||
int wordListIndex = wordList.indexOf(phraseWords[i]);
|
||||
if (wordListIndex == -1)
|
||||
// Word not found
|
||||
return null;
|
||||
|
||||
output[byteIndex++] |= (byte) (wordListIndex >> bitShift);
|
||||
|
||||
bitShift = 8 - bitShift;
|
||||
if (bitShift >= 0) {
|
||||
// Leftover fits inside one byte
|
||||
output[byteIndex] |= (byte) ((wordListIndex << bitShift));
|
||||
bitShift = BITS_PER_WORD - bitShift;
|
||||
} else {
|
||||
// Leftover spread over next two bytes
|
||||
bitShift = 0 - bitShift;
|
||||
output[byteIndex++] |= (byte) (wordListIndex >> bitShift);
|
||||
|
||||
output[byteIndex] |= (byte) ((wordListIndex << (8 - bitShift)));
|
||||
bitShift = bitShift + BITS_PER_WORD - 8;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Convert binary to BIP39 seed phrase */
|
||||
public static String encode(byte[] input, String lang) {
|
||||
if (lang == null)
|
||||
lang = "en";
|
||||
|
||||
List<String> wordList = BIP39WordList.getInstance().getByLang(lang);
|
||||
if (wordList == null)
|
||||
throw new IllegalStateException("BIP39 word list for lang '" + lang + "' unavailable");
|
||||
|
||||
List<String> phraseWords = new ArrayList<>();
|
||||
|
||||
int bitMask = 128; // MSB first
|
||||
int byteIndex = 0;
|
||||
while (true) {
|
||||
int wordListIndex = 0;
|
||||
for (int bitCount = 0; bitCount < BITS_PER_WORD; ++bitCount) {
|
||||
wordListIndex <<= 1;
|
||||
|
||||
if ((input[byteIndex] & bitMask) != 0)
|
||||
++wordListIndex;
|
||||
|
||||
bitMask >>= 1;
|
||||
if (bitMask == 0) {
|
||||
bitMask = 128;
|
||||
++byteIndex;
|
||||
|
||||
if (byteIndex >= input.length)
|
||||
return String.join(" ", phraseWords);
|
||||
}
|
||||
}
|
||||
|
||||
phraseWords.add(wordList.get(wordListIndex));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue
Block a user