Browse Source

BIP39 seed phrase support

split-DB
catbref 6 years ago
parent
commit
e1dbaa5597
  1. 2048
      globalization/BIP39.en.txt
  2. 2
      src/api/AssetsResource.java
  3. 135
      src/api/UtilsResource.java
  4. 55
      src/globalization/BIP39WordList.java
  5. 86
      src/utils/BIP39.java

2048
globalization/BIP39.en.txt

File diff suppressed because it is too large Load Diff

2
src/api/AssetsResource.java

@ -225,7 +225,7 @@ public class AssetsResource {
requestBody = @RequestBody( requestBody = @RequestBody(
required = true, required = true,
content = @Content( content = @Content(
mediaType = "application/json", mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(implementation = IssueAssetRequest.class) schema = @Schema(implementation = IssueAssetRequest.class)
) )
) )

135
src/api/UtilsResource.java

@ -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.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import qora.crypto.Crypto; import qora.crypto.Crypto;
import utils.BIP39;
import utils.Base58; import utils.Base58;
import java.math.BigInteger;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64; import java.util.Base64;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.POST; import javax.ws.rs.POST;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context; import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import com.google.common.primitives.Bytes; import com.google.common.primitives.Bytes;
import com.google.common.primitives.Longs; import com.google.common.primitives.Longs;
import globalization.BIP39WordList;
@Path("/utils") @Path("/utils")
@Produces({ @Produces({
MediaType.TEXT_PLAIN MediaType.TEXT_PLAIN
@ -38,47 +42,65 @@ public class UtilsResource {
@Context @Context
HttpServletRequest request; HttpServletRequest request;
@GET @POST
@Path("/base58from64/{base64}") @Path("/base58from64")
@Operation( @Operation(
summary = "Convert base64 data to base58", summary = "Convert base64 data to base58",
requestBody = @RequestBody(
required = true,
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "string"
)
)
),
responses = { responses = {
@ApiResponse( @ApiResponse(
description = "base58 data", description = "base58 data",
content = @Content( content = @Content(
schema = @Schema( schema = @Schema(
implementation = String.class type = "string"
) )
) )
) )
} }
) )
public String base58from64(@PathParam("base64") String base64) { public String base58from64(String base64) {
try { try {
return Base58.encode(Base64.getDecoder().decode(base64)); return Base58.encode(Base64.getDecoder().decode(base64.trim()));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA); throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA);
} }
} }
@GET @POST
@Path("/base64from58/{base58}") @Path("/base64from58")
@Operation( @Operation(
summary = "Convert base58 data to base64", summary = "Convert base58 data to base64",
requestBody = @RequestBody(
required = true,
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "string"
)
)
),
responses = { responses = {
@ApiResponse( @ApiResponse(
description = "base64 data", description = "base64 data",
content = @Content( content = @Content(
schema = @Schema( schema = @Schema(
implementation = String.class type = "string"
) )
) )
) )
} }
) )
public String base64from58(@PathParam("base58") String base58) { public String base64from58(String base58) {
try { try {
return Base64.getEncoder().encodeToString(Base58.decode(base58)); return Base64.getEncoder().encodeToString(Base58.decode(base58.trim()));
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA); throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA);
} }
@ -87,7 +109,8 @@ public class UtilsResource {
@GET @GET
@Path("/seed") @Path("/seed")
@Operation( @Operation(
summary = "Generate random 32-byte seed", summary = "Generate random seed",
description = "Optionally pass seed length, defaults to 32 bytes.",
responses = { responses = {
@ApiResponse( @ApiResponse(
description = "base58 data", description = "base58 data",
@ -100,8 +123,11 @@ public class UtilsResource {
) )
} }
) )
public String seed() { public String seed(@QueryParam("length") Integer length) {
byte[] seed = new byte[32]; if (length == null)
length = 32;
byte[] seed = new byte[length];
new SecureRandom().nextBytes(seed); new SecureRandom().nextBytes(seed);
return Base58.encode(seed); return Base58.encode(seed);
} }
@ -110,6 +136,8 @@ public class UtilsResource {
@Path("/seedPhrase") @Path("/seedPhrase")
@Operation( @Operation(
summary = "Generate random 12-word BIP39 seed phrase", 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 = { responses = {
@ApiResponse( @ApiResponse(
description = "seed phrase", 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. * BIP39 word lists have 2048 entries so can be represented by 11 bits.
* UUID (128bits) and another 4 bits gives 132 bits. * UUID (128bits) and another 4 bits gives 132 bits.
* 132 bits, divided by 11, gives 12 words. * 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) {
System.out.println("UUID: " + uuid.toString()); // Use caller-supplied entropy input
try {
byte[] uuidMSB = Longs.toByteArray(uuid.getMostSignificantBits()); message = Base58.decode(input);
byte[] uuidLSB = Longs.toByteArray(uuid.getLeastSignificantBits()); } catch (NumberFormatException e) {
byte[] message = Bytes.concat(uuidMSB, uuidLSB); throw ApiErrorFactory.getInstance().createError(ApiError.INVALID_DATA);
}
// 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());
message = Bytes.concat(uuidMSB, uuidLSB);
}
// Use SHA256 to generate more bits // Use SHA256 to generate more bits
byte[] hash = Crypto.digest(message); 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[] { message = Bytes.concat(message, new byte[] {
hash[hash.length - 1] checksum
}); });
BigInteger wordBits = new BigInteger(message); return BIP39.encode(message, "en");
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);
} }
@POST @POST
@Path("/privateKey") @Path("/seedPhrase")
@Operation( @Operation(
summary = "Calculate private key from 12-word BIP39 seed phrase", summary = "Calculate binary form of 12-word BIP39 seed phrase",
description = "Returns the base58-encoded private key, or \"false\" if phrase is invalid.", description = "Returns the base58-encoded binary form, or \"false\" if phrase is invalid.",
requestBody = @RequestBody( requestBody = @RequestBody(
required = true, required = true,
content = @Content( content = @Content(
@ -184,9 +217,25 @@ public class UtilsResource {
) )
} }
) )
public String getPublicKey(String seedPhrase) { public String getBinarySeed(String seedPhrase) {
// TODO: convert BIP39 seed phrase to private key if (seedPhrase.isEmpty())
return seedPhrase; 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

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

@ -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…
Cancel
Save