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
146 changes: 136 additions & 10 deletions src/main/java/com/example/bip39/api/DefaultBip39Service.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package com.example.bip39.api;

import com.example.bip39.bit.BitPacker;
import com.example.bip39.crypto.Pbkdf2HmacSha512;
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.ParsedMnemonic;
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.nio.charset.StandardCharsets;
import java.text.Normalizer;
import java.text.Normalizer.Form;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
Expand Down Expand Up @@ -47,44 +52,40 @@ public String entropyToMnemonic(byte[] entropy) {

@Override
public byte[] mnemonicToEntropy(String mnemonic) {
StrictMnemonicParser.parse(mnemonic);
throw new UnsupportedOperationException("mnemonicToEntropy is not implemented yet");
return requireValidMnemonic(analyzeParsedMnemonic(StrictMnemonicParser.parse(mnemonic)));
}

@Override
public byte[] mnemonicToEntropy(List<String> words) {
StrictMnemonicParser.parse(words);
throw new UnsupportedOperationException("mnemonicToEntropy is not implemented yet");
return requireValidMnemonic(analyzeParsedMnemonic(StrictMnemonicParser.parse(words)));
}

@Override
public ValidationResult validateMnemonic(String mnemonic) {
try {
StrictMnemonicParser.parse(mnemonic);
return analyzeParsedMnemonic(StrictMnemonicParser.parse(mnemonic)).validationResult();
} catch (Bip39Exception exception) {
return invalidFormatResult(exception);
}
throw new UnsupportedOperationException("validateMnemonic is not implemented yet");
}

@Override
public ValidationResult validateMnemonic(List<String> words) {
try {
StrictMnemonicParser.parse(words);
return analyzeParsedMnemonic(StrictMnemonicParser.parse(words)).validationResult();
} catch (Bip39Exception exception) {
return invalidFormatResult(exception);
}
throw new UnsupportedOperationException("validateMnemonic is not implemented yet");
}

@Override
public byte[] mnemonicToSeed(String mnemonic, String passphrase) {
throw new UnsupportedOperationException("mnemonicToSeed is not implemented yet");
return deriveSeed(requireMnemonicString(mnemonic), requirePassphrase(passphrase));
}

@Override
public byte[] mnemonicToSeed(List<String> words, String passphrase) {
throw new UnsupportedOperationException("mnemonicToSeed is not implemented yet");
return deriveSeed(joinSeedMnemonicWords(words), requirePassphrase(passphrase));
}

private static ValidationResult invalidFormatResult(Bip39Exception exception) {
Expand All @@ -110,4 +111,129 @@ private String joinMnemonicWords(int[] wordIndexes) {
}
return mnemonic.toString();
}

private MnemonicAnalysis analyzeParsedMnemonic(ParsedMnemonic parsedMnemonic) {
int wordCount = parsedMnemonic.wordCount();
String normalizedMnemonic = parsedMnemonic.normalizedMnemonic();
List<String> words = parsedMnemonic.words();

if (!isSupportedWordCount(wordCount)) {
return new MnemonicAnalysis(
ValidationResult.failure(
Bip39ErrorCode.ERR_INVALID_WORD_COUNT, normalizedMnemonic, wordCount, null),
null);
}

int[] wordIndexes = new int[wordCount];
for (int index = 0; index < words.size(); index++) {
String word = words.get(index);
if (!wordList.containsWord(word)) {
return new MnemonicAnalysis(
ValidationResult.failure(
Bip39ErrorCode.ERR_WORD_NOT_IN_LIST, normalizedMnemonic, wordCount, word),
null);
}
wordIndexes[index] = wordList.wordToIndex(word);
}

int totalBitLength = words.size() * 11;
int checksumLengthBits = totalBitLength / 33;
int entropyLengthBits = totalBitLength - checksumLengthBits;
byte[] packedMnemonicBits = BitPacker.pack11BitValues(wordIndexes);
byte[] entropy = BitPacker.extractBytes(packedMnemonicBits, 0, entropyLengthBits);

int expectedChecksumBits =
BitPacker.readBits(Sha256Digest.digest(entropy), 0, checksumLengthBits);
int actualChecksumBits =
BitPacker.readBits(packedMnemonicBits, entropyLengthBits, checksumLengthBits);
if (actualChecksumBits != expectedChecksumBits) {
return new MnemonicAnalysis(
ValidationResult.failure(
Bip39ErrorCode.ERR_CHECKSUM_MISMATCH, normalizedMnemonic, wordCount, null),
null);
}

return new MnemonicAnalysis(ValidationResult.success(normalizedMnemonic, wordCount), entropy);
}

private static byte[] requireValidMnemonic(MnemonicAnalysis mnemonicAnalysis) {
if (mnemonicAnalysis.validationResult().ok()) {
return mnemonicAnalysis.entropy();
}

Bip39ErrorCode errorCode = mnemonicAnalysis.validationResult().errorCode();
if (errorCode == Bip39ErrorCode.ERR_INVALID_WORD_COUNT) {
throw new Bip39Exception(errorCode, "Unsupported mnemonic word count");
}
if (errorCode == Bip39ErrorCode.ERR_WORD_NOT_IN_LIST) {
throw new Bip39Exception(errorCode, "Word is not in the list");
}
throw new Bip39Exception(errorCode, "Checksum mismatch");
}

private static boolean isSupportedWordCount(int wordCount) {
return wordCount == 12
|| wordCount == 15
|| wordCount == 18
|| wordCount == 21
|| wordCount == 24;
}

private static String requireMnemonicString(String mnemonic) {
if (mnemonic == null) {
throw invalidMnemonicFormatException();
}
return mnemonic;
}

private static String requirePassphrase(String passphrase) {
if (passphrase == null) {
throw invalidMnemonicFormatException();
}
return passphrase;
}

private static String joinSeedMnemonicWords(List<String> words) {
if (words == null) {
throw invalidMnemonicFormatException();
}

StringBuilder mnemonic = new StringBuilder();
for (int index = 0; index < words.size(); index++) {
String word = words.get(index);
if (word == null || word.isEmpty() || containsWhitespace(word)) {
throw invalidMnemonicFormatException();
}
if (index > 0) {
mnemonic.append(' ');
}
mnemonic.append(word);
}
return mnemonic.toString();
}

private static byte[] deriveSeed(String mnemonic, String passphrase) {
String normalizedMnemonic = Normalizer.normalize(mnemonic, Form.NFKD);
String normalizedPassphrase = Normalizer.normalize(passphrase, Form.NFKD);
byte[] passwordBytes = normalizedMnemonic.getBytes(StandardCharsets.UTF_8);
byte[] saltBytes = ("mnemonic" + normalizedPassphrase).getBytes(StandardCharsets.UTF_8);
return Pbkdf2HmacSha512.derive(passwordBytes, saltBytes);
}

private static boolean containsWhitespace(String value) {
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
if (Character.isWhitespace(character) || Character.isSpaceChar(character)) {
return true;
}
}
return false;
}

