diff --git a/src/main/java/com/example/bip39/api/DefaultBip39Service.java b/src/main/java/com/example/bip39/api/DefaultBip39Service.java index b9deb13..18a2d91 100644 --- a/src/main/java/com/example/bip39/api/DefaultBip39Service.java +++ b/src/main/java/com/example/bip39/api/DefaultBip39Service.java @@ -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; @@ -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 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 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 @@ -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 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) {} } diff --git a/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java b/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java index a095138..efa04df 100644 --- a/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java +++ b/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java @@ -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; @@ -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(); @@ -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")); diff --git a/src/test/java/com/example/bip39/api/MnemonicToEntropyTest.java b/src/test/java/com/example/bip39/api/MnemonicToEntropyTest.java new file mode 100644 index 0000000..d404d21 --- /dev/null +++ b/src/test/java/com/example/bip39/api/MnemonicToEntropyTest.java @@ -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 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"); + } + } +} diff --git a/src/test/java/com/example/bip39/api/ValidateMnemonicTest.java b/src/test/java/com/example/bip39/api/ValidateMnemonicTest.java new file mode 100644 index 0000000..f95986a --- /dev/null +++ b/src/test/java/com/example/bip39/api/ValidateMnemonicTest.java @@ -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")); + } +}