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
82 changes: 74 additions & 8 deletions src/main/java/com/example/bip39/api/DefaultBip39Service.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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;
Expand Down Expand Up @@ -47,34 +48,30 @@ 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
Expand Down Expand Up @@ -110,4 +107,73 @@ 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 record MnemonicAnalysis(ValidationResult validationResult, byte[] entropy) {}
}
36 changes: 21 additions & 15 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 @@ -15,6 +16,10 @@

class DefaultBip39ServiceTest {

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

@Test
void defaultServiceLoadsValidatedEnglishWordList() {
DefaultBip39Service service = new DefaultBip39Service();
Expand All @@ -31,24 +36,25 @@ 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(
"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")));
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")));
assertThrows(
UnsupportedOperationException.class,
() -> service.mnemonicToSeed("abandon ability", "TREZOR"));
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");
}
}
}
86 changes: 86 additions & 0 deletions src/test/java/com/example/bip39/api/ValidateMnemonicTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.example.bip39.api;

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

import com.example.bip39.error.Bip39ErrorCode;
import com.example.bip39.model.ValidationResult;
import java.util.List;
import org.junit.jupiter.api.Test;

class ValidateMnemonicTest {

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

private static final String INVALID_WORD_COUNT_MNEMONIC =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon";

private static final String WORD_NOT_IN_LIST_MNEMONIC =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
+ " typo";

private static final String CHECKSUM_MISMATCH_MNEMONIC =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
+ " abandon";

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

assertEquals(
ValidationResult.success(ZERO_ENTROPY_MNEMONIC, 12),
service.validateMnemonic(ZERO_ENTROPY_MNEMONIC));
}

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

assertEquals(
ValidationResult.success(ZERO_ENTROPY_MNEMONIC, 12),
service.validateMnemonic(
List.of(
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon", "about")));
}

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

assertEquals(
ValidationResult.failure(
Bip39ErrorCode.ERR_INVALID_WORD_COUNT, INVALID_WORD_COUNT_MNEMONIC, 11, null),
service.validateMnemonic(INVALID_WORD_COUNT_MNEMONIC));
}

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

assertEquals(
ValidationResult.failure(
Bip39ErrorCode.ERR_WORD_NOT_IN_LIST, WORD_NOT_IN_LIST_MNEMONIC, 12, "typo"),
service.validateMnemonic(WORD_NOT_IN_LIST_MNEMONIC));
}

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

assertEquals(
ValidationResult.failure(
Bip39ErrorCode.ERR_CHECKSUM_MISMATCH, CHECKSUM_MISMATCH_MNEMONIC, 12, null),
service.validateMnemonic(CHECKSUM_MISMATCH_MNEMONIC));
}

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

assertEquals(
ValidationResult.failure(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, null, null, null),
service.validateMnemonic("abandon\tabandon abandon"));
}
}
Loading