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
7 changes: 7 additions & 0 deletions src/main/java/com/example/bip39/entropy/EntropySource.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.bip39.entropy;

@FunctionalInterface
public interface EntropySource {

void nextBytes(byte[] bytes);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.example.bip39.entropy;

import com.example.bip39.api.Bip39Service;
import com.example.bip39.error.Bip39ErrorCode;
import com.example.bip39.error.Bip39Exception;
import com.example.bip39.util.Bip39Constants;
import java.security.SecureRandom;
import java.util.Objects;

public final class SecureEntropyGenerator {

private final EntropySource entropySource;

public SecureEntropyGenerator() {
this(new SecureRandom()::nextBytes);
}

SecureEntropyGenerator(EntropySource entropySource) {
this.entropySource = Objects.requireNonNull(entropySource, "entropySource must not be null");
}

public byte[] generateEntropy(int numBytes) {
if (!Bip39Constants.isAllowedEntropyLengthBytes(numBytes)) {
throw new Bip39Exception(Bip39ErrorCode.ERR_ENTROPY_LENGTH, "Unsupported entropy length");
}

byte[] entropy = new byte[numBytes];
entropySource.nextBytes(entropy);
return entropy;
}

public String generateMnemonic(int numBytes, Bip39Service bip39Service) {
Objects.requireNonNull(bip39Service, "bip39Service must not be null");
return bip39Service.entropyToMnemonic(generateEntropy(numBytes));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.example.bip39.normalize;

import java.text.Normalizer;
import java.text.Normalizer.Form;
import java.util.Locale;
import java.util.Objects;

public final class MnemonicInputNormalizer {

private MnemonicInputNormalizer() {}

public static String normalizeMnemonicInput(String text) {
Objects.requireNonNull(text, "text must not be null");

String trimmed = text.strip();
String whitespaceNormalized = replaceAsciiWhitespaceWithSpace(trimmed);
String singleSpaced = collapseSpaces(whitespaceNormalized);
String nfkdNormalized = Normalizer.normalize(singleSpaced, Form.NFKD);
return nfkdNormalized.toLowerCase(Locale.ROOT);
}

private static String replaceAsciiWhitespaceWithSpace(String text) {
StringBuilder normalized = new StringBuilder(text.length());
for (int index = 0; index < text.length(); index++) {
char character = text.charAt(index);
if (character == '\t' || character == '\n' || character == '\r') {
normalized.append(' ');
} else {
normalized.append(character);
}
}
return normalized.toString();
}

private static String collapseSpaces(String text) {
StringBuilder collapsed = new StringBuilder(text.length());
boolean previousWasSpace = false;
for (int index = 0; index < text.length(); index++) {
char character = text.charAt(index);
if (character == ' ') {
if (!previousWasSpace) {
collapsed.append(character);
}
previousWasSpace = true;
} else {
collapsed.append(character);
previousWasSpace = false;
}
}
return collapsed.toString();
}
}
62 changes: 62 additions & 0 deletions src/test/java/com/example/bip39/api/ErrorPriorityTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.example.bip39.api;

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 org.junit.jupiter.api.Test;

class ErrorPriorityTest {

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

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

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

assertEquals(
Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT,
service.validateMnemonic("abandon abandon typo").errorCode());
}

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

assertEquals(
Bip39ErrorCode.ERR_INVALID_WORD_COUNT,
service.validateMnemonic(INVALID_WORD_COUNT_WITH_UNKNOWN_WORD).errorCode());
}

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

assertEquals(
Bip39ErrorCode.ERR_WORD_NOT_IN_LIST,
service.validateMnemonic(WORD_NOT_IN_LIST_WITH_VALID_WORD_COUNT).errorCode());
}

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

Bip39Exception invalidWordCount =
assertThrows(
Bip39Exception.class,
() -> service.mnemonicToEntropy(INVALID_WORD_COUNT_WITH_UNKNOWN_WORD));
assertEquals(Bip39ErrorCode.ERR_INVALID_WORD_COUNT, invalidWordCount.getErrorCode());

Bip39Exception wordNotInList =
assertThrows(
Bip39Exception.class,
() -> service.mnemonicToEntropy(WORD_NOT_IN_LIST_WITH_VALID_WORD_COUNT));
assertEquals(Bip39ErrorCode.ERR_WORD_NOT_IN_LIST, wordNotInList.getErrorCode());
}
}
102 changes: 102 additions & 0 deletions src/test/java/com/example/bip39/api/FixedFailureCasesTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.example.bip39.api;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

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

class FixedFailureCasesTest {

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

private static final String CHECKSUM_MISMATCH_MNEMONIC =
"abandon 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 INVALID_FORMAT_MNEMONIC =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
+ " about";

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

Bip39Exception exception =
assertThrows(Bip39Exception.class, () -> service.entropyToMnemonic(new byte[15]));

assertEquals(Bip39ErrorCode.ERR_ENTROPY_LENGTH, exception.getErrorCode());
}

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

Bip39Exception exception =
assertThrows(
Bip39Exception.class, () -> service.mnemonicToEntropy(INVALID_WORD_COUNT_MNEMONIC));

assertEquals(Bip39ErrorCode.ERR_INVALID_WORD_COUNT, exception.getErrorCode());
}

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

Bip39Exception exception =
assertThrows(
Bip39Exception.class, () -> service.mnemonicToEntropy(CHECKSUM_MISMATCH_MNEMONIC));

