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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.bip39.integration;

@FunctionalInterface
public interface Bip32SeedConsumer {

void accept(byte[] seed);
}
38 changes: 38 additions & 0 deletions src/main/java/com/example/bip39/integration/Bip39UiMessages.java
Original file line number Diff line number Diff line change
@@ -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<Bip39ErrorCode, String> 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<Bip39ErrorCode, String> createUiMessages() {
EnumMap<Bip39ErrorCode, String> 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);
}
}
65 changes: 65 additions & 0 deletions src/main/java/com/example/bip39/integration/MnemonicFlow.java
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
138 changes: 138 additions & 0 deletions src/test/java/com/example/bip39/integration/MnemonicFlowTest.java
Original file line number Diff line number Diff line change
@@ -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<byte[]> 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<String> 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<String> 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<String> words, String passphrase) {
throw new UnsupportedOperationException();
}
}
}
Loading