Skip to content

Commit 4449bc8

Browse files
committed
feat(cli): add CLI commands for mnemonic generation, validation, and normalization
1 parent 2b03d5f commit 4449bc8

6 files changed

Lines changed: 325 additions & 2 deletions

File tree

Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
MVNW := ./mvnw -B -ntp
44

5-
.PHONY: help test verify format lint clean
5+
.PHONY: help test verify format lint clean cli
66

77
help:
88
@printf "Available targets (requires Java 17):\n"
@@ -11,6 +11,7 @@ help:
1111
@printf " make format - Apply Spotless formatting\n"
1212
@printf " make lint - Run Spotless and SpotBugs checks\n"
1313
@printf " make clean - Remove build outputs\n"
14+
@printf " make cli - Build and run the CLI with ARGS=\"...\"\n"
1415

1516
test:
1617
$(MVNW) test
@@ -26,3 +27,7 @@ lint:
2627

2728
clean:
2829
$(MVNW) clean
30+
31+
cli:
32+
@$(MVNW) -q -DskipTests package >/dev/null
33+
@java -jar target/bip39-java-0.1.0-SNAPSHOT.jar $(ARGS)

pom.xml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<spotless.maven.plugin.version>2.46.1</spotless.maven.plugin.version>
2424
<spotbugs.maven.plugin.version>4.9.3.0</spotbugs.maven.plugin.version>
2525
<google.java.format.version>1.24.0</google.java.format.version>
26+
<maven.jar.plugin.version>3.4.2</maven.jar.plugin.version>
2627
</properties>
2728

