From 2b03d5fc608e929879dcbf1be546e264fbc807ca Mon Sep 17 00:00:00 2001 From: xt0x Date: Sat, 18 Apr 2026 14:19:59 +0900 Subject: [PATCH] feat(integration): implement MnemonicFlow and related classes for mnemonic processing and validation --- .../bip39/integration/Bip32SeedConsumer.java | 7 + .../bip39/integration/Bip39UiMessages.java | 38 +++++ .../bip39/integration/MnemonicFlow.java | 65 +++++++++ .../integration/PreparedMnemonicInput.java | 11 ++ .../bip39/integration/MnemonicFlowTest.java | 138 ++++++++++++++++++ 5 files changed, 259 insertions(+) create mode 100644 src/main/java/com/example/bip39/integration/Bip32SeedConsumer.java create mode 100644 src/main/java/com/example/bip39/integration/Bip39UiMessages.java create mode 100644 src/main/java/com/example/bip39/integration/MnemonicFlow.java create mode 100644 src/main/java/com/example/bip39/integration/PreparedMnemonicInput.java create mode 100644 src/test/java/com/example/bip39/integration/MnemonicFlowTest.java diff --git a/src/main/java/com/example/bip39/integration/Bip32SeedConsumer.java b/src/main/java/com/example/bip39/integration/Bip32SeedConsumer.java new file mode 100644 index 0000000..b100697 --- /dev/null +++ b/src/main/java/com/example/bip39/integration/Bip32SeedConsumer.java @@ -0,0 +1,7 @@ +package com.example.bip39.integration; + +@FunctionalInterface +public interface Bip32SeedConsumer { + + void accept(byte[] seed); +} diff --git a/src/main/java/com/example/bip39/integration/Bip39UiMessages.java b/src/main/java/com/example/bip39/integration/Bip39UiMessages.java new file mode 100644 index 0000000..4e1cbc1 --- /dev/null +++ b/src/main/java/com/example/bip39/integration/Bip39UiMessages.java @@ -0,0 +1,38 @@ +package com.example.bip39.integration; + +import com.example.bip39.error.Bip39ErrorCode; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; + +public final class Bip39UiMessages { + + private static final Map UI_MESSAGES = createUiMessages(); + + private Bip39UiMessages() {} + + public static String messageFor(Bip39ErrorCode errorCode) { + Objects.requireNonNull(errorCode, "errorCode must not be null"); + return UI_MESSAGES.get(errorCode); + } + + private static Map createUiMessages() { + EnumMap messages = new EnumMap<>(Bip39ErrorCode.class); + messages.put( + Bip39ErrorCode.ERR_ENTROPY_LENGTH, "Entropy length must be 16, 20, 24, 28, or 32 bytes."); + messages.put( + Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, "Enter words in normalized lowercase form."); + messages.put( + Bip39ErrorCode.ERR_INVALID_WORD_COUNT, + "Mnemonic word count must be 12, 15, 18, 21, or 24."); + messages.put( + Bip39ErrorCode.ERR_WORD_NOT_IN_LIST, + "Mnemonic contains a word outside the English BIP39 list."); + messages.put( + Bip39ErrorCode.ERR_CHECKSUM_MISMATCH, + "Mnemonic checksum does not match the words provided."); + messages.put( + Bip39ErrorCode.ERR_PBKDF2_FAILURE, "Seed derivation is unavailable in this runtime."); + return Map.copyOf(messages); + } +} diff --git a/src/main/java/com/example/bip39/integration/MnemonicFlow.java b/src/main/java/com/example/bip39/integration/MnemonicFlow.java new file mode 100644 index 0000000..84f4936 --- /dev/null +++ b/src/main/java/com/example/bip39/integration/MnemonicFlow.java @@ -0,0 +1,65 @@ +package com.example.bip39.integration; + +import com.example.bip39.api.Bip39Service; +import com.example.bip39.error.Bip39ErrorCode; +import com.example.bip39.error.Bip39Exception; +import com.example.bip39.model.ValidationResult; +import com.example.bip39.normalize.MnemonicInputNormalizer; +import java.util.Objects; + +public final class MnemonicFlow { + + private final Bip39Service bip39Service; + + public MnemonicFlow(Bip39Service bip39Service) { + this.bip39Service = Objects.requireNonNull(bip39Service, "bip39Service must not be null"); + } + + public PreparedMnemonicInput prepareInput(String rawMnemonic) { + Objects.requireNonNull(rawMnemonic, "rawMnemonic must not be null"); + return new PreparedMnemonicInput( + rawMnemonic, MnemonicInputNormalizer.normalizeMnemonicInput(rawMnemonic)); + } + + public ValidationResult validatePreparedInput(PreparedMnemonicInput preparedMnemonicInput) { + Objects.requireNonNull(preparedMnemonicInput, "preparedMnemonicInput must not be null"); + return bip39Service.validateMnemonic(preparedMnemonicInput.normalizedMnemonic()); + } + + public byte[] deriveSeed(PreparedMnemonicInput preparedMnemonicInput, String passphrase) { + Objects.requireNonNull(preparedMnemonicInput, "preparedMnemonicInput must not be null"); + return bip39Service.mnemonicToSeed( + preparedMnemonicInput.normalizedMnemonic(), requirePassphrase(passphrase)); + } + + public byte[] validateThenDeriveSeed( + PreparedMnemonicInput preparedMnemonicInput, String passphrase) { + ValidationResult validationResult = validatePreparedInput(preparedMnemonicInput); + if (!validationResult.ok()) { + throw validationFailure(validationResult.errorCode()); + } + return deriveSeed(preparedMnemonicInput, passphrase); + } + + public byte[] validateThenSendSeedToBip32( + String rawMnemonic, String passphrase, Bip32SeedConsumer bip32SeedConsumer) { + Objects.requireNonNull(bip32SeedConsumer, "bip32SeedConsumer must not be null"); + + PreparedMnemonicInput preparedMnemonicInput = prepareInput(rawMnemonic); + byte[] seed = validateThenDeriveSeed(preparedMnemonicInput, passphrase); + bip32SeedConsumer.accept(seed.clone()); + return seed; + } + + public String uiMessage(Bip39ErrorCode errorCode) { + return Bip39UiMessages.messageFor(errorCode); + } + + private static String requirePassphrase(String passphrase) { + return Objects.requireNonNull(passphrase, "passphrase must not be null"); + } + + private static Bip39Exception validationFailure(Bip39ErrorCode errorCode) { + return new Bip39Exception(errorCode, Bip39UiMessages.messageFor(errorCode)); + } +} diff --git a/src/main/java/com/example/bip39/integration/PreparedMnemonicInput.java b/src/main/java/com/example/bip39/integration/PreparedMnemonicInput.java new file mode 100644 index 0000000..862abd2 --- /dev/null +++ b/src/main/java/com/example/bip39/integration/PreparedMnemonicInput.java @@ -0,0 +1,11 @@ +package com.example.bip39.integration; + +import java.util.Objects; + +public record PreparedMnemonicInput(String originalInput, String normalizedMnemonic) { + + public PreparedMnemonicInput { + Objects.requireNonNull(originalInput, "originalInput must not be null"); + Objects.requireNonNull(normalizedMnemonic, "normalizedMnemonic must not be null"); + } +} diff --git a/src/test/java/com/example/bip39/integration/MnemonicFlowTest.java b/src/test/java/com/example/bip39/integration/MnemonicFlowTest.java new file mode 100644 index 0000000..565478c --- /dev/null +++ b/src/test/java/com/example/bip39/integration/MnemonicFlowTest.java @@ -0,0 +1,138 @@ +package com.example.bip39.integration; + +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.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.example.bip39.api.Bip39Service; +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 java.util.HexFormat; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class MnemonicFlowTest { + + private static final String RAW_ZERO_ENTROPY_MNEMONIC = + " ABANDON\tabandon\nABANDON\rabandon abandon\tabandon\nabandon\rabandon" + + " abandon\tabandon\nABANDON\rABOUT "; + + private static final String NORMALIZED_ZERO_ENTROPY_MNEMONIC = + "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 preparesUiInputWhilePreservingOriginalText() { + MnemonicFlow mnemonicFlow = new MnemonicFlow(new DefaultBip39Service()); + + PreparedMnemonicInput preparedMnemonicInput = + mnemonicFlow.prepareInput(RAW_ZERO_ENTROPY_MNEMONIC); + + assertEquals(RAW_ZERO_ENTROPY_MNEMONIC, preparedMnemonicInput.originalInput()); + assertEquals(NORMALIZED_ZERO_ENTROPY_MNEMONIC, preparedMnemonicInput.normalizedMnemonic()); + } + + @Test + void validatesNormalizedInputSeparatelyFromDerivation() { + MnemonicFlow mnemonicFlow = new MnemonicFlow(new DefaultBip39Service()); + + ValidationResult validationResult = + mnemonicFlow.validatePreparedInput(mnemonicFlow.prepareInput(RAW_ZERO_ENTROPY_MNEMONIC)); + + assertTrue(validationResult.ok()); + assertEquals(NORMALIZED_ZERO_ENTROPY_MNEMONIC, validationResult.normalizedMnemonic()); + } + + @Test + void validatesBeforeDerivingSeedWhenRequested() { + RecordingBip39Service bip39Service = new RecordingBip39Service(); + MnemonicFlow mnemonicFlow = new MnemonicFlow(bip39Service); + PreparedMnemonicInput preparedMnemonicInput = + new PreparedMnemonicInput("abandon typo", "abandon typo"); + + Bip39Exception exception = + assertThrows( + Bip39Exception.class, + () -> mnemonicFlow.validateThenDeriveSeed(preparedMnemonicInput, "TREZOR")); + + assertEquals(Bip39ErrorCode.ERR_INVALID_WORD_COUNT, exception.getErrorCode()); + assertFalse(bip39Service.mnemonicToSeedCalled.get()); + } + + @Test + void forwardsValidatedSeedToBip32Consumer() { + MnemonicFlow mnemonicFlow = new MnemonicFlow(new DefaultBip39Service()); + AtomicReference capturedSeed = new AtomicReference<>(); + + byte[] returnedSeed = + mnemonicFlow.validateThenSendSeedToBip32( + RAW_ZERO_ENTROPY_MNEMONIC, "TREZOR", capturedSeed::set); + + assertEquals(ZERO_ENTROPY_SEED_WITH_TREZOR, HexFormat.of().formatHex(returnedSeed)); + assertArrayEquals(returnedSeed, capturedSeed.get()); + assertNotSame(returnedSeed, capturedSeed.get()); + } + + @Test + void mapsErrorCodesToUiMessages() { + MnemonicFlow mnemonicFlow = new MnemonicFlow(new DefaultBip39Service()); + + assertEquals( + "Mnemonic contains a word outside the English BIP39 list.", + mnemonicFlow.uiMessage(Bip39ErrorCode.ERR_WORD_NOT_IN_LIST)); + assertEquals( + "Mnemonic checksum does not match the words provided.", + mnemonicFlow.uiMessage(Bip39ErrorCode.ERR_CHECKSUM_MISMATCH)); + } + + private static final class RecordingBip39Service implements Bip39Service { + + private final AtomicBoolean mnemonicToSeedCalled = new AtomicBoolean(false); + + @Override + public String entropyToMnemonic(byte[] entropy) { + throw new UnsupportedOperationException(); + } + + @Override + public byte[] mnemonicToEntropy(String mnemonic) { + throw new UnsupportedOperationException(); + } + + @Override + public byte[] mnemonicToEntropy(java.util.List words) { + throw new UnsupportedOperationException(); + } + + @Override + public ValidationResult validateMnemonic(String mnemonic) { + return ValidationResult.failure(Bip39ErrorCode.ERR_INVALID_WORD_COUNT, mnemonic, 2, null); + } + + @Override + public ValidationResult validateMnemonic(java.util.List words) { + throw new UnsupportedOperationException(); + } + + @Override + public byte[] mnemonicToSeed(String mnemonic, String passphrase) { + mnemonicToSeedCalled.set(true); + return new byte[64]; + } + + @Override + public byte[] mnemonicToSeed(java.util.List words, String passphrase) { + throw new UnsupportedOperationException(); + } + } +}