feat(license-minter): add LicenseMinterCli (without --verify)

Reads PEM or base64 PKCS#8 Ed25519 private key, maps --max-foo-bar
flags to max_foo_bar limit keys, parses --expires as a UTC date,
defaults --grace-days to 0. Unknown flags fail fast with exit 2.
--verify path is added in the next task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 10:56:02 +02:00
parent 1ae5a1a27e
commit 7300424a49
2 changed files with 170 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
package com.cameleer.license.minter.cli;
import com.cameleer.license.minter.LicenseMinter;
import com.cameleer.server.core.license.LicenseInfo;
import java.io.PrintStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
public final class LicenseMinterCli {
private static final Set<String> KNOWN_FLAGS = Set.of(
"--private-key", "--public-key", "--tenant", "--label",
"--expires", "--grace-days", "--output", "--verify"
);
public static void main(String[] args) {
System.exit(run(args));
}
public static int run(String[] args) {
return run(args, System.out, System.err);
}
public static int run(String[] args, PrintStream out, PrintStream err) {
Map<String, String> flags = new LinkedHashMap<>();
Set<String> bool = new HashSet<>();
Map<String, Integer> limits = new TreeMap<>();
for (String arg : args) {
if (!arg.startsWith("--")) {
err.println("unexpected positional argument: " + arg);
return 2;
}
int eq = arg.indexOf('=');
String key = eq < 0 ? arg : arg.substring(0, eq);
String value = eq < 0 ? null : arg.substring(eq + 1);
if (key.startsWith("--max-")) {
String limitKey = "max_" + key.substring("--max-".length()).replace('-', '_');
if (value == null) {
err.println("missing value for " + key);
return 2;
}
limits.put(limitKey, Integer.parseInt(value));
continue;
}
if (!KNOWN_FLAGS.contains(key)) {
err.println("unknown flag: " + key);
return 2;
}
if (value == null) {
bool.add(key);
} else {
flags.put(key, value);
}
}
String privPath = flags.get("--private-key");
String tenant = flags.get("--tenant");
String expiresIso = flags.get("--expires");
if (privPath == null || tenant == null || expiresIso == null) {
err.println("required: --private-key --tenant --expires");
return 2;
}
try {
PrivateKey privateKey = readEd25519PrivateKey(Path.of(privPath));
int graceDays = Integer.parseInt(flags.getOrDefault("--grace-days", "0"));
Instant exp = LocalDate.parse(expiresIso).atStartOfDay(ZoneOffset.UTC).toInstant();
LicenseInfo info = new LicenseInfo(
UUID.randomUUID(),
tenant,
flags.get("--label"),
Collections.unmodifiableMap(limits),
Instant.now(),
exp,
graceDays
);
String token = LicenseMinter.mint(info, privateKey);
String outPath = flags.get("--output");
if (outPath != null) {
Files.writeString(Path.of(outPath), token);
out.println("wrote " + outPath);
} else {
out.println(token);
}
return 0;
} catch (Exception e) {
err.println("ERROR: " + e.getMessage());
return 1;
}
}
private static PrivateKey readEd25519PrivateKey(Path path) throws Exception {
String s = Files.readString(path).trim();
if (s.startsWith("-----BEGIN")) {
s = s.replaceAll("-----BEGIN [A-Z ]+-----", "")
.replaceAll("-----END [A-Z ]+-----", "")
.replaceAll("\\s", "");
}
byte[] der = Base64.getDecoder().decode(s);
return KeyFactory.getInstance("Ed25519")
.generatePrivate(new PKCS8EncodedKeySpec(der));
}
}

View File

@@ -0,0 +1,51 @@
package com.cameleer.license.minter.cli;
import com.cameleer.server.core.license.LicenseValidator;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Base64;
import static org.assertj.core.api.Assertions.assertThat;
class LicenseMinterCliTest {
@TempDir Path tmp;
@Test
void mints_validToken_validatorAccepts() throws Exception {
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
Path priv = tmp.resolve("priv.b64");
Path pub = tmp.resolve("pub.b64");
Files.writeString(priv, Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded()));
Files.writeString(pub, Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()));
Path out = tmp.resolve("license.tok");
int code = LicenseMinterCli.run(new String[]{
"--private-key=" + priv,
"--tenant=acme",
"--label=ACME",
"--expires=2099-12-31",
"--grace-days=30",
"--max-apps=50",
"--output=" + out
});
assertThat(code).isEqualTo(0);
String token = Files.readString(out).trim();
var info = new LicenseValidator(Files.readString(pub).trim(), "acme").validate(token);
assertThat(info.tenantId()).isEqualTo("acme");
assertThat(info.limits().get("max_apps")).isEqualTo(50);
assertThat(info.gracePeriodDays()).isEqualTo(30);
}
@Test
void unknownFlag_failsFast() {
int code = LicenseMinterCli.run(new String[]{"--frobnicate=yes"});
assertThat(code).isNotZero();
}
}