2829
<dependencies>
@@ -76,6 +77,18 @@
7677
<release>${maven.compiler.release}</release>
7778
</configuration>
7879
</plugin>
80+
<plugin>
81+
<groupId>org.apache.maven.plugins</groupId>
82+
<artifactId>maven-jar-plugin</artifactId>
83+
<version>${maven.jar.plugin.version}</version>
84+
<configuration>
85+
<archive>
86+
<manifest>
87+
<mainClass>com.example.bip39.cli.Bip39Cli</mainClass>
88+
</manifest>
89+
</archive>
90+
</configuration>
91+
</plugin>
7992
<plugin>
8093
<groupId>org.apache.maven.plugins</groupId>
8194
<artifactId>maven-surefire-plugin</artifactId>
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package com.example.bip39.cli;
2+
3+
import com.example.bip39.api.Bip39Service;
4+
import com.example.bip39.api.DefaultBip39Service;
5+
import com.example.bip39.entropy.SecureEntropyGenerator;
6+
import com.example.bip39.error.Bip39Exception;
7+
import com.example.bip39.model.ValidationResult;
8+
import com.example.bip39.normalize.MnemonicInputNormalizer;
9+
import com.example.bip39.util.Bip39Constants;
10+
import java.io.PrintStream;
11+
import java.util.Arrays;
12+
import java.util.HexFormat;
13+
14+
public final class Bip39Cli {
15+
16+
private static final String MAIN_CLASS = "com.example.bip39.cli.Bip39Cli";
17+
18+
private final Bip39Service bip39Service;
19+
private final SecureEntropyGenerator entropyGenerator;
20+
21+
public Bip39Cli() {
22+
this(new DefaultBip39Service(), new SecureEntropyGenerator());
23+
}
24+
25+
Bip39Cli(Bip39Service bip39Service, SecureEntropyGenerator entropyGenerator) {
26+
this.bip39Service = bip39Service;
27+
this.entropyGenerator = entropyGenerator;
28+
}
29+
30+
public static void main(String[] args) {
31+
int exitCode = new Bip39Cli().run(args, System.out, System.err);
32+
if (exitCode != 0) {
33+
System.exit(exitCode);
34+
}
35+
}
36+
37+
int run(String[] args, PrintStream out, PrintStream err) {
38+
if (args.length == 0 || isHelpCommand(args[0])) {
39+
printUsage(out);
40+
return 0;
41+
}
42+
43+
try {
44+
return switch (args[0]) {
45+
case "generate" -> runGenerate(Arrays.copyOfRange(args, 1, args.length), out);
46+
case "to-entropy" -> runToEntropy(Arrays.copyOfRange(args, 1, args.length), out);
47+
case "to-seed" -> runToSeed(Arrays.copyOfRange(args, 1, args.length), out, err);
48+
case "validate" -> runValidate(Arrays.copyOfRange(args, 1, args.length), out);
49+
case "normalize" -> runNormalize(Arrays.copyOfRange(args, 1, args.length), out);
50+
default -> {
51+
err.println("Unknown command: " + args[0]);
52+
printUsage(err);
53+
yield 2;
54+
}
55+
};
56+
} catch (IllegalArgumentException exception) {
57+
err.println(exception.getMessage());
58+
return 2;
59+
} catch (Bip39Exception exception) {
60+
err.println("errorCode=" + exception.getErrorCode());
61+
err.println("message=" + exception.getMessage());
62+
return 1;
63+
}
64+
}
65+
66+
private int runGenerate(String[] args, PrintStream out) {
67+
int mnemonicWordCount = 12;
68+
boolean showEntropy = false;
69+
70+
for (int index = 0; index < args.length; index++) {
71+
String argument = args[index];
72+
if ("--words".equals(argument)) {
73+
if (index + 1 >= args.length) {
74+
throw new IllegalArgumentException("Missing value for --words");
75+
}
76+
mnemonicWordCount = Integer.parseInt(args[++index]);
77+
} else if ("--show-entropy".equals(argument)) {
78+
showEntropy = true;
79+
} else {
80+
throw new IllegalArgumentException("Unknown option for generate: " + argument);
81+
}
82+
}
83+
84+
int numBytes = Bip39Constants.entropyLengthBytesForMnemonicWordCount(mnemonicWordCount);
85+
byte[] entropy = entropyGenerator.generateEntropy(numBytes);
86+
String mnemonic = bip39Service.entropyToMnemonic(entropy);
87+
if (showEntropy) {
88+
out.println("entropy=" + HexFormat.of().formatHex(entropy));
89+
}
90+
out.println("mnemonic=" + mnemonic);
91+
return 0;
92+
}
93+
94+
private int runToEntropy(String[] args, PrintStream out) {
95+
String mnemonic = requireMnemonicArgument(args, "to-entropy");
96+
out.println(HexFormat.of().formatHex(bip39Service.mnemonicToEntropy(mnemonic)));
97+
return 0;
98+
}
99+
100+
private int runToSeed(String[] args, PrintStream out, PrintStream err) {
101+
String passphrase = "";
102+
int mnemonicStart = 0;
103+
104+
while (mnemonicStart < args.length && args[mnemonicStart].startsWith("--")) {
105+
String option = args[mnemonicStart];
106+
if (!"--passphrase".equals(option)) {
107+
throw new IllegalArgumentException("Unknown option for to-seed: " + option);
108+
}
109+
if (mnemonicStart + 1 >= args.length) {
110+
throw new IllegalArgumentException("Missing value for --passphrase");
111+
}
112+
passphrase = args[mnemonicStart + 1];
113+
mnemonicStart += 2;
114+
}
115+
116+
if (mnemonicStart >= args.length) {
117+
printUsage(err);
118+
throw new IllegalArgumentException("Missing mnemonic for to-seed");
119+
}
120+
121+
String mnemonic = joinArgs(Arrays.copyOfRange(args, mnemonicStart, args.length));
122+
out.println(HexFormat.of().formatHex(bip39Service.mnemonicToSeed(mnemonic, passphrase)));
123+
return 0;
124+
}
125+
126+
private int runValidate(String[] args, PrintStream out) {
127+
ValidationResult validationResult =
128+
bip39Service.validateMnemonic(requireMnemonicArgument(args, "validate"));
129+
130+
out.println("ok=" + validationResult.ok());
131+
out.println("errorCode=" + nullableValue(validationResult.errorCode()));
132+
out.println("normalizedMnemonic=" + nullableValue(validationResult.normalizedMnemonic()));
133+
out.println("wordCount=" + nullableValue(validationResult.wordCount()));
134+
out.println("invalidWord=" + nullableValue(validationResult.invalidWord()));
135+
return validationResult.ok() ? 0 : 1;
136+
}
137+
138+
private int runNormalize(String[] args, PrintStream out) {
139+
out.println(
140+
MnemonicInputNormalizer.normalizeMnemonicInput(requireMnemonicArgument(args, "normalize")));
141+
return 0;
142+
}
143+
144+
private static String requireMnemonicArgument(String[] args, String command) {
145+
if (args.length == 0) {
146+
throw new IllegalArgumentException("Missing mnemonic for " + command);
147+
}
148+
return joinArgs(args);
149+
}
150+
151+
private static String joinArgs(String[] args) {
152+
return String.join(" ", args);
153+
}
154+
155+
private static String nullableValue(Object value) {
156+
return value == null ? "null" : value.toString();
157+
}
158+
159+
private static boolean isHelpCommand(String command) {
160+
return "help".equals(command) || "--help".equals(command) || "-h".equals(command);
161+
}
162+
163+
private static void printUsage(PrintStream out) {
164+
out.println("Usage: java -jar target/bip39-java-0.1.0-SNAPSHOT.jar <command> [options]");
165+
out.println(
166+
" or: java -cp target/bip39-java-0.1.0-SNAPSHOT.jar " + MAIN_CLASS + " <command>");
167+
out.println();
168+
out.println("Commands:");
169+
out.println(" generate [--words N] [--show-entropy]");
170+
out.println(" to-entropy <mnemonic>");
171+
out.println(" to-seed [--passphrase TEXT] <mnemonic>");
172+
out.println(" validate <mnemonic>");
173+
out.println(" normalize <mnemonic>");
174+
out.println(" help");
175+
}
176+
}

