Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion src/main/java/com/example/bip39/api/DefaultBip39Service.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package com.example.bip39.api;

import com.example.bip39.bit.BitPacker;
import com.example.bip39.crypto.Sha256Digest;
import com.example.bip39.error.Bip39ErrorCode;
import com.example.bip39.error.Bip39Exception;
import com.example.bip39.model.ValidationResult;
import com.example.bip39.parser.StrictMnemonicParser;
import com.example.bip39.util.Bip39Constants;
import com.example.bip39.wordlist.Bip39WordList;
import com.example.bip39.wordlist.EnglishWordList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;

Expand All @@ -27,7 +31,18 @@ Bip39WordList wordList() {

@Override
public String entropyToMnemonic(byte[] entropy) {
throw new UnsupportedOperationException("entropyToMnemonic is not implemented yet");
validateEntropy(entropy);

int checksumLengthBits = Bip39Constants.checksumLengthBits(entropy.length);
int entropyLengthBits = entropy.length * Byte.SIZE;
int totalBitLength = entropyLengthBits + checksumLengthBits;

byte[] entropyWithChecksum = Arrays.copyOf(entropy, (totalBitLength + 7) / Byte.SIZE);
int checksumBits = BitPacker.readBits(Sha256Digest.digest(entropy), 0, checksumLengthBits);
BitPacker.writeBits(entropyWithChecksum, entropyLengthBits, checksumLengthBits, checksumBits);

int[] wordIndexes = BitPacker.splitTo11BitValues(entropyWithChecksum, totalBitLength);
return joinMnemonicWords(wordIndexes);
}

@Override
Expand Down Expand Up @@ -78,4 +93,21 @@ private static ValidationResult invalidFormatResult(Bip39Exception exception) {
}
return ValidationResult.failure(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, null, null, null);
}

private static void validateEntropy(byte[] entropy) {
if (entropy == null || !Bip39Constants.isAllowedEntropyLengthBytes(entropy.length)) {
throw new Bip39Exception(Bip39ErrorCode.ERR_ENTROPY_LENGTH, "Unsupported entropy length");
}
}

private String joinMnemonicWords(int[] wordIndexes) {
StringBuilder mnemonic = new StringBuilder();
for (int index = 0; index < wordIndexes.length; index++) {
if (index > 0) {
mnemonic.append(' ');
}
mnemonic.append(wordList.indexToWord(wordIndexes[index]));
}
return mnemonic.toString();
}
}
107 changes: 107 additions & 0 deletions src/main/java/com/example/bip39/bit/BitPacker.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.example.bip39.bit;

import java.util.Objects;