assertEquals(Bip39ErrorCode.ERR_CHECKSUM_MISMATCH, exception.getErrorCode());
}

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

Bip39Exception exception =
assertThrows(
Bip39Exception.class, () -> service.mnemonicToEntropy(WORD_NOT_IN_LIST_MNEMONIC));

assertEquals(Bip39ErrorCode.ERR_WORD_NOT_IN_LIST, exception.getErrorCode());
}

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

Bip39Exception entropyException =
assertThrows(
Bip39Exception.class, () -> service.mnemonicToEntropy(INVALID_FORMAT_MNEMONIC));
assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, entropyException.getErrorCode());

ValidationResult validationResult = service.validateMnemonic(INVALID_FORMAT_MNEMONIC);
assertFalse(validationResult.ok());
assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, validationResult.errorCode());
assertNull(validationResult.normalizedMnemonic());
assertNull(validationResult.wordCount());
assertNull(validationResult.invalidWord());
}

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

Bip39Exception exception =
assertThrows(
Bip39Exception.class,
() -> service.mnemonicToSeed(List.of("abandon", "", "abandon"), ""));

assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, exception.getErrorCode());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.example.bip39.entropy;

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.api.DefaultBip39Service;
import com.example.bip39.error.Bip39ErrorCode;
import com.example.bip39.error.Bip39Exception;
import com.example.bip39.util.Bip39Constants;
import java.util.Arrays;
import org.junit.jupiter.api.Test;

class SecureEntropyGeneratorTest {

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

@Test
void rejectsUnsupportedEntropyLengths() {
SecureEntropyGenerator generator =
new SecureEntropyGenerator(bytes -> Arrays.fill(bytes, (byte) 0));

Bip39Exception exception =
assertThrows(Bip39Exception.class, () -> generator.generateEntropy(12));

assertEquals(Bip39ErrorCode.ERR_ENTROPY_LENGTH, exception.getErrorCode());
}

@Test
void generatesAllAllowedEntropyLengths() {
SecureEntropyGenerator generator =
new SecureEntropyGenerator(bytes -> Arrays.fill(bytes, (byte) 0));

for (int entropyLength : Bip39Constants.ALLOWED_ENTROPY_LENGTHS_BYTES) {
assertEquals(entropyLength, generator.generateEntropy(entropyLength).length);
}
}

@Test
void generatesDeterministicEntropyWhenSourceIsInjected() {
byte[] expectedEntropy = new byte[16];
for (int index = 0; index < expectedEntropy.length; index++) {
expectedEntropy[index] = (byte) (index + 1);
}

SecureEntropyGenerator generator =
new SecureEntropyGenerator(
bytes -> System.arraycopy(expectedEntropy, 0, bytes, 0, bytes.length));

assertArrayEquals(expectedEntropy, generator.generateEntropy(16));
}

@Test
void convertsGeneratedEntropyThroughBip39ServiceUsingThinAdapter() {
SecureEntropyGenerator generator =
new SecureEntropyGenerator(bytes -> Arrays.fill(bytes, (byte) 0));

assertEquals(ZERO_ENTROPY_MNEMONIC, generator.generateMnemonic(16, new DefaultBip39Service()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.example.bip39.normalize;

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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import com.example.bip39.api.DefaultBip39Service;
import com.example.bip39.error.Bip39ErrorCode;
import com.example.bip39.error.Bip39Exception;
import com.example.bip39.model.ValidationResult;
import org.junit.jupiter.api.Test;

class MnemonicInputNormalizerTest {

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

@Test
void normalizesWhitespaceAndCaseForEnglishProfile() {
String rawMnemonic =
" ABANDON\tabandon\nABANDON\rabandon abandon\tabandon\nabandon\rabandon"
+ " abandon\tabandon\nABANDON\rABOUT ";

assertEquals(
ZERO_ENTROPY_MNEMONIC, MnemonicInputNormalizer.normalizeMnemonicInput(rawMnemonic));
}

@Test
void appliesNfkdBeforeLowercasing() {
String rawMnemonic = "\tABANDON\nABOUT\r";

assertEquals("abandon about", MnemonicInputNormalizer.normalizeMnemonicInput(rawMnemonic));
}

@Test
void letsUiLayerExplicitlyNormalizeBeforeCallingStrictCoreApis() {
DefaultBip39Service service = new DefaultBip39Service();
String rawMnemonic =
" ABANDON\tabandon\nABANDON\rabandon abandon\tabandon\nabandon\rabandon"
+ " abandon\tabandon\nABANDON\rABOUT ";

Bip39Exception exception =
assertThrows(Bip39Exception.class, () -> service.mnemonicToEntropy(rawMnemonic));
assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, exception.getErrorCode());

ValidationResult rawValidation = service.validateMnemonic(rawMnemonic);
assertFalse(rawValidation.ok());
assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, rawValidation.errorCode());

String normalizedMnemonic = MnemonicInputNormalizer.normalizeMnemonicInput(rawMnemonic);
assertArrayEquals(new byte[16], service.mnemonicToEntropy(normalizedMnemonic));

ValidationResult normalizedValidation = service.validateMnemonic(normalizedMnemonic);
assertTrue(normalizedValidation.ok());
assertEquals(ZERO_ENTROPY_MNEMONIC, normalizedValidation.normalizedMnemonic());
}
}
Loading