Skip to content

Commit 91716f4

Browse files
authored
Merge pull request #6 from xt0x/feat/mnemonic-to-seed
Implement mnemonic methods and enhance validation with tests
2 parents 56c5ea4 + f9e9e06 commit 91716f4

3 files changed

Lines changed: 178 additions & 8 deletions

File tree

src/main/java/com/example/bip39/api/DefaultBip39Service.java

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.example.bip39.api;
22

33
import com.example.bip39.bit.BitPacker;
4+
import com.example.bip39.crypto.Pbkdf2HmacSha512;
45
import com.example.bip39.crypto.Sha256Digest;
56
import com.example.bip39.error.Bip39ErrorCode;
67
import com.example.bip39.error.Bip39Exception;
@@ -10,6 +11,9 @@
1011
import com.example.bip39.util.Bip39Constants;
1112
import com.example.bip39.wordlist.Bip39WordList;
1213
import com.example.bip39.wordlist.EnglishWordList;
14+
import java.nio.charset.StandardCharsets;
15+
import java.text.Normalizer;
16+
import java.text.Normalizer.Form;
1317
import java.util.Arrays;
1418
import java.util.List;
1519
import java.util.Objects;
@@ -76,12 +80,12 @@ public ValidationResult validateMnemonic(List<String> words) {
7680

7781
@Override
7882
public byte[] mnemonicToSeed(String mnemonic, String passphrase) {
79-
throw new UnsupportedOperationException("mnemonicToSeed is not implemented yet");
83+
return deriveSeed(requireMnemonicString(mnemonic), requirePassphrase(passphrase));
8084
}
8185

8286
@Override
8387
public byte[] mnemonicToSeed(List<String> words, String passphrase) {
84-
throw new UnsupportedOperationException("mnemonicToSeed is not implemented yet");
88+
return deriveSeed(joinSeedMnemonicWords(words), requirePassphrase(passphrase));
8589
}
8690

8791
private static ValidationResult invalidFormatResult(Bip39Exception exception) {
@@ -175,5 +179,61 @@ private static boolean isSupportedWordCount(int wordCount) {
175179
|| wordCount == 24;
176180
}
177181

182+
private static String requireMnemonicString(String mnemonic) {
183+
if (mnemonic == null) {
184+
throw invalidMnemonicFormatException();
185+
}
186+
return mnemonic;
187+
}
188+
189+
private static String requirePassphrase(String passphrase) {
190+
if (passphrase == null) {
191+
throw invalidMnemonicFormatException();
192+
}
193+
return passphrase;
194+
}
195+
196+
private static String joinSeedMnemonicWords(List<String> words) {
197+
if (words == null) {
198+
throw invalidMnemonicFormatException();
199+
}
200+
201+
StringBuilder mnemonic = new StringBuilder();
202+
for (int index = 0; index < words.size(); index++) {
203+
String word = words.get(index);
204+
if (word == null || word.isEmpty() || containsWhitespace(word)) {
205+
throw invalidMnemonicFormatException();
206+
}
207+
if (index > 0) {
208+
mnemonic.append(' ');
209+
}
210+
mnemonic.append(word);
211+
}
212+
return mnemonic.toString();
213+
}
214+
215+
private static byte[] deriveSeed(String mnemonic, String passphrase) {
216+
String normalizedMnemonic = Normalizer.normalize(mnemonic, Form.NFKD);
217+
String normalizedPassphrase = Normalizer.normalize(passphrase, Form.NFKD);
218+
byte[] passwordBytes = normalizedMnemonic.getBytes(StandardCharsets.UTF_8);
219+
byte[] saltBytes = ("mnemonic" + normalizedPassphrase).getBytes(StandardCharsets.UTF_8);
220+
return Pbkdf2HmacSha512.derive(passwordBytes, saltBytes);
221+
}
222+
223+
private static boolean containsWhitespace(String value) {
224+
for (int index = 0; index < value.length(); index++) {
225+
char character = value.charAt(index);
226+
if (Character.isWhitespace(character) || Character.isSpaceChar(character)) {
227+
return true;
228+
}
229+
}
230+
return false;
231+
}
232+
233+
private static Bip39Exception invalidMnemonicFormatException() {
234+
return new Bip39Exception(
235+
Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, "Mnemonic format is invalid");
236+
}
237+
178238
private record MnemonicAnalysis(ValidationResult validationResult, byte[] entropy) {}
179239
}

src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import com.example.bip39.error.Bip39Exception;
1212
import com.example.bip39.model.ValidationResult;
1313
import com.example.bip39.wordlist.EnglishWordList;
14+
import java.util.HexFormat;
1415
import java.util.List;
1516
import org.junit.jupiter.api.Test;
1617

@@ -20,6 +21,10 @@ class DefaultBip39ServiceTest {
2021
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
2122
+ " about";
2223

24+
private static final String ZERO_ENTROPY_SEED_WITH_TREZOR =
25+
"c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553"
26+
+ "1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04";
27+
2328
@Test
2429
void defaultServiceLoadsValidatedEnglishWordList() {
2530
DefaultBip39Service service = new DefaultBip39Service();
@@ -55,12 +60,18 @@ void methodsBeyondCoreConversionsRemainExplicitlyUnimplementedForNormalizedInput
5560
ValidationResult.failure(
5661
Bip39ErrorCode.ERR_INVALID_WORD_COUNT, "abandon ability able", 3, null),
5762
service.validateMnemonic(List.of("abandon", "ability", "able")));
58-
assertThrows(
59-
UnsupportedOperationException.class,
60-
() -> service.mnemonicToSeed("abandon ability", "TREZOR"));
61-
assertThrows(
62-
UnsupportedOperationException.class,
63-
() -> service.mnemonicToSeed(List.of("abandon", "ability"), "TREZOR"));
63+
assertEquals(
64+
ZERO_ENTROPY_SEED_WITH_TREZOR,
65+
HexFormat.of().formatHex(service.mnemonicToSeed(ZERO_ENTROPY_MNEMONIC, "TREZOR")));
66+
assertEquals(
67+
ZERO_ENTROPY_SEED_WITH_TREZOR,
68+
java.util.HexFormat.of()
69+
.formatHex(
70+
service.mnemonicToSeed(
71+
List.of(
72+
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
73+
"abandon", "abandon", "abandon", "abandon", "about"),
74+
"TREZOR")));
6475
}
6576

6677
@Test
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package com.example.bip39.api;
2+
3+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
4+
import static org.junit.jupiter.api.Assertions.assertEquals;
5+
import static org.junit.jupiter.api.Assertions.assertThrows;
6+
7+
import com.example.bip39.error.Bip39ErrorCode;
8+
import com.example.bip39.error.Bip39Exception;
9+
import com.fasterxml.jackson.databind.JsonNode;
10+
import com.fasterxml.jackson.databind.ObjectMapper;
11+
import java.io.IOException;
12+
import java.io.InputStream;
13+
import java.util.HexFormat;
14+
import java.util.List;
15+
import org.junit.jupiter.api.Test;
16+
17+
class MnemonicToSeedTest {
18+
19+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
20+
21+
private static final String ZERO_ENTROPY_MNEMONIC =
22+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
23+
+ " about";
24+
25+
@Test
26+
void matchesAllOfficialEnglishSeedVectorsWithTrezorPassphrase() throws IOException {
27+
DefaultBip39Service service = new DefaultBip39Service();
28+
JsonNode vectors = loadEnglishVectors();
29+
30+
for (JsonNode vector : vectors) {
31+
String mnemonic = vector.get(1).textValue();
32+
byte[] expectedSeed = HexFormat.of().parseHex(vector.get(2).textValue());
33+
34+
assertArrayEquals(expectedSeed, service.mnemonicToSeed(mnemonic, "TREZOR"));
35+
}
36+
}
37+
38+
@Test
39+
void derivesKnownSeedWithEmptyPassphrase() {
40+
DefaultBip39Service service = new DefaultBip39Service();
41+
42+
assertEquals(
43+
"5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1"
44+
+ "9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4",
45+
HexFormat.of().formatHex(service.mnemonicToSeed(ZERO_ENTROPY_MNEMONIC, "")));
46+
}
47+
48+
@Test
49+
void derivesSeedFromWordListInput() {
50+
DefaultBip39Service service = new DefaultBip39Service();
51+
52+
assertArrayEquals(
53+
service.mnemonicToSeed(ZERO_ENTROPY_MNEMONIC, "TREZOR"),
54+
service.mnemonicToSeed(
55+
List.of(
56+
"abandon", "abandon", "abandon", "abandon", "abandon", "abandon", "abandon",
57+
"abandon", "abandon", "abandon", "abandon", "about"),
58+
"TREZOR"));
59+
}
60+
61+
@Test
62+
void doesNotValidateMnemonicContentForStringInput() {
63+
DefaultBip39Service service = new DefaultBip39Service();
64+
65+
assertEquals(64, service.mnemonicToSeed("abandon\tabandon abandon", "").length);
66+
assertEquals(64, service.mnemonicToSeed("abandon abandon abandon", "TREZOR").length);
67+
}
68+
69+
@Test
70+
void rejectsInvalidListStructureOrNullInputs() {
71+
DefaultBip39Service service = new DefaultBip39Service();
72+
73+
assertInvalidFormat(() -> service.mnemonicToSeed((String) null, ""));
74+
assertInvalidFormat(() -> service.mnemonicToSeed(ZERO_ENTROPY_MNEMONIC, null));
75+
assertInvalidFormat(() -> service.mnemonicToSeed((List<String>) null, ""));
76+
assertInvalidFormat(() -> service.mnemonicToSeed(List.of("abandon", "", "about"), ""));
77+
assertInvalidFormat(() -> service.mnemonicToSeed(List.of("abandon", "ab andon", "about"), ""));
78+
}
79+
80+
private static void assertInvalidFormat(ThrowingRunnable action) {
81+
Bip39Exception exception = assertThrows(Bip39Exception.class, action::run);
82+
assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, exception.getErrorCode());
83+
}
84+
85+
private static JsonNode loadEnglishVectors() throws IOException {
86+
try (InputStream inputStream =
87+
MnemonicToSeedTest.class.getResourceAsStream("/bip39/vectors.json")) {
88+
if (inputStream == null) {
89+
throw new IllegalStateException("Missing resource: /bip39/vectors.json");
90+
}
91+
return OBJECT_MAPPER.readTree(inputStream).get("english");
92+
}
93+
}
94+
95+
@FunctionalInterface
96+
private interface ThrowingRunnable {
97+
void run();
98+
}
99+
}

0 commit comments

Comments
 (0)