diff --git a/src/main/java/com/example/bip39/entropy/EntropySource.java b/src/main/java/com/example/bip39/entropy/EntropySource.java new file mode 100644 index 0000000..35e1442 --- /dev/null +++ b/src/main/java/com/example/bip39/entropy/EntropySource.java @@ -0,0 +1,7 @@ +package com.example.bip39.entropy; + +@FunctionalInterface +public interface EntropySource { + + void nextBytes(byte[] bytes); +} diff --git a/src/main/java/com/example/bip39/entropy/SecureEntropyGenerator.java b/src/main/java/com/example/bip39/entropy/SecureEntropyGenerator.java new file mode 100644 index 0000000..582b5ee --- /dev/null +++ b/src/main/java/com/example/bip39/entropy/SecureEntropyGenerator.java @@ -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)); + } +} diff --git a/src/main/java/com/example/bip39/normalize/MnemonicInputNormalizer.java b/src/main/java/com/example/bip39/normalize/MnemonicInputNormalizer.java new file mode 100644 index 0000000..7afcea7 --- /dev/null +++ b/src/main/java/com/example/bip39/normalize/MnemonicInputNormalizer.java @@ -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(); + } +} diff --git a/src/test/java/com/example/bip39/api/ErrorPriorityTest.java b/src/test/java/com/example/bip39/api/ErrorPriorityTest.java new file mode 100644 index 0000000..25b90be --- /dev/null +++ b/src/test/java/com/example/bip39/api/ErrorPriorityTest.java @@ -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()); + } +} diff --git a/src/test/java/com/example/bip39/api/FixedFailureCasesTest.java b/src/test/java/com/example/bip39/api/FixedFailureCasesTest.java new file mode 100644 index 0000000..1cf40f1 --- /dev/null +++ b/src/test/java/com/example/bip39/api/FixedFailureCasesTest.java @@ -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()); + } +} diff --git a/src/test/java/com/example/bip39/entropy/SecureEntropyGeneratorTest.java b/src/test/java/com/example/bip39/entropy/SecureEntropyGeneratorTest.java new file mode 100644 index 0000000..aa1f802 --- /dev/null +++ b/src/test/java/com/example/bip39/entropy/SecureEntropyGeneratorTest.java @@ -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())); + } +} diff --git a/src/test/java/com/example/bip39/normalize/MnemonicInputNormalizerTest.java b/src/test/java/com/example/bip39/normalize/MnemonicInputNormalizerTest.java new file mode 100644 index 0000000..7c2ebd6 --- /dev/null +++ b/src/test/java/com/example/bip39/normalize/MnemonicInputNormalizerTest.java @@ -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()); + } +}