src/main/java/com/example/bip39/entropy/SecureEntropyGenerator.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public SecureEntropyGenerator() {
1515
this(new SecureRandom()::nextBytes);
1616
}
1717

18-
SecureEntropyGenerator(EntropySource entropySource) {
18+
public SecureEntropyGenerator(EntropySource entropySource) {
1919
this.entropySource = Objects.requireNonNull(entropySource, "entropySource must not be null");
2020
}
2121

src/main/java/com/example/bip39/util/Bip39Constants.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ public static int mnemonicWordCount(int entropyLengthBytes) {
2929
return (entropyLengthBits + checksumLengthBits(entropyLengthBytes)) / 11;
3030
}
3131

32+
public static int entropyLengthBytesForMnemonicWordCount(int mnemonicWordCount) {
33+
return switch (mnemonicWordCount) {
34+
case 12 -> 16;
35+
case 15 -> 20;
36+
case 18 -> 24;
37+
case 21 -> 28;
38+
case 24 -> 32;
39+
default ->
40+
throw new IllegalArgumentException(
41+
"Unsupported mnemonic word count: " + mnemonicWordCount);
42+
};
43+
}
44+
3245
public static void validateEntropyLengthBytes(int entropyLengthBytes) {
3346
if (!isAllowedEntropyLengthBytes(entropyLengthBytes)) {
3447
throw new IllegalArgumentException("Unsupported entropy length bytes: " + entropyLengthBytes);
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package com.example.bip39.cli;
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.assertTrue;
6+
7+
import com.example.bip39.api.DefaultBip39Service;
8+
import com.example.bip39.entropy.SecureEntropyGenerator;
9+
import java.io.ByteArrayOutputStream;
10+
import java.io.PrintStream;
11+
import java.nio.charset.StandardCharsets;
12+
import java.util.Arrays;
13+
import org.junit.jupiter.api.Test;
14+
15+
class Bip39CliTest {
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+
private final Bip39Cli cli =
22+
new Bip39Cli(
23+
new DefaultBip39Service(),
24+
new SecureEntropyGenerator(bytes -> Arrays.fill(bytes, (byte) 0)));
25+
26+
@Test
27+
void generatePrintsMnemonicAndOptionalEntropy() {
28+
CommandResult result = run("generate", "--words", "12", "--show-entropy");
29+
30+
assertEquals(0, result.exitCode());
31+
assertTrue(result.stdout().contains("entropy=00000000000000000000000000000000"));
32+
assertTrue(result.stdout().contains("mnemonic=" + ZERO_ENTROPY_MNEMONIC));
33+
assertEquals("", result.stderr());
34+
}
35+
36+
@Test
37+
void toEntropyPrintsHex() {
38+
CommandResult result = run("to-entropy", ZERO_ENTROPY_MNEMONIC);
39+
40+
assertEquals(0, result.exitCode());
41+
assertEquals("00000000000000000000000000000000\n", result.stdout());
42+
}
43+
44+
@Test
45+
void toSeedPrintsSeedHex() {
46+
CommandResult result = run("to-seed", "--passphrase", "TREZOR", ZERO_ENTROPY_MNEMONIC);
47+
48+
assertEquals(0, result.exitCode());
49+
assertTrue(
50+
result
51+
.stdout()
52+
.contains("c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553"));
53+
}
54+
55+
@Test
56+
void validateReturnsStructuredFailureForInvalidMnemonic() {
57+
CommandResult result = run("validate", "abandon ability able");
58+
59+
assertEquals(1, result.exitCode());
60+
assertTrue(result.stdout().contains("ok=false"));
61+
assertTrue(result.stdout().contains("errorCode=ERR_INVALID_WORD_COUNT"));
62+
assertTrue(result.stdout().contains("wordCount=3"));
63+
}
64+
65+
@Test
66+
void normalizePrintsCompatibilityNormalizedMnemonic() {
67+
CommandResult result =
68+
run(
69+
"normalize",
70+
" ABANDON\tabandon\nABANDON\rabandon abandon\tabandon\nabandon\rabandon"
71+
+ " abandon\tabandon\nABANDON\rABOUT ");
72+
73+
assertEquals(0, result.exitCode());
74+
assertEquals(ZERO_ENTROPY_MNEMONIC + "\n", result.stdout());
75+
}
76+
77+
@Test
78+
void unknownCommandReturnsUsageError() {
79+
CommandResult result = run("wat");
80+
81+
assertEquals(2, result.exitCode());
82+
assertTrue(result.stderr().contains("Unknown command: wat"));
83+
assertTrue(result.stderr().contains("Commands:"));
84+
}
85+
86+
@Test
87+
void helpPrintsUsage() {
88+
CommandResult result = run("help");
89+
90+
assertEquals(0, result.exitCode());
91+
assertTrue(result.stdout().contains("generate [--words N] [--show-entropy]"));
92+
assertFalse(result.stdout().isEmpty());
93+
}
94+
95+
@Test
96+
void generateRejectsUnsupportedWordCount() {
97+
CommandResult result = run("generate", "--words", "13");
98+
99+
assertEquals(2, result.exitCode());
100+
assertTrue(result.stderr().contains("Unsupported mnemonic word count: 13"));
101+
}
102+
103+
private CommandResult run(String... args) {
104+
ByteArrayOutputStream stdout = new ByteArrayOutputStream();
105+
ByteArrayOutputStream stderr = new ByteArrayOutputStream();
106+
int exitCode =
107+
cli.run(
108+
args,
109+
new PrintStream(stdout, true, StandardCharsets.UTF_8),
110+
new PrintStream(stderr, true, StandardCharsets.UTF_8));
111+
return new CommandResult(
112+
exitCode, stdout.toString(StandardCharsets.UTF_8), stderr.toString(StandardCharsets.UTF_8));
113+
}
114+
115+
private record CommandResult(int exitCode, String stdout, String stderr) {}
116+
}

0 commit comments

Comments
 (0)