private static Bip39Exception invalidMnemonicFormatException() {
return new Bip39Exception(
Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, "Mnemonic format is invalid");
}

private record MnemonicAnalysis(ValidationResult validationResult, byte[] entropy) {}
}
59 changes: 38 additions & 21 deletions src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.example.bip39.api;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
Expand All @@ -10,11 +11,20 @@
import com.example.bip39.error.Bip39Exception;
import com.example.bip39.model.ValidationResult;
import com.example.bip39.wordlist.EnglishWordList;
import java.util.HexFormat;
import java.util.List;
import org.junit.jupiter.api.Test;

class DefaultBip39ServiceTest {

private static final String ZERO_ENTROPY_MNEMONIC =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
+ " about";

private static final String ZERO_ENTROPY_SEED_WITH_TREZOR =
"c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553"
+ "1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04";

@Test
void defaultServiceLoadsValidatedEnglishWordList() {
DefaultBip39Service service = new DefaultBip39Service();
Expand All @@ -31,30 +41,37 @@ void servicesShareSingleLoadedWordListInstance() {
}

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

assertEquals(ZERO_ENTROPY_MNEMONIC, service.entropyToMnemonic(new byte[16]));
assertArrayEquals(new byte[16], service.mnemonicToEntropy(ZERO_ENTROPY_MNEMONIC));
assertArrayEquals(
new byte[16],
service.mnemonicToEntropy(
List.of(
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon", "about")));
assertEquals(
ValidationResult.failure(
Bip39ErrorCode.ERR_INVALID_WORD_COUNT, "abandon ability able", 3, null),
service.validateMnemonic("abandon ability able"));
assertEquals(
ValidationResult.failure(
Bip39ErrorCode.ERR_INVALID_WORD_COUNT, "abandon ability able", 3, null),
service.validateMnemonic(List.of("abandon", "ability", "able")));
assertEquals(
ZERO_ENTROPY_SEED_WITH_TREZOR,
HexFormat.of().formatHex(service.mnemonicToSeed(ZERO_ENTROPY_MNEMONIC, "TREZOR")));
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"));
assertThrows(
UnsupportedOperationException.class,
() -> service.mnemonicToEntropy(List.of("abandon", "ability", "able")));
assertThrows(
UnsupportedOperationException.class,
() -> service.validateMnemonic("abandon ability able"));
assertThrows(
UnsupportedOperationException.class,
() -> service.validateMnemonic(List.of("abandon", "ability", "able")));
assertThrows(
UnsupportedOperationException.class,
() -> service.mnemonicToSeed("abandon ability", "TREZOR"));
assertThrows(
UnsupportedOperationException.class,
() -> service.mnemonicToSeed(List.of("abandon", "ability"), "TREZOR"));
ZERO_ENTROPY_SEED_WITH_TREZOR,
java.util.HexFormat.of()
.formatHex(
service.mnemonicToSeed(
List.of(
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon", "about"),
"TREZOR")));
}

@Test
Expand Down
109 changes: 109 additions & 0 deletions src/test/java/com/example/bip39/api/MnemonicToEntropyTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.example.bip39.api;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
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 java.util.List;
import org.junit.jupiter.api.Test;

class MnemonicToEntropyTest {

private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

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

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

assertArrayEquals(expectedEntropy, service.mnemonicToEntropy(mnemonic));
}
}

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

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

assertArrayEquals(
expectedEntropy, service.mnemonicToEntropy(service.entropyToMnemonic(expectedEntropy)));
}
}

@Test
void decodesListInputUsingSameStrictRules() {
DefaultBip39Service service = new DefaultBip39Service();
List<String> words =
List.of(
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "about");

assertArrayEquals(new byte[16], service.mnemonicToEntropy(words));
}

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

Bip39Exception exception =
assertThrows(
Bip39Exception.class,
() ->
service.mnemonicToEntropy(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"));

assertEquals(Bip39ErrorCode.ERR_INVALID_WORD_COUNT, exception.getErrorCode());
}

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

Bip39Exception exception =
assertThrows(
Bip39Exception.class,
() ->
service.mnemonicToEntropy(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon typo"));

assertEquals(Bip39ErrorCode.ERR_WORD_NOT_IN_LIST, exception.getErrorCode());
}

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

Bip39Exception exception =
assertThrows(
Bip39Exception.class,
() ->
service.mnemonicToEntropy(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"));

assertEquals(Bip39ErrorCode.ERR_CHECKSUM_MISMATCH, exception.getErrorCode());
}

private static JsonNode loadEnglishVectors() throws IOException {
try (InputStream inputStream =
MnemonicToEntropyTest.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