From 0c858dd8c06f620cffeac90300dd2e3d28c40cad Mon Sep 17 00:00:00 2001 From: xt0x Date: Sat, 18 Apr 2026 13:53:53 +0900 Subject: [PATCH 1/3] feat(api): implement mnemonicToEntropy method and add tests for mnemonic validation --- .../bip39/api/DefaultBip39Service.java | 46 +++++++- .../bip39/api/DefaultBip39ServiceTest.java | 19 +-- .../bip39/api/MnemonicToEntropyTest.java | 109 ++++++++++++++++++ 3 files changed, 163 insertions(+), 11 deletions(-) create mode 100644 src/test/java/com/example/bip39/api/MnemonicToEntropyTest.java diff --git a/src/main/java/com/example/bip39/api/DefaultBip39Service.java b/src/main/java/com/example/bip39/api/DefaultBip39Service.java index b9deb13..f2a6da7 100644 --- a/src/main/java/com/example/bip39/api/DefaultBip39Service.java +++ b/src/main/java/com/example/bip39/api/DefaultBip39Service.java @@ -47,14 +47,12 @@ public String entropyToMnemonic(byte[] entropy) { @Override public byte[] mnemonicToEntropy(String mnemonic) { - StrictMnemonicParser.parse(mnemonic); - throw new UnsupportedOperationException("mnemonicToEntropy is not implemented yet"); + return decodeMnemonicWords(StrictMnemonicParser.parse(mnemonic).words()); } @Override public byte[] mnemonicToEntropy(List words) { - StrictMnemonicParser.parse(words); - throw new UnsupportedOperationException("mnemonicToEntropy is not implemented yet"); + return decodeMnemonicWords(StrictMnemonicParser.parse(words).words()); } @Override @@ -110,4 +108,44 @@ private String joinMnemonicWords(int[] wordIndexes) { } return mnemonic.toString(); } + + private byte[] decodeMnemonicWords(List words) { + validateWordCount(words.size()); + + int[] wordIndexes = new int[words.size()]; + for (int index = 0; index < words.size(); index++) { + String word = words.get(index); + if (!wordList.containsWord(word)) { + throw new Bip39Exception(Bip39ErrorCode.ERR_WORD_NOT_IN_LIST, "Word is not in the list"); + } + 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) { + throw new Bip39Exception(Bip39ErrorCode.ERR_CHECKSUM_MISMATCH, "Checksum mismatch"); + } + + return entropy; + } + + private static void validateWordCount(int wordCount) { + if (wordCount != 12 + && wordCount != 15 + && wordCount != 18 + && wordCount != 21 + && wordCount != 24) { + throw new Bip39Exception( + Bip39ErrorCode.ERR_INVALID_WORD_COUNT, "Unsupported mnemonic word count"); + } + } } diff --git a/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java b/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java index a095138..bedf365 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; @@ -31,18 +32,22 @@ void servicesShareSingleLoadedWordListInstance() { } @Test - void methodsBeyondEntropyEncodingRemainExplicitlyUnimplementedForNormalizedInputs() { + void methodsBeyondEntropyEncodingAndDecodingRemainExplicitlyUnimplementedForNormalizedInputs() { DefaultBip39Service service = new DefaultBip39Service(); 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"))); + assertArrayEquals( + new byte[16], + service.mnemonicToEntropy( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")); + assertArrayEquals( + new byte[16], + service.mnemonicToEntropy( + List.of( + "abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "abandon", "about"))); assertThrows( UnsupportedOperationException.class, () -> service.validateMnemonic("abandon ability able")); 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"); + } + } +} From 48b4ccf1296d021a9d2e6efc94801d1170ed3f8f Mon Sep 17 00:00:00 2001 From: xt0x Date: Sat, 18 Apr 2026 13:58:01 +0900 Subject: [PATCH 2/3] feat(api): enhance mnemonic validation with detailed error reporting and add unit tests --- .../bip39/api/DefaultBip39Service.java | 68 ++++++++++----- .../bip39/api/DefaultBip39ServiceTest.java | 29 ++++--- .../bip39/api/ValidateMnemonicTest.java | 86 +++++++++++++++++++ 3 files changed, 149 insertions(+), 34 deletions(-) create mode 100644 src/test/java/com/example/bip39/api/ValidateMnemonicTest.java diff --git a/src/main/java/com/example/bip39/api/DefaultBip39Service.java b/src/main/java/com/example/bip39/api/DefaultBip39Service.java index f2a6da7..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,32 +48,30 @@ public String entropyToMnemonic(byte[] entropy) { @Override public byte[] mnemonicToEntropy(String mnemonic) { - return decodeMnemonicWords(StrictMnemonicParser.parse(mnemonic).words()); + return requireValidMnemonic(analyzeParsedMnemonic(StrictMnemonicParser.parse(mnemonic))); } @Override public byte[] mnemonicToEntropy(List words) { - return decodeMnemonicWords(StrictMnemonicParser.parse(words).words()); + 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 @@ -109,14 +108,26 @@ private String joinMnemonicWords(int[] wordIndexes) { return mnemonic.toString(); } - private byte[] decodeMnemonicWords(List words) { - validateWordCount(words.size()); + private MnemonicAnalysis analyzeParsedMnemonic(ParsedMnemonic parsedMnemonic) { + int wordCount = parsedMnemonic.wordCount(); + String normalizedMnemonic = parsedMnemonic.normalizedMnemonic(); + List words = parsedMnemonic.words(); - int[] wordIndexes = new int[words.size()]; + 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)) { - throw new Bip39Exception(Bip39ErrorCode.ERR_WORD_NOT_IN_LIST, "Word is not in the list"); + return new MnemonicAnalysis( + ValidationResult.failure( + Bip39ErrorCode.ERR_WORD_NOT_IN_LIST, normalizedMnemonic, wordCount, word), + null); } wordIndexes[index] = wordList.wordToIndex(word); } @@ -132,20 +143,37 @@ private byte[] decodeMnemonicWords(List words) { int actualChecksumBits = BitPacker.readBits(packedMnemonicBits, entropyLengthBits, checksumLengthBits); if (actualChecksumBits != expectedChecksumBits) { - throw new Bip39Exception(Bip39ErrorCode.ERR_CHECKSUM_MISMATCH, "Checksum mismatch"); + return new MnemonicAnalysis( + ValidationResult.failure( + Bip39ErrorCode.ERR_CHECKSUM_MISMATCH, normalizedMnemonic, wordCount, null), + null); } - return entropy; + return new MnemonicAnalysis(ValidationResult.success(normalizedMnemonic, wordCount), entropy); } - private static void validateWordCount(int wordCount) { - if (wordCount != 12 - && wordCount != 15 - && wordCount != 18 - && wordCount != 21 - && wordCount != 24) { - throw new Bip39Exception( - Bip39ErrorCode.ERR_INVALID_WORD_COUNT, "Unsupported mnemonic word count"); + 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 bedf365..efa04df 100644 --- a/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java +++ b/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java @@ -16,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(); @@ -32,28 +36,25 @@ void servicesShareSingleLoadedWordListInstance() { } @Test - void methodsBeyondEntropyEncodingAndDecodingRemainExplicitlyUnimplementedForNormalizedInputs() { + void methodsBeyondCoreConversionsRemainExplicitlyUnimplementedForNormalizedInputs() { DefaultBip39Service service = new DefaultBip39Service(); - assertEquals( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - service.entropyToMnemonic(new byte[16])); - assertArrayEquals( - new byte[16], - service.mnemonicToEntropy( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")); + 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"))); - assertThrows( - UnsupportedOperationException.class, - () -> service.validateMnemonic("abandon ability able")); - assertThrows( - UnsupportedOperationException.class, - () -> service.validateMnemonic(List.of("abandon", "ability", "able"))); + 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"))); assertThrows( UnsupportedOperationException.class, () -> service.mnemonicToSeed("abandon ability", "TREZOR")); 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")); + } +} From f9e9e069f88c90e2d3c2a46e0d14d8f07a70cea8 Mon Sep 17 00:00:00 2001 From: xt0x Date: Sat, 18 Apr 2026 14:01:15 +0900 Subject: [PATCH 3/3] feat(api): implement mnemonicToSeed method and add tests for validation and error handling --- .../bip39/api/DefaultBip39Service.java | 64 +++++++++++- .../bip39/api/DefaultBip39ServiceTest.java | 23 +++-- .../example/bip39/api/MnemonicToSeedTest.java | 99 +++++++++++++++++++ 3 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 src/test/java/com/example/bip39/api/MnemonicToSeedTest.java diff --git a/src/main/java/com/example/bip39/api/DefaultBip39Service.java b/src/main/java/com/example/bip39/api/DefaultBip39Service.java index 18a2d91..21fefa3 100644 --- a/src/main/java/com/example/bip39/api/DefaultBip39Service.java +++ b/src/main/java/com/example/bip39/api/DefaultBip39Service.java @@ -1,6 +1,7 @@ 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; @@ -10,6 +11,9 @@ 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; @@ -76,12 +80,12 @@ public ValidationResult validateMnemonic(List words) { @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 words, String passphrase) { - throw new UnsupportedOperationException("mnemonicToSeed is not implemented yet"); + return deriveSeed(joinSeedMnemonicWords(words), requirePassphrase(passphrase)); } private static ValidationResult invalidFormatResult(Bip39Exception exception) { @@ -175,5 +179,61 @@ private static boolean isSupportedWordCount(int wordCount) { || 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 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) {} } diff --git a/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java b/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java index efa04df..8e6357e 100644 --- a/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java +++ b/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java @@ -11,6 +11,7 @@ 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; @@ -20,6 +21,10 @@ class DefaultBip39ServiceTest { "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(); @@ -55,12 +60,18 @@ void methodsBeyondCoreConversionsRemainExplicitlyUnimplementedForNormalizedInput 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")); - assertThrows( - UnsupportedOperationException.class, - () -> service.mnemonicToSeed(List.of("abandon", "ability"), "TREZOR")); + assertEquals( + ZERO_ENTROPY_SEED_WITH_TREZOR, + HexFormat.of().formatHex(service.mnemonicToSeed(ZERO_ENTROPY_MNEMONIC, "TREZOR"))); + assertEquals( + 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 diff --git a/src/test/java/com/example/bip39/api/MnemonicToSeedTest.java b/src/test/java/com/example/bip39/api/MnemonicToSeedTest.java new file mode 100644 index 0000000..3b4bf32 --- /dev/null +++ b/src/test/java/com/example/bip39/api/MnemonicToSeedTest.java @@ -0,0 +1,99 @@ +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 MnemonicToSeedTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final String ZERO_ENTROPY_MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" + + " about"; + + @Test + void matchesAllOfficialEnglishSeedVectorsWithTrezorPassphrase() throws IOException { + DefaultBip39Service service = new DefaultBip39Service(); + JsonNode vectors = loadEnglishVectors(); + + for (JsonNode vector : vectors) { + String mnemonic = vector.get(1).textValue(); + byte[] expectedSeed = HexFormat.of().parseHex(vector.get(2).textValue()); + + assertArrayEquals(expectedSeed, service.mnemonicToSeed(mnemonic, "TREZOR")); + } + } + + @Test + void derivesKnownSeedWithEmptyPassphrase() { + DefaultBip39Service service = new DefaultBip39Service(); + + assertEquals( + "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1" + + "9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4", + HexFormat.of().formatHex(service.mnemonicToSeed(ZERO_ENTROPY_MNEMONIC, ""))); + } + + @Test + void derivesSeedFromWordListInput() { + DefaultBip39Service service = new DefaultBip39Service(); + + assertArrayEquals( + service.mnemonicToSeed(ZERO_ENTROPY_MNEMONIC, "TREZOR"), + service.mnemonicToSeed( + List.of( + "abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "abandon", "about"), + "TREZOR")); + } + + @Test + void doesNotValidateMnemonicContentForStringInput() { + DefaultBip39Service service = new DefaultBip39Service(); + + assertEquals(64, service.mnemonicToSeed("abandon\tabandon abandon", "").length); + assertEquals(64, service.mnemonicToSeed("abandon abandon abandon", "TREZOR").length); + } + + @Test + void rejectsInvalidListStructureOrNullInputs() { + DefaultBip39Service service = new DefaultBip39Service(); + + assertInvalidFormat(() -> service.mnemonicToSeed((String) null, "")); + assertInvalidFormat(() -> service.mnemonicToSeed(ZERO_ENTROPY_MNEMONIC, null)); + assertInvalidFormat(() -> service.mnemonicToSeed((List) null, "")); + assertInvalidFormat(() -> service.mnemonicToSeed(List.of("abandon", "", "about"), "")); + assertInvalidFormat(() -> service.mnemonicToSeed(List.of("abandon", "ab andon", "about"), "")); + } + + private static void assertInvalidFormat(ThrowingRunnable action) { + Bip39Exception exception = assertThrows(Bip39Exception.class, action::run); + assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, exception.getErrorCode()); + } + + private static JsonNode loadEnglishVectors() throws IOException { + try (InputStream inputStream = + MnemonicToSeedTest.class.getResourceAsStream("/bip39/vectors.json")) { + if (inputStream == null) { + throw new IllegalStateException("Missing resource: /bip39/vectors.json"); + } + return OBJECT_MAPPER.readTree(inputStream).get("english"); + } + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run(); + } +}