public final class BitPacker {

private static final int ELEVEN_BIT_GROUP_SIZE = 11;

private BitPacker() {}

public static int readBits(byte[] source, int bitOffset, int bitLength) {
Objects.requireNonNull(source, "source must not be null");
validateBitAccess(source.length, bitOffset, bitLength);
if (bitLength > Integer.SIZE - 1) {
throw new IllegalArgumentException("bitLength must be at most 31");
}

int value = 0;
for (int bitIndex = 0; bitIndex < bitLength; bitIndex++) {
int absoluteBitIndex = bitOffset + bitIndex;
int byteIndex = absoluteBitIndex / Byte.SIZE;
int bitIndexInByte = 7 - (absoluteBitIndex % Byte.SIZE);
int bit = ((source[byteIndex] & 0xff) >> bitIndexInByte) & 0x01;
value = (value << 1) | bit;
}
return value;
}

public static void writeBits(byte[] target, int bitOffset, int bitLength, int value) {
Objects.requireNonNull(target, "target must not be null");
validateBitAccess(target.length, bitOffset, bitLength);
if (bitLength > Integer.SIZE - 1) {
throw new IllegalArgumentException("bitLength must be at most 31");
}
if (bitLength != 0 && value >>> bitLength != 0) {
throw new IllegalArgumentException("value does not fit into the requested bit length");
}

for (int bitIndex = 0; bitIndex < bitLength; bitIndex++) {
int absoluteBitIndex = bitOffset + bitIndex;
int byteIndex = absoluteBitIndex / Byte.SIZE;
int bitIndexInByte = 7 - (absoluteBitIndex % Byte.SIZE);
int bitMask = 1 << bitIndexInByte;
int bit = (value >> (bitLength - 1 - bitIndex)) & 0x01;
int clearedByte = target[byteIndex] & ~bitMask;
target[byteIndex] = (byte) (clearedByte | (bit << bitIndexInByte));
}
}

public static int[] splitTo11BitValues(byte[] source, int totalBitLength) {
Objects.requireNonNull(source, "source must not be null");
validateTotalBitLength(source.length, totalBitLength);
if (totalBitLength % ELEVEN_BIT_GROUP_SIZE != 0) {
throw new IllegalArgumentException("totalBitLength must be divisible by 11");
}

int[] values = new int[totalBitLength / ELEVEN_BIT_GROUP_SIZE];
for (int valueIndex = 0; valueIndex < values.length; valueIndex++) {
values[valueIndex] =
readBits(source, valueIndex * ELEVEN_BIT_GROUP_SIZE, ELEVEN_BIT_GROUP_SIZE);
}
return values;
}

public static byte[] pack11BitValues(int[] values) {
Objects.requireNonNull(values, "values must not be null");
byte[] packed = new byte[(values.length * ELEVEN_BIT_GROUP_SIZE + 7) / Byte.SIZE];
for (int valueIndex = 0; valueIndex < values.length; valueIndex++) {
int value = values[valueIndex];
if (value < 0 || value > 0x7ff) {
throw new IllegalArgumentException("11-bit value out of range: " + value);
}
writeBits(packed, valueIndex * ELEVEN_BIT_GROUP_SIZE, ELEVEN_BIT_GROUP_SIZE, value);
}
return packed;
}

public static byte[] extractBytes(byte[] source, int bitOffset, int bitLength) {
Objects.requireNonNull(source, "source must not be null");
validateBitAccess(source.length, bitOffset, bitLength);
if (bitLength % Byte.SIZE != 0) {
throw new IllegalArgumentException("bitLength must be divisible by 8");
}

byte[] bytes = new byte[bitLength / Byte.SIZE];
for (int byteIndex = 0; byteIndex < bytes.length; byteIndex++) {
bytes[byteIndex] = (byte) readBits(source, bitOffset + byteIndex * Byte.SIZE, Byte.SIZE);
}
return bytes;
}

private static void validateTotalBitLength(int sourceLengthBytes, int totalBitLength) {
validateBitAccess(sourceLengthBytes, 0, totalBitLength);
}

private static void validateBitAccess(int sourceLengthBytes, int bitOffset, int bitLength) {
if (bitOffset < 0) {
throw new IllegalArgumentException("bitOffset must not be negative");
}
if (bitLength < 0) {
throw new IllegalArgumentException("bitLength must not be negative");
}
if (bitOffset + bitLength > sourceLengthBytes * Byte.SIZE) {
throw new IllegalArgumentException("Requested bits exceed source length");
}
}
}
75 changes: 75 additions & 0 deletions src/main/java/com/example/bip39/crypto/Pbkdf2HmacSha512.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.example.bip39.crypto;

import com.example.bip39.error.Bip39ErrorCode;
import com.example.bip39.error.Bip39Exception;
import com.example.bip39.util.Bip39Constants;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Objects;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class Pbkdf2HmacSha512 {

private static final String HMAC_SHA512 = "HmacSHA512";

private Pbkdf2HmacSha512() {}

public static byte[] derive(byte[] passwordBytes, byte[] saltBytes) {
Objects.requireNonNull(passwordBytes, "passwordBytes must not be null");
Objects.requireNonNull(saltBytes, "saltBytes must not be null");

try {
Mac mac = Mac.getInstance(HMAC_SHA512);
mac.init(new SecretKeySpec(passwordBytes, HMAC_SHA512));
return deriveKey(
mac, saltBytes, Bip39Constants.PBKDF2_ITERATIONS, Bip39Constants.SEED_LEN_BYTES);
} catch (NoSuchAlgorithmException | InvalidKeyException exception) {
throw new Bip39Exception(
Bip39ErrorCode.ERR_PBKDF2_FAILURE, "PBKDF2-HMAC-SHA512 is unavailable", exception);
}
}

private static byte[] deriveKey(
Mac mac, byte[] saltBytes, int iterations, int derivedKeyLengthBytes) {
int blockLengthBytes = mac.getMacLength();
int blockCount = (derivedKeyLengthBytes + blockLengthBytes - 1) / blockLengthBytes;
byte[] derivedKey = new byte[blockCount * blockLengthBytes];

for (int blockIndex = 1; blockIndex <= blockCount; blockIndex++) {
byte[] block = deriveBlock(mac, saltBytes, iterations, blockIndex);
System.arraycopy(block, 0, derivedKey, (blockIndex - 1) * blockLengthBytes, block.length);
}

return Arrays.copyOf(derivedKey, derivedKeyLengthBytes);
}

private static byte[] deriveBlock(Mac mac, byte[] saltBytes, int iterations, int blockIndex) {
byte[] input = Arrays.copyOf(saltBytes, saltBytes.length + Integer.BYTES);
writeIntBigEndian(input, saltBytes.length, blockIndex);

byte[] iterationResult = mac.doFinal(input);
byte[] block = iterationResult.clone();

for (int iteration = 1; iteration < iterations; iteration++) {
iterationResult = mac.doFinal(iterationResult);
xorInto(block, iterationResult);
}

return block;
}

private static void xorInto(byte[] target, byte[] source) {
for (int index = 0; index < target.length; index++) {
target[index] ^= source[index];
}
}

private static void writeIntBigEndian(byte[] bytes, int offset, int value) {
bytes[offset] = (byte) (value >>> 24);
bytes[offset + 1] = (byte) (value >>> 16);
bytes[offset + 2] = (byte) (value >>> 8);
bytes[offset + 3] = (byte) value;
}
}
21 changes: 21 additions & 0 deletions src/main/java/com/example/bip39/crypto/Sha256Digest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.example.bip39.crypto;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Objects;

