Skip to content

Commit 1584916

Browse files
authored
Merge pull request #7 from xt0x/feat/compat-input-and-entropy-generation
Add mnemonic validation tests and implement entropy generator
2 parents 91716f4 + 8832495 commit 1584916

7 files changed

Lines changed: 381 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.example.bip39.entropy;
2+
3+
@FunctionalInterface
4+
public interface EntropySource {
5+
6+
void nextBytes(byte[] bytes);
7+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.example.bip39.entropy;
2+
3+
import com.example.bip39.api.Bip39Service;
4+
import com.example.bip39.error.Bip39ErrorCode;
5+
import com.example.bip39.error.Bip39Exception;
6+
import com.example.bip39.util.Bip39Constants;
7+
import java.security.SecureRandom;
8+
import java.util.Objects;
9+
10+
public final class SecureEntropyGenerator {
11+
12+
private final EntropySource entropySource;
13+
14+
public SecureEntropyGenerator() {
15+
this(new SecureRandom()::nextBytes);
16+
}
17+
18+
SecureEntropyGenerator(EntropySource entropySource) {
19+
this.entropySource = Objects.requireNonNull(entropySource, "entropySource must not be null");
20+
}
21+
22+
public byte[] generateEntropy(int numBytes) {
23+
if (!Bip39Constants.isAllowedEntropyLengthBytes(numBytes)) {
24+
throw new Bip39Exception(Bip39ErrorCode.ERR_ENTROPY_LENGTH, "Unsupported entropy length");
25+
}
26+
27+
byte[] entropy = new byte[numBytes];
28+
entropySource.nextBytes(entropy);
29+
return entropy;
30+
}
31+
32+
public String generateMnemonic(int numBytes, Bip39Service bip39Service) {
33+
Objects.requireNonNull(bip39Service, "bip39Service must not be null");
34+
return bip39Service.entropyToMnemonic(generateEntropy(numBytes));
35+
}
36+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.example.bip39.normalize;
2+
3+
import java.text.Normalizer;
4+
import java.text.Normalizer.Form;
5+
import java.util.Locale;
6+
import java.util.Objects;
7+
8+
public final class MnemonicInputNormalizer {
9+
10+
private MnemonicInputNormalizer() {}
11+
12+
public static String normalizeMnemonicInput(String text) {
13+
Objects.requireNonNull(text, "text must not be null");
14+
15+
String trimmed = text.strip();
16+
String whitespaceNormalized = replaceAsciiWhitespaceWithSpace(trimmed);
17+
String singleSpaced = collapseSpaces(whitespaceNormalized);
18+
String nfkdNormalized = Normalizer.normalize(singleSpaced, Form.NFKD);
19+
return nfkdNormalized.toLowerCase(Locale.ROOT);
20+
}
21+
22+
private static String replaceAsciiWhitespaceWithSpace(String text) {
23+
StringBuilder normalized = new StringBuilder(text.length());
24+
for (int index = 0; index < text.length(); index++) {
25+
char character = text.charAt(index);
26+
if (character == '\t' || character == '\n' || character == '\r') {
27+
normalized.append(' ');
28+
} else {
29+
normalized.append(character);
30+
}
31+
}
32+
return normalized.toString();
33+
}
34+
35+
private static String collapseSpaces(String text) {
36+
StringBuilder collapsed = new StringBuilder(text.length());
37+
boolean previousWasSpace = false;
38+
for (int index = 0; index < text.length(); index++) {
39+
char character = text.charAt(index);
40+
if (character == ' ') {
41+
if (!previousWasSpace) {
42+
collapsed.append(character);
43+
}
44+
previousWasSpace = true;
45+
} else {
46+
collapsed.append(character);
47+
previousWasSpace = false;
48+
}
49+
}
50+
return collapsed.toString();
51+
}
52+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.example.bip39.api;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertThrows;
5+
6+
import com.example.bip39.error.Bip39ErrorCode;
7+
import com.example.bip39.error.Bip39Exception;
8+
import org.junit.jupiter.api.Test;
9+
10+
class ErrorPriorityTest {
11+
12+
private static final String INVALID_WORD_COUNT_WITH_UNKNOWN_WORD =
13+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" + " typo";
14+
15+
private static final String WORD_NOT_IN_LIST_WITH_VALID_WORD_COUNT =
16+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
17+
+ " abandon typo";
18+
19+
@Test
20+
void validateMnemonicPrioritizesFormatBeforeAnyDeeperChecks() {
21+
DefaultBip39Service service = new DefaultBip39Service();
22+
23+
assertEquals(
24+
Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT,
25+
service.validateMnemonic("abandon abandon typo").errorCode());
26+
}
27+
28+
@Test
29+
void validateMnemonicPrioritizesWordCountBeforeWordListChecks() {
30+
DefaultBip39Service service = new DefaultBip39Service();
31+
32+
assertEquals(
33+
Bip39ErrorCode.ERR_INVALID_WORD_COUNT,
34+
service.validateMnemonic(INVALID_WORD_COUNT_WITH_UNKNOWN_WORD).errorCode());
35+
}
36+
37+
@Test
38+
void validateMnemonicPrioritizesWordListBeforeChecksumMismatch() {
39+
DefaultBip39Service service = new DefaultBip39Service();
40+
41+
assertEquals(
42+
Bip39ErrorCode.ERR_WORD_NOT_IN_LIST,
43+
service.validateMnemonic(WORD_NOT_IN_LIST_WITH_VALID_WORD_COUNT).errorCode());
44+
}
45+
46+
@Test
47+
void mnemonicToEntropyUsesSamePriorityOrder() {
48+
DefaultBip39Service service = new DefaultBip39Service();
49+
50+
Bip39Exception invalidWordCount =
51+
assertThrows(
52+
Bip39Exception.class,
53+
() -> service.mnemonicToEntropy(INVALID_WORD_COUNT_WITH_UNKNOWN_WORD));
54+
assertEquals(Bip39ErrorCode.ERR_INVALID_WORD_COUNT, invalidWordCount.getErrorCode());
55+
56+
Bip39Exception wordNotInList =
57+
assertThrows(
58+
Bip39Exception.class,
59+
() -> service.mnemonicToEntropy(WORD_NOT_IN_LIST_WITH_VALID_WORD_COUNT));
60+
assertEquals(Bip39ErrorCode.ERR_WORD_NOT_IN_LIST, wordNotInList.getErrorCode());
61+
}
62+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package com.example.bip39.api;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertFalse;
5+
import static org.junit.jupiter.api.Assertions.assertNull;
6+
import static org.junit.jupiter.api.Assertions.assertThrows;
7+
8+
import com.example.bip39.error.Bip39ErrorCode;
9+
import com.example.bip39.error.Bip39Exception;
10+
import com.example.bip39.model.ValidationResult;
11+
import java.util.List;
12+
import org.junit.jupiter.api.Test;
13+
14+
class FixedFailureCasesTest {
15+
16+
private static final String INVALID_WORD_COUNT_MNEMONIC =
17+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon";
18+
19+
private static final String CHECKSUM_MISMATCH_MNEMONIC =
20+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
21+
+ " abandon";
22+
23+
private static final String WORD_NOT_IN_LIST_MNEMONIC =
24+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
25+
+ " typo";
26+
27+
private static final String INVALID_FORMAT_MNEMONIC =
28+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
29+
+ " about";
30+
31+
@Test
32+
void appendixCCase1RejectsInvalidEntropyLength() {
33+
DefaultBip39Service service = new DefaultBip39Service();
34+
35+
Bip39Exception exception =
36+
assertThrows(Bip39Exception.class, () -> service.entropyToMnemonic(new byte[15]));
37+
38+
assertEquals(Bip39ErrorCode.ERR_ENTROPY_LENGTH, exception.getErrorCode());
39+
}
40+
41+
@Test
42+
void appendixCCase2RejectsInvalidWordCount() {
43+
DefaultBip39Service service = new DefaultBip39Service();
44+
45+
Bip39Exception exception =
46+
assertThrows(
47+
Bip39Exception.class, () -> service.mnemonicToEntropy(INVALID_WORD_COUNT_MNEMONIC));
48+
49+
assertEquals(Bip39ErrorCode.ERR_INVALID_WORD_COUNT, exception.getErrorCode());
50+
}
51+
52+
@Test
53+
void appendixCCase3RejectsChecksumMismatch() {
54+
DefaultBip39Service service = new DefaultBip39Service();
55+
56+
Bip39Exception exception =
57+
assertThrows(
58+
Bip39Exception.class, () -> service.mnemonicToEntropy(CHECKSUM_MISMATCH_MNEMONIC));
59+
60+
assertEquals(Bip39ErrorCode.ERR_CHECKSUM_MISMATCH, exception.getErrorCode());
61+
}
62+
63+
@Test
64+
void appendixCCase4RejectsWordNotInList() {
65+
DefaultBip39Service service = new DefaultBip39Service();
66+
67+
Bip39Exception exception =
68+
assertThrows(
69+
Bip39Exception.class, () -> service.mnemonicToEntropy(WORD_NOT_IN_LIST_MNEMONIC));
70+
71+
assertEquals(Bip39ErrorCode.ERR_WORD_NOT_IN_LIST, exception.getErrorCode());
72+
}
73+
74+
@Test
75+
void appendixCCase5RejectsInvalidFormatInCoreApis() {
76+
DefaultBip39Service service = new DefaultBip39Service();
77+
78+
Bip39Exception entropyException =
79+
assertThrows(
80+
Bip39Exception.class, () -> service.mnemonicToEntropy(INVALID_FORMAT_MNEMONIC));
81+
assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, entropyException.getErrorCode());
82+
83+
ValidationResult validationResult = service.validateMnemonic(INVALID_FORMAT_MNEMONIC);
84+
assertFalse(validationResult.ok());
85+
assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, validationResult.errorCode());
86+
assertNull(validationResult.normalizedMnemonic());
87+
assertNull(validationResult.wordCount());
88+
assertNull(validationResult.invalidWord());
89+
}
90+
91+
@Test
92+
void appendixCCase6RejectsInvalidSeedWordListStructure() {
93+
DefaultBip39Service service = new DefaultBip39Service();
94+
95+
Bip39Exception exception =
96+
assertThrows(
97+
Bip39Exception.class,
98+
() -> service.mnemonicToSeed(List.of("abandon", "", "abandon"), ""));
99+
100+
assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, exception.getErrorCode());
101+
}
102+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.example.bip39.entropy;
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.api.DefaultBip39Service;
8+
import com.example.bip39.error.Bip39ErrorCode;
9+
import com.example.bip39.error.Bip39Exception;
10+
import com.example.bip39.util.Bip39Constants;
11+
import java.util.Arrays;
12+
import org.junit.jupiter.api.Test;
13+
14+
class SecureEntropyGeneratorTest {
15+
16+
private static final String ZERO_ENTROPY_MNEMONIC =
17+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
18+
+ " about";
19+
20+
@Test
21+
void rejectsUnsupportedEntropyLengths() {
22+
SecureEntropyGenerator generator =
23+
new SecureEntropyGenerator(bytes -> Arrays.fill(bytes, (byte) 0));
24+
25+
Bip39Exception exception =
26+
assertThrows(Bip39Exception.class, () -> generator.generateEntropy(12));
27+
28+
assertEquals(Bip39ErrorCode.ERR_ENTROPY_LENGTH, exception.getErrorCode());
29+
}
30+
31+
@Test
32+
void generatesAllAllowedEntropyLengths() {
33+
SecureEntropyGenerator generator =
34+
new SecureEntropyGenerator(bytes -> Arrays.fill(bytes, (byte) 0));
35+
36+
for (int entropyLength : Bip39Constants.ALLOWED_ENTROPY_LENGTHS_BYTES) {
37+
assertEquals(entropyLength, generator.generateEntropy(entropyLength).length);
38+
}
39+
}
40+
41+
@Test
42+
void generatesDeterministicEntropyWhenSourceIsInjected() {
43+
byte[] expectedEntropy = new byte[16];
44+
for (int index = 0; index < expectedEntropy.length; index++) {
45+
expectedEntropy[index] = (byte) (index + 1);
46+
}
47+
48+
SecureEntropyGenerator generator =
49+
new SecureEntropyGenerator(
50+
bytes -> System.arraycopy(expectedEntropy, 0, bytes, 0, bytes.length));
51+
52+
assertArrayEquals(expectedEntropy, generator.generateEntropy(16));
53+
}
54+
55+
@Test
56+
void convertsGeneratedEntropyThroughBip39ServiceUsingThinAdapter() {
57+
SecureEntropyGenerator generator =
58+
new SecureEntropyGenerator(bytes -> Arrays.fill(bytes, (byte) 0));
59+
60+
assertEquals(ZERO_ENTROPY_MNEMONIC, generator.generateMnemonic(16, new DefaultBip39Service()));
61+
}
62+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.example.bip39.normalize;
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.assertFalse;
6+
import static org.junit.jupiter.api.Assertions.assertThrows;
7+
import static org.junit.jupiter.api.Assertions.assertTrue;
8+
9+
import com.example.bip39.api.DefaultBip39Service;
10+
import com.example.bip39.error.Bip39ErrorCode;
11+
import com.example.bip39.error.Bip39Exception;
12+
import com.example.bip39.model.ValidationResult;
13+
import org.junit.jupiter.api.Test;
14+
15+
class MnemonicInputNormalizerTest {
16+
17+
private static final String ZERO_ENTROPY_MNEMONIC =
18+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
19+
+ " about";
20+
21+
@Test
22+
void normalizesWhitespaceAndCaseForEnglishProfile() {
23+
String rawMnemonic =
24+
" ABANDON\tabandon\nABANDON\rabandon abandon\tabandon\nabandon\rabandon"
25+
+ " abandon\tabandon\nABANDON\rABOUT ";
26+
27+
assertEquals(
28+
ZERO_ENTROPY_MNEMONIC, MnemonicInputNormalizer.normalizeMnemonicInput(rawMnemonic));
29+
}
30+
31+
@Test
32+
void appliesNfkdBeforeLowercasing() {
33+
String rawMnemonic = "\tABANDON\nABOUT\r";
34+
35+
assertEquals("abandon about", MnemonicInputNormalizer.normalizeMnemonicInput(rawMnemonic));
36+
}
37+
38+
@Test
39+
void letsUiLayerExplicitlyNormalizeBeforeCallingStrictCoreApis() {
40+
DefaultBip39Service service = new DefaultBip39Service();
41+
String rawMnemonic =
42+
" ABANDON\tabandon\nABANDON\rabandon abandon\tabandon\nabandon\rabandon"
43+
+ " abandon\tabandon\nABANDON\rABOUT ";
44+
45+
Bip39Exception exception =
46+
assertThrows(Bip39Exception.class, () -> service.mnemonicToEntropy(rawMnemonic));
47+
assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, exception.getErrorCode());
48+
49+
ValidationResult rawValidation = service.validateMnemonic(rawMnemonic);
50+
assertFalse(rawValidation.ok());
51+
assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, rawValidation.errorCode());
52+
53+
String normalizedMnemonic = MnemonicInputNormalizer.normalizeMnemonicInput(rawMnemonic);
54+
assertArrayEquals(new byte[16], service.mnemonicToEntropy(normalizedMnemonic));
55+
56+
ValidationResult normalizedValidation = service.validateMnemonic(normalizedMnemonic);
57+
assertTrue(normalizedValidation.ok());
58+
assertEquals(ZERO_ENTROPY_MNEMONIC, normalizedValidation.normalizedMnemonic());
59+
}
60+
}

0 commit comments

Comments
 (0)