public final class Sha256Digest {

private Sha256Digest() {}

public static byte[] digest(byte[] input) {
Objects.requireNonNull(input, "input must not be null");

try {
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
return messageDigest.digest(input);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,12 @@ void servicesShareSingleLoadedWordListInstance() {
}

@Test
void methodsBeyondParsingRemainExplicitlyUnimplementedForNormalizedInputs() {
void methodsBeyondEntropyEncodingRemainExplicitlyUnimplementedForNormalizedInputs() {
DefaultBip39Service service = new DefaultBip39Service();

assertThrows(
UnsupportedOperationException.class, () -> service.entropyToMnemonic(new byte[16]));
assertEquals(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
service.entropyToMnemonic(new byte[16]));
assertThrows(
UnsupportedOperationException.class,
() -> service.mnemonicToEntropy("abandon ability able"));
Expand Down
58 changes: 58 additions & 0 deletions src/test/java/com/example/bip39/api/EntropyToMnemonicTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.example.bip39.api;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import com.example.bip39.error.Bip39ErrorCode;
import com.example.bip39.error.Bip39Exception;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.InputStream;
import java.util.HexFormat;
import org.junit.jupiter.api.Test;

class EntropyToMnemonicTest {

private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

@Test
void matchesAllOfficialEnglishEntropyToMnemonicVectors() throws IOException {
DefaultBip39Service service = new DefaultBip39Service();
JsonNode vectors = loadEnglishVectors();

for (JsonNode vector : vectors) {
byte[] entropy = HexFormat.of().parseHex(vector.get(0).textValue());
String expectedMnemonic = vector.get(1).textValue();

assertEquals(expectedMnemonic, service.entropyToMnemonic(entropy));
}
}

@Test
void rejectsUnsupportedEntropyLengths() {
DefaultBip39Service service = new DefaultBip39Service();

Bip39Exception nullException =
assertThrows(Bip39Exception.class, () -> service.entropyToMnemonic(null));
assertEquals(Bip39ErrorCode.ERR_ENTROPY_LENGTH, nullException.getErrorCode());

Bip39Exception shortException =
assertThrows(Bip39Exception.class, () -> service.entropyToMnemonic(new byte[12]));
assertEquals(Bip39ErrorCode.ERR_ENTROPY_LENGTH, shortException.getErrorCode());

Bip39Exception longException =
assertThrows(Bip39Exception.class, () -> service.entropyToMnemonic(new byte[33]));
assertEquals(Bip39ErrorCode.ERR_ENTROPY_LENGTH, longException.getErrorCode());
}

private static JsonNode loadEnglishVectors() throws IOException {
try (InputStream inputStream =
EntropyToMnemonicTest.class.getResourceAsStream("/bip39/vectors.json")) {
if (inputStream == null) {
throw new IllegalStateException("Missing resource: /bip39/vectors.json");
}
return OBJECT_MAPPER.readTree(inputStream).get("english");
}
}
}
Loading
Loading