diff --git a/license-generator/pom.xml b/license-generator/pom.xml
index 1187244..01c1537 100644
--- a/license-generator/pom.xml
+++ b/license-generator/pom.xml
@@ -24,6 +24,7 @@
5.13.4
3.5.3
0.8.13
+ 1.2.1
@@ -83,6 +84,13 @@
test
+
+ com.github.stefanbirkner
+ system-lambda
+ ${system-lambda.version}
+ test
+
+
diff --git a/license-generator/src/main/java/io/github/bsayli/license/cli/Ed25519KeygenCli.java b/license-generator/src/main/java/io/github/bsayli/license/cli/Ed25519KeygenCli.java
index a01c6bc..0bbd8a9 100644
--- a/license-generator/src/main/java/io/github/bsayli/license/cli/Ed25519KeygenCli.java
+++ b/license-generator/src/main/java/io/github/bsayli/license/cli/Ed25519KeygenCli.java
@@ -1,48 +1,47 @@
package io.github.bsayli.license.cli;
-import java.io.IOException;
-import java.nio.file.Files;
+import io.github.bsayli.license.cli.service.Ed25519KeyService;
import java.nio.file.Path;
-import java.security.*;
-import java.security.spec.ECGenParameterSpec;
-import java.util.Base64;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class Ed25519KeygenCli {
+ static final int EXIT_OK = 0;
+ static final int EXIT_USAGE = 2;
+ static final int EXIT_ERROR = 3;
private static final Logger log = LoggerFactory.getLogger(Ed25519KeygenCli.class);
-
- private static final String ED25519_STD_ALGO = "Ed25519";
- private static final String EDDSA_BC_ALGO = "EdDSA";
- private static final String ED25519_CURVE = "Ed25519";
- private static final String BC_PROVIDER = "BC";
+ private static final String ARG_OUT_PRIVATE = "--outPrivate";
+ private static final String ARG_OUT_PUBLIC = "--outPublic";
+ private static final String ARG_HELP_LONG = "--help";
+ private static final String ARG_HELP_SHORT = "-h";
private Ed25519KeygenCli() {}
public static void main(String[] args) {
- String outPriv = readOpt(args, "--outPrivate").orElse(null);
- String outPub = readOpt(args, "--outPublic").orElse(null);
+ System.exit(run(args));
+ }
+ static int run(String[] args) {
try {
- ensureBouncyCastleIfNeeded();
+ if (hasFlag(args, ARG_HELP_LONG) || hasFlag(args, ARG_HELP_SHORT)) {
+ printUsage();
+ return EXIT_OK;
+ }
- KeyPair kp = generateEd25519KeyPair();
+ String outPriv = readOpt(args, ARG_OUT_PRIVATE).orElse(null);
+ String outPub = readOpt(args, ARG_OUT_PUBLIC).orElse(null);
- String privatePkcs8B64 = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
- String publicSpkiB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
+ var service = new Ed25519KeyService();
+ var keys = service.generate();
- if (outPriv != null) {
- writeString(Path.of(outPriv), privatePkcs8B64);
- }
- if (outPub != null) {
- writeString(Path.of(outPub), publicSpkiB64);
- }
+ if (outPub != null) service.writeString(Path.of(outPub), keys.publicSpkiB64());
+ if (outPriv != null) service.writeString(Path.of(outPriv), keys.privatePkcs8B64());
log.info("=== Ed25519 Key Pair (Base64) ===");
- log.info("Public (SPKI, X.509): {}", publicSpkiB64);
- log.info("Private (PKCS#8) : {}", privatePkcs8B64);
+ log.info("Public (SPKI, X.509): {}", keys.publicSpkiB64());
+ log.info("Private (PKCS#8) : {}", keys.privatePkcs8B64());
if (outPriv != null || outPub != null) {
log.info("Written:");
@@ -50,59 +49,42 @@ public static void main(String[] args) {
if (outPriv != null) log.info(" {} (private, PKCS#8)", outPriv);
}
- log.info("");
- log.info("Use with SignatureCli (sign): --privateKey ");
- log.info(
- "Use with SignatureValidator (verify): pass SPKI public key Base64 to its constructor.");
-
- System.exit(0);
+ return EXIT_OK;
+ } catch (IllegalArgumentException e) {
+ log.error(e.getMessage());
+ printUsage();
+ return EXIT_USAGE;
} catch (Exception e) {
- log.error("Key generation error: {}", e.getMessage(), e);
- System.exit(2);
+ log.error("Key generation error", e);
+ return EXIT_ERROR;
}
}
- private static KeyPair generateEd25519KeyPair() throws GeneralSecurityException {
- try {
- KeyPairGenerator kpg = KeyPairGenerator.getInstance(ED25519_STD_ALGO);
- return kpg.generateKeyPair();
- } catch (NoSuchAlgorithmException e) {
- KeyPairGenerator kpg = KeyPairGenerator.getInstance(EDDSA_BC_ALGO, BC_PROVIDER);
- kpg.initialize(new ECGenParameterSpec(ED25519_CURVE), new SecureRandom());
- return kpg.generateKeyPair();
- }
- }
-
- private static void ensureBouncyCastleIfNeeded() {
- if (Security.getProvider(BC_PROVIDER) == null) {
- try {
- Class> bc = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider");
- Provider p = (Provider) bc.getDeclaredConstructor().newInstance();
- Security.addProvider(p);
- log.debug("BouncyCastle provider added.");
- } catch (Exception ignored) {
- log.debug("BouncyCastle not on classpath; will use JDK provider if available.");
- }
- }
- }
-
- private static Optional readOpt(String[] argv, String name) {
+ static Optional readOpt(String[] argv, String name) {
for (int i = 0; i < argv.length; i++) {
if (name.equals(argv[i]) && i + 1 < argv.length) {
String v = argv[i + 1];
- if (v != null && !v.startsWith("--") && !v.startsWith("-")) {
- return Optional.of(v);
- }
+ if (v != null && !v.startsWith("--") && !v.startsWith("-")) return Optional.of(v);
}
}
return Optional.empty();
}
- private static void writeString(Path path, String content) throws IOException {
- Path parent = path.toAbsolutePath().getParent();
- if (parent != null) {
- Files.createDirectories(parent);
- }
- Files.writeString(path, content);
+ private static boolean hasFlag(String[] argv, String flag) {
+ for (String s : argv) if (flag.equals(s)) return true;
+ return false;
+ }
+
+ private static void printUsage() {
+ log.info(
+ """
+ Usage:
+ java -cp license-generator.jar io.github.bsayli.license.cli.Ed25519KeygenCli [--outPublic ] [--outPrivate ] [--help]
+
+ Options:
+ --outPublic Write Base64 SPKI public key to file
+ --outPrivate Write Base64 PKCS#8 private key to file
+ --help, -h Show this help
+ """);
}
}
diff --git a/license-generator/src/main/java/io/github/bsayli/license/cli/EncryptUserIdCli.java b/license-generator/src/main/java/io/github/bsayli/license/cli/EncryptUserIdCli.java
index 2b02c29..17953f4 100644
--- a/license-generator/src/main/java/io/github/bsayli/license/cli/EncryptUserIdCli.java
+++ b/license-generator/src/main/java/io/github/bsayli/license/cli/EncryptUserIdCli.java
@@ -1,6 +1,6 @@
package io.github.bsayli.license.cli;
-import io.github.bsayli.license.licensekey.encrypter.UserIdEncrypter;
+import io.github.bsayli.license.cli.service.UserIdCryptoService;
import java.security.GeneralSecurityException;
import java.util.Arrays;
import java.util.List;
@@ -10,133 +10,125 @@
public final class EncryptUserIdCli {
+ static final int EXIT_OK = 0;
+ static final int EXIT_USAGE = 2;
+ static final int EXIT_CRYPTO = 3;
private static final Logger log = LoggerFactory.getLogger(EncryptUserIdCli.class);
-
private static final String CMD_ENCRYPT = "encrypt";
private static final String CMD_DECRYPT = "decrypt";
-
private static final String ARG_USER_ID = "--userId";
private static final String ARG_CIPHERTEXT = "--ciphertext";
private static final String ARG_HELP_LONG = "--help";
private static final String ARG_HELP_SHORT = "-h";
- private static final int EXIT_OK = 0;
- private static final int EXIT_USAGE = 2;
- private static final int EXIT_CRYPTO = 3;
-
- private static final String MSG_MISSING_CMD = "Missing command: encrypt | decrypt";
- private static final String MSG_MISSING_USER_ID = "Missing --userId for encrypt";
- private static final String MSG_BLANK_USER_ID = "--userId must not be blank";
- private static final String MSG_MISSING_CIPHERTEXT = "Missing --ciphertext for decrypt";
- private static final String MSG_BLANK_CIPHERTEXT = "--ciphertext must not be blank";
-
private EncryptUserIdCli() {}
public static void main(String[] args) {
- List argv = Arrays.asList(args);
+ System.exit(run(args));
+ }
+
+ static int run(String[] args) {
+ final List argv = Arrays.asList(args);
if (argv.isEmpty() || argv.contains(ARG_HELP_LONG) || argv.contains(ARG_HELP_SHORT)) {
- printUsage(argv.isEmpty() ? EXIT_USAGE : EXIT_OK);
+ printUsage();
+ return argv.isEmpty() ? EXIT_USAGE : EXIT_OK;
}
- String cmd = argv.getFirst();
- switch (cmd) {
- case CMD_ENCRYPT -> runEncrypt(argv);
- case CMD_DECRYPT -> runDecrypt(argv);
+ final String cmd = argv.getFirst();
+ return switch (cmd) {
+ case CMD_ENCRYPT ->
+ runWithArg(
+ argv,
+ ARG_USER_ID,
+ "Missing --userId for encrypt",
+ "--userId must not be blank",
+ UserIdCryptoService::encrypt,
+ "Encrypted userId: {}");
+ case CMD_DECRYPT ->
+ runWithArg(
+ argv,
+ ARG_CIPHERTEXT,
+ "Missing --ciphertext for decrypt",
+ "--ciphertext must not be blank",
+ UserIdCryptoService::decrypt,
+ "Decrypted userId: {}");
default -> {
- log.error(MSG_MISSING_CMD);
- printUsage(EXIT_USAGE);
+ log.error("Missing command: encrypt | decrypt");
+ printUsage();
+ yield EXIT_USAGE;
}
- }
+ };
}
- private static void runEncrypt(List argv) {
- String userId =
- readOptionValue(argv, ARG_USER_ID)
- .orElseGet(
- () -> {
- log.error(MSG_MISSING_USER_ID);
- printUsage(EXIT_USAGE);
- return null;
- });
-
- if (userId == null || userId.isBlank()) {
- log.error(MSG_BLANK_USER_ID);
- printUsage(EXIT_USAGE);
- }
-
- try {
- String encrypted = UserIdEncrypter.encrypt(userId);
- log.info("Encrypted userId: {}", encrypted);
- System.exit(EXIT_OK);
- } catch (GeneralSecurityException e) {
- log.error("Encryption error: {}", e.getMessage(), e);
- System.exit(EXIT_CRYPTO);
- } catch (RuntimeException e) {
- log.error("Unexpected error: {}", e.getMessage(), e);
- System.exit(EXIT_CRYPTO);
+ private static int runWithArg(
+ List argv,
+ String requiredFlag,
+ String missingMsg,
+ String blankMsg,
+ CryptoOp op,
+ String successLogPattern) {
+
+ String value = readOptionValue(argv, requiredFlag).orElse(null);
+ if (value == null) {
+ log.error(missingMsg);
+ printUsage();
+ return EXIT_USAGE;
}
- }
-
- private static void runDecrypt(List argv) {
- String ciphertext =
- readOptionValue(argv, ARG_CIPHERTEXT)
- .orElseGet(
- () -> {
- log.error(MSG_MISSING_CIPHERTEXT);
- printUsage(EXIT_USAGE);
- return null;
- });
-
- if (ciphertext == null || ciphertext.isBlank()) {
- log.error(MSG_BLANK_CIPHERTEXT);
- printUsage(EXIT_USAGE);
+ if (value.isBlank()) {
+ log.error(blankMsg);
+ printUsage();
+ return EXIT_USAGE;
}
try {
- String plain = UserIdEncrypter.decrypt(ciphertext);
- log.info("Decrypted userId: {}", plain);
- System.exit(EXIT_OK);
+ var svc = new UserIdCryptoService();
+ String result = op.apply(svc, value);
+ log.info(successLogPattern, result);
+ return EXIT_OK;
} catch (GeneralSecurityException e) {
- log.error("Decryption error: {}", e.getMessage(), e);
- System.exit(EXIT_CRYPTO);
+ log.error("Crypto error: {}", e.getMessage(), e);
+ return EXIT_CRYPTO;
} catch (IllegalArgumentException e) {
- log.error("Invalid Base64 ciphertext: {}", e.getMessage(), e);
- System.exit(EXIT_CRYPTO);
+ log.error("Invalid input: {}", e.getMessage(), e);
+ return EXIT_CRYPTO;
} catch (RuntimeException e) {
log.error("Unexpected error: {}", e.getMessage(), e);
- System.exit(EXIT_CRYPTO);
+ return EXIT_CRYPTO;
}
}
- private static Optional readOptionValue(List argv, String name) {
+ static Optional readOptionValue(List argv, String name) {
int idx = argv.indexOf(name);
if (idx < 0) return Optional.empty();
int valIdx = idx + 1;
if (valIdx >= argv.size()) return Optional.empty();
String val = argv.get(valIdx);
- if (val != null && !val.startsWith("--") && !val.startsWith("-")) {
- return Optional.of(val);
- }
- return Optional.empty();
+ return (val != null && !val.startsWith("--") && !val.startsWith("-"))
+ ? Optional.of(val)
+ : Optional.empty();
}
- private static void printUsage(int exitCode) {
+ private static void printUsage() {
log.info(
"""
- Usage:
- java -cp license-generator.jar io.github.bsayli.license.cli.EncryptUserIdCli encrypt --userId
- java -cp license-generator.jar io.github.bsayli.license.cli.EncryptUserIdCli decrypt --ciphertext
-
- Commands:
- encrypt Encrypt a Keycloak user UUID (AES-GCM, Base64 output)
- decrypt Decrypt a previously encrypted Base64 payload
-
- Options:
- --userId Required for 'encrypt'
- --ciphertext Required for 'decrypt'
- --help, -h Show this help
- """);
- System.exit(exitCode);
+ Usage:
+ java -cp license-generator.jar io.github.bsayli.license.cli.EncryptUserIdCli encrypt --userId
+ java -cp license-generator.jar io.github.bsayli.license.cli.EncryptUserIdCli decrypt --ciphertext
+
+ Commands:
+ encrypt Encrypt a Keycloak user UUID (AES-GCM, Base64 output)
+ decrypt Decrypt a previously encrypted Base64 payload
+
+ Options:
+ --userId Required for 'encrypt'
+ --ciphertext Required for 'decrypt'
+ --help, -h Show this help
+ """);
+ }
+
+ @FunctionalInterface
+ private interface CryptoOp {
+ String apply(UserIdCryptoService svc, String value) throws GeneralSecurityException;
}
}
diff --git a/license-generator/src/main/java/io/github/bsayli/license/cli/KeygenCli.java b/license-generator/src/main/java/io/github/bsayli/license/cli/KeygenCli.java
index 9273acc..fe52712 100644
--- a/license-generator/src/main/java/io/github/bsayli/license/cli/KeygenCli.java
+++ b/license-generator/src/main/java/io/github/bsayli/license/cli/KeygenCli.java
@@ -1,73 +1,68 @@
package io.github.bsayli.license.cli;
-import io.github.bsayli.license.common.CryptoUtils;
-import io.github.bsayli.license.securekey.generator.SecureEdDSAKeyPairGenerator;
-import io.github.bsayli.license.securekey.generator.SecureKeyGenerator;
-import java.security.GeneralSecurityException;
-import java.security.KeyPair;
+import io.github.bsayli.license.cli.service.KeygenService;
import java.util.*;
-import javax.crypto.SecretKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class KeygenCli {
+ static final int EXIT_OK = 0;
+ static final int EXIT_USAGE = 2;
+ static final int EXIT_ERROR = 3;
private static final Logger log = LoggerFactory.getLogger(KeygenCli.class);
-
- private static final String ARG_MODE = "--mode"; // aes | ed25519
+ private static final String ARG_MODE = "--mode";
private static final String MODE_AES = "aes";
private static final String MODE_ED25519 = "ed25519";
-
- private static final String ARG_SIZE = "--size"; // for AES: 128|192|256
-
+ private static final String ARG_SIZE = "--size";
private static final String ARG_HELP_LONG = "--help";
private static final String ARG_HELP_SHORT = "-h";
- private static final int EXIT_OK = 0;
- private static final int EXIT_USAGE = 2;
- private static final int EXIT_ERROR = 3;
-
private KeygenCli() {}
public static void main(String[] args) {
+ System.exit(run(args));
+ }
+
+ static int run(String[] args) {
List argv = Arrays.asList(args);
if (argv.contains(ARG_HELP_LONG) || argv.contains(ARG_HELP_SHORT)) {
- printUsage(EXIT_OK);
+ printUsage();
+ return EXIT_OK;
}
String mode = read(ARG_MODE, argv).orElse(null);
- if (mode == null
- || (!MODE_AES.equalsIgnoreCase(mode) && !MODE_ED25519.equalsIgnoreCase(mode))) {
+ if (!MODE_AES.equalsIgnoreCase(mode) && !MODE_ED25519.equalsIgnoreCase(mode)) {
log.error("Missing or invalid --mode (expected: aes | ed25519)");
- printUsage(EXIT_USAGE);
+ printUsage();
+ return EXIT_USAGE;
}
try {
+ var service = new KeygenService();
+
if (MODE_AES.equalsIgnoreCase(mode)) {
- runAes(argv);
+ int size = parseAesSize(read(ARG_SIZE, argv).orElse("256"));
+ var out = service.generateAes(size);
+ log.info("AES-{} SecretKey (Base64): {}", out.sizeBits(), out.base64());
} else {
- runEd25519();
+ var pair = service.generateEd25519();
+ log.info("Ed25519 PublicKey (Base64): {}", pair.publicSpkiB64());
+ log.info("Ed25519 PrivateKey (Base64): {}", pair.privatePkcs8B64());
}
- System.exit(EXIT_OK);
+
+ return EXIT_OK;
+ } catch (IllegalArgumentException e) {
+ log.error(e.getMessage());
+ printUsage();
+ return EXIT_USAGE;
} catch (Exception e) {
log.error("Key generation error: {}", e.getMessage(), e);
- System.exit(EXIT_ERROR);
+ return EXIT_ERROR;
}
}
- private static void runAes(List argv) throws Exception {
- int size = parseAesSize(read(ARG_SIZE, argv).orElse("256"));
- SecretKey key = SecureKeyGenerator.generateAesKey(size);
- log.info("AES-{} SecretKey (Base64): {}", size, CryptoUtils.toBase64(key));
- }
-
- private static void runEd25519() throws GeneralSecurityException {
- KeyPair kp = SecureEdDSAKeyPairGenerator.generateKeyPair();
- log.info("Ed25519 PublicKey (Base64): {}", CryptoUtils.toBase64(kp.getPublic()));
- log.info("Ed25519 PrivateKey (Base64): {}", CryptoUtils.toBase64(kp.getPrivate()));
- }
-
private static int parseAesSize(String raw) {
try {
int v = Integer.parseInt(raw.trim());
@@ -78,34 +73,25 @@ private static int parseAesSize(String raw) {
}
private static Optional read(String name, List argv) {
- int idx = argv.indexOf(name);
- if (idx < 0) return Optional.empty();
- int valIdx = idx + 1;
- if (valIdx >= argv.size()) return Optional.empty();
- String val = argv.get(valIdx);
- if (val != null && !val.startsWith("--") && !val.startsWith("-")) {
- return Optional.of(val);
- }
- return Optional.empty();
+ return EncryptUserIdCli.readOptionValue(argv, name);
}
- private static void printUsage(int exitCode) {
+ private static void printUsage() {
log.info(
"""
- Usage:
- # AES secret key (Base64)
- java -cp license-generator.jar io.github.bsayli.license.cli.KeygenCli \\
- --mode aes --size 256
-
- # Ed25519 key pair (Base64)
- java -cp license-generator.jar io.github.bsayli.license.cli.KeygenCli \\
- --mode ed25519
-
- Options:
- --mode aes|ed25519
- --size 128|192|256 (AES only, default: 256)
- --help, -h
- """);
- System.exit(exitCode);
+ Usage:
+ # AES secret key (Base64)
+ java -cp license-generator.jar io.github.bsayli.license.cli.KeygenCli \\
+ --mode aes --size 256
+
+ # Ed25519 key pair (Base64)
+ java -cp license-generator.jar io.github.bsayli.license.cli.KeygenCli \\
+ --mode ed25519
+
+ Options:
+ --mode aes|ed25519
+ --size 128|192|256 (AES only, default: 256)
+ --help, -h
+ """);
}
}
diff --git a/license-generator/src/main/java/io/github/bsayli/license/cli/LicenseKeyGeneratorCli.java b/license-generator/src/main/java/io/github/bsayli/license/cli/LicenseKeyGeneratorCli.java
index 62f3cf1..aeb0af4 100644
--- a/license-generator/src/main/java/io/github/bsayli/license/cli/LicenseKeyGeneratorCli.java
+++ b/license-generator/src/main/java/io/github/bsayli/license/cli/LicenseKeyGeneratorCli.java
@@ -1,8 +1,6 @@
package io.github.bsayli.license.cli;
-import io.github.bsayli.license.licensekey.encrypter.UserIdEncrypter;
-import io.github.bsayli.license.licensekey.generator.LicenseKeyGenerator;
-import io.github.bsayli.license.licensekey.model.LicenseKeyData;
+import io.github.bsayli.license.cli.service.LicenseKeyService;
import java.security.GeneralSecurityException;
import java.util.Arrays;
import java.util.List;
@@ -12,64 +10,59 @@
public final class LicenseKeyGeneratorCli {
+ static final int EXIT_OK = 0;
+ static final int EXIT_USAGE = 2;
+ static final int EXIT_CRYPTO = 3;
private static final Logger log = LoggerFactory.getLogger(LicenseKeyGeneratorCli.class);
-
private static final String ARG_USER_ID = "--userId";
private static final String ARG_PRINT_SEGMENTS = "--printSegments";
private static final String ARG_HELP_LONG = "--help";
private static final String ARG_HELP_SHORT = "-h";
-
- private static final int EXIT_OK = 0;
- private static final int EXIT_USAGE = 2;
- private static final int EXIT_CRYPTO = 3;
-
private static final String MSG_MISSING_USER_ID = "Missing --userId ";
private static final String MSG_BLANK_USER_ID = "--userId must not be blank";
private LicenseKeyGeneratorCli() {}
public static void main(String[] args) {
- List argv = Arrays.asList(args);
+ System.exit(run(args));
+ }
+
+ static int run(String[] args) {
+ final List argv = Arrays.asList(args);
if (argv.contains(ARG_HELP_LONG) || argv.contains(ARG_HELP_SHORT)) {
- printUsage(EXIT_OK);
+ printUsage();
+ return EXIT_OK;
}
- String userId =
- readOptionValue(argv, ARG_USER_ID)
- .orElseThrow(
- () -> {
- log.error(MSG_MISSING_USER_ID);
- printUsage(EXIT_USAGE);
- return new IllegalStateException(MSG_MISSING_USER_ID);
- });
-
+ String userId = readOptionValue(argv, ARG_USER_ID).orElse(null);
+ if (userId == null) {
+ log.error(MSG_MISSING_USER_ID);
+ printUsage();
+ return EXIT_USAGE;
+ }
if (userId.isBlank()) {
log.error(MSG_BLANK_USER_ID);
- printUsage(EXIT_USAGE);
+ printUsage();
+ return EXIT_USAGE;
}
try {
- // 1) Encrypt Keycloak user UUID
- String encryptedUserId = UserIdEncrypter.encrypt(userId);
-
- // 2) Build license key (PREFIX ~ random ~ encryptedUserId)
- LicenseKeyData licenseKeyData = LicenseKeyGenerator.generateLicenseKey(encryptedUserId);
- String licenseKey = licenseKeyData.generateLicenseKey();
+ var svc = new LicenseKeyService();
+ var out = svc.generate(userId);
- // 3) Output
- log.info("License Key: {}", licenseKey);
+ log.info("License Key: {}", out.licenseKey());
- if (hasFlag(argv, ARG_PRINT_SEGMENTS)) {
- log.info(" prefix : {}", licenseKeyData.prefix());
- log.info(" randomString : {}", licenseKeyData.randomString());
- log.info(" encryptedUserId: {}", licenseKeyData.uuid());
+ if (argv.contains(ARG_PRINT_SEGMENTS)) {
+ log.info(" prefix : {}", out.prefix());
+ log.info(" randomString : {}", out.randomString());
+ log.info(" encryptedUserId: {}", out.encryptedUserId());
}
- System.exit(EXIT_OK);
+ return EXIT_OK;
} catch (GeneralSecurityException e) {
log.error("License key generation failed: {}", e.getMessage(), e);
- System.exit(EXIT_CRYPTO);
+ return EXIT_CRYPTO;
}
}
@@ -79,27 +72,20 @@ private static Optional readOptionValue(List argv, String name)
int valIdx = idx + 1;
if (valIdx >= argv.size()) return Optional.empty();
String val = argv.get(valIdx);
- if (val != null && !val.startsWith("--") && !val.startsWith("-")) {
- return Optional.of(val);
- }
+ if (val != null && !val.startsWith("--") && !val.startsWith("-")) return Optional.of(val);
return Optional.empty();
}
- private static boolean hasFlag(List argv, String flag) {
- return argv.contains(flag);
- }
-
- private static void printUsage(int exitCode) {
+ private static void printUsage() {
log.info(
"""
- Usage:
- java -cp license-generator.jar io.github.bsayli.license.cli.LicenseKeyGeneratorCli --userId [--printSegments]
-
- Options:
- --userId Keycloak user UUID to bind this license to
- --printSegments Also print prefix, random and encryptedUserId parts
- --help, -h Show this help
- """);
- System.exit(exitCode);
+ Usage:
+ java -cp license-generator.jar io.github.bsayli.license.cli.LicenseKeyGeneratorCli --userId [--printSegments]
+
+ Options:
+ --userId Keycloak user UUID to bind this license to
+ --printSegments Also print prefix, random and encryptedUserId parts
+ --help, -h Show this help
+ """);
}
}
diff --git a/license-generator/src/main/java/io/github/bsayli/license/cli/LicenseTokenCli.java b/license-generator/src/main/java/io/github/bsayli/license/cli/LicenseTokenCli.java
index e53f48f..691e18b 100644
--- a/license-generator/src/main/java/io/github/bsayli/license/cli/LicenseTokenCli.java
+++ b/license-generator/src/main/java/io/github/bsayli/license/cli/LicenseTokenCli.java
@@ -1,6 +1,6 @@
package io.github.bsayli.license.cli;
-import io.github.bsayli.license.token.extractor.JwtTokenExtractor;
+import io.github.bsayli.license.cli.service.LicenseTokenService;
import io.github.bsayli.license.token.extractor.model.LicenseValidationResult;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
@@ -12,75 +12,62 @@
public final class LicenseTokenCli {
+ static final int EXIT_OK = 0;
+ static final int EXIT_USAGE = 2;
+ static final int EXIT_VALIDATION = 4;
private static final Logger log = LoggerFactory.getLogger(LicenseTokenCli.class);
-
private static final String ARG_PUBLIC_KEY = "--publicKey";
private static final String ARG_TOKEN = "--token";
private static final String ARG_HELP_LONG = "--help";
private static final String ARG_HELP_SHORT = "-h";
-
- private static final int EXIT_OK = 0;
- private static final int EXIT_USAGE = 2;
- private static final int EXIT_VALIDATION = 4;
-
private static final String MSG_MISSING_PUBLIC_KEY = "Missing --publicKey ";
private static final String MSG_MISSING_TOKEN = "Missing --token ";
- private static final String MSG_BLANK_PUBLIC_KEY = "--publicKey must not be blank";
- private static final String MSG_BLANK_TOKEN = "--token must not be blank";
private LicenseTokenCli() {}
public static void main(String[] args) {
- List argv = Arrays.asList(args);
+ System.exit(run(args));
+ }
+
+ static int run(String[] args) {
+ final List argv = Arrays.asList(args);
if (argv.contains(ARG_HELP_LONG) || argv.contains(ARG_HELP_SHORT)) {
- printUsage(EXIT_OK);
+ printUsage();
+ return EXIT_OK;
}
- String publicKeyB64 =
- readOptionValue(argv, ARG_PUBLIC_KEY)
- .orElseThrow(
- () -> {
- log.error(MSG_MISSING_PUBLIC_KEY);
- printUsage(EXIT_USAGE);
- return new IllegalStateException(MSG_MISSING_PUBLIC_KEY);
- });
-
- String token =
- readOptionValue(argv, ARG_TOKEN)
- .orElseThrow(
- () -> {
- log.error(MSG_MISSING_TOKEN);
- printUsage(EXIT_USAGE);
- return new IllegalStateException(MSG_MISSING_TOKEN);
- });
-
- if (publicKeyB64.isBlank()) {
- log.error(MSG_BLANK_PUBLIC_KEY);
- printUsage(EXIT_USAGE);
+ String publicKeyB64 = readOptionValue(argv, ARG_PUBLIC_KEY).orElse(null);
+ if (publicKeyB64 == null || publicKeyB64.isBlank()) {
+ log.error(MSG_MISSING_PUBLIC_KEY);
+ printUsage();
+ return EXIT_USAGE;
}
- if (token.isBlank()) {
- log.error(MSG_BLANK_TOKEN);
- printUsage(EXIT_USAGE);
+
+ String token = readOptionValue(argv, ARG_TOKEN).orElse(null);
+ if (token == null || token.isBlank()) {
+ log.error(MSG_MISSING_TOKEN);
+ printUsage();
+ return EXIT_USAGE;
}
try {
- JwtTokenExtractor extractor = new JwtTokenExtractor(publicKeyB64);
- LicenseValidationResult result = extractor.validateAndGetToken(token);
+ var svc = new LicenseTokenService();
+ LicenseValidationResult result = svc.validate(publicKeyB64, token);
log.info("License token is VALID");
log.info(" status : {}", result.licenseStatus());
log.info(" tier : {}", result.licenseTier());
log.info(" message : {}", result.message());
log.info(" expiration : {}", result.expirationDate());
+ return EXIT_OK;
- System.exit(EXIT_OK);
} catch (ExpiredJwtException e) {
log.error("License token is EXPIRED: {}", e.getMessage());
- System.exit(EXIT_VALIDATION);
+ return EXIT_VALIDATION;
} catch (JwtException | IllegalArgumentException e) {
log.error("License token is INVALID: {}", e.getMessage());
- System.exit(EXIT_VALIDATION);
+ return EXIT_VALIDATION;
}
}
@@ -90,24 +77,21 @@ private static Optional readOptionValue(List argv, String name)
int valIdx = idx + 1;
if (valIdx >= argv.size()) return Optional.empty();
String val = argv.get(valIdx);
- if (val != null && !val.startsWith("--") && !val.startsWith("-")) {
- return Optional.of(val);
- }
+ if (val != null && !val.startsWith("--") && !val.startsWith("-")) return Optional.of(val);
return Optional.empty();
}
- private static void printUsage(int exitCode) {
+ private static void printUsage() {
log.info(
"""
- Usage:
- java -cp license-generator.jar io.github.bsayli.license.cli.LicenseTokenCli \\
- --publicKey --token
-
- Options:
- --publicKey Base64-encoded SPKI Ed25519 public key used to verify the token
- --token Compact JWS (EdDSA) license token to validate
- --help, -h Show this help
- """);
- System.exit(exitCode);
+ Usage:
+ java -cp license-generator.jar io.github.bsayli.license.cli.LicenseTokenCli \\
+ --publicKey --token
+
+ Options:
+ --publicKey Base64-encoded SPKI Ed25519 public key used to verify the token
+ --token Compact JWS (EdDSA) license token to validate
+ --help, -h Show this help
+ """);
}
}
diff --git a/license-generator/src/main/java/io/github/bsayli/license/cli/SignatureCli.java b/license-generator/src/main/java/io/github/bsayli/license/cli/SignatureCli.java
index 846d310..33f607a 100644
--- a/license-generator/src/main/java/io/github/bsayli/license/cli/SignatureCli.java
+++ b/license-generator/src/main/java/io/github/bsayli/license/cli/SignatureCli.java
@@ -1,10 +1,11 @@
package io.github.bsayli.license.cli;
-import io.github.bsayli.license.signature.generator.SignatureGenerator;
-import io.github.bsayli.license.signature.model.SignatureData;
-import io.github.bsayli.license.signature.validator.SignatureValidator;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import io.github.bsayli.license.cli.service.SignatureService;
import java.security.GeneralSecurityException;
-import java.util.*;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -12,21 +13,20 @@ public final class SignatureCli {
private static final Logger log = LoggerFactory.getLogger(SignatureCli.class);
- private static final String ARG_MODE = "--mode"; // sign | verify
+ private static final String ARG_MODE = "--mode";
private static final String MODE_SIGN = "sign";
private static final String MODE_VERIFY = "verify";
private static final String ARG_SERVICE_ID = "--serviceId";
private static final String ARG_SERVICE_VERSION = "--serviceVersion";
private static final String ARG_INSTANCE_ID = "--instanceId";
- private static final String ARG_LICENSE_KEY = "--licenseKey"; // FULL licenseKey string
- private static final String ARG_TOKEN = "--token"; // JWT license token
+ private static final String ARG_LICENSE_KEY = "--licenseKey";
+ private static final String ARG_TOKEN = "--token";
+ private static final String ARG_PRIVATE_KEY = "--privateKey";
- private static final String ARG_PRIVATE_KEY = "--privateKey"; // Base64 PKCS#8 Ed25519 (sign)
- private static final String ARG_PUBLIC_KEY = "--publicKey"; // Base64 SPKI Ed25519 (verify)
-
- private static final String ARG_DATA_JSON = "--dataJson"; // exact JSON used for signing
- private static final String ARG_SIGNATURE = "--signatureB64"; // Base64 detached signature
+ private static final String ARG_DATA_JSON = "--dataJson";
+ private static final String ARG_SIGNATURE = "--signatureB64";
+ private static final String ARG_PUBLIC_KEY = "--publicKey";
private static final String ARG_HELP_LONG = "--help";
private static final String ARG_HELP_SHORT = "-h";
@@ -39,104 +39,108 @@ public final class SignatureCli {
private SignatureCli() {}
public static void main(String[] args) {
- List argv = Arrays.asList(args);
+ System.exit(run(args));
+ }
+
+ static int run(String[] args) {
+ final List argv = Arrays.asList(args);
if (argv.contains(ARG_HELP_LONG) || argv.contains(ARG_HELP_SHORT)) {
- printUsage(EXIT_OK);
+ printUsage();
+ return EXIT_OK;
}
- String mode = read(ARG_MODE, argv).orElse(null);
- if ((!MODE_SIGN.equalsIgnoreCase(mode) && !MODE_VERIFY.equalsIgnoreCase(mode))) {
+ final String mode = readOptionValue(argv, ARG_MODE).orElse(null);
+ if (!MODE_SIGN.equalsIgnoreCase(mode) && !MODE_VERIFY.equalsIgnoreCase(mode)) {
log.error("Missing or invalid --mode (expected: sign | verify)");
- printUsage(EXIT_USAGE);
+ printUsage();
+ return EXIT_USAGE;
}
- if (MODE_SIGN.equalsIgnoreCase(mode)) {
- runSign(argv);
- } else {
- runVerify(argv);
+ try {
+ return MODE_SIGN.equalsIgnoreCase(mode) ? runSign(argv) : runVerify(argv);
+ } catch (Exception e) {
+ log.error("Unexpected error: {}", e.getMessage(), e);
+ return EXIT_SIGN;
}
}
- private static void runSign(List argv) {
- String serviceId = require(ARG_SERVICE_ID, argv, "Missing --serviceId");
- String serviceVersion = require(ARG_SERVICE_VERSION, argv, "Missing --serviceVersion");
- String instanceId = require(ARG_INSTANCE_ID, argv, "Missing --instanceId");
+ private static int runSign(List argv) {
+ String serviceId = readRequired(argv, ARG_SERVICE_ID, "Missing --serviceId");
+ String serviceVersion = readRequired(argv, ARG_SERVICE_VERSION, "Missing --serviceVersion");
+ String instanceId = readRequired(argv, ARG_INSTANCE_ID, "Missing --instanceId");
String privateKeyB64 =
- require(ARG_PRIVATE_KEY, argv, "Missing --privateKey (PKCS#8 Base64 Ed25519)");
+ readRequired(argv, ARG_PRIVATE_KEY, "Missing --privateKey (PKCS#8 Base64 Ed25519)");
+
+ boolean missingRequired =
+ isBlank(serviceId)
+ || isBlank(serviceVersion)
+ || isBlank(instanceId)
+ || isBlank(privateKeyB64);
- Optional licenseKeyOpt = read(ARG_LICENSE_KEY, argv);
- Optional tokenOpt = read(ARG_TOKEN, argv);
+ if (missingRequired) {
+ printUsage();
+ return EXIT_USAGE;
+ }
- boolean hasLicenseKey = licenseKeyOpt.filter(s -> !s.isBlank()).isPresent();
- boolean hasToken = tokenOpt.filter(s -> !s.isBlank()).isPresent();
+ Optional licOpt = readOptionValue(argv, ARG_LICENSE_KEY).filter(s -> !s.isBlank());
+ Optional tokOpt = readOptionValue(argv, ARG_TOKEN).filter(s -> !s.isBlank());
- if (hasLicenseKey == hasToken) {
+ if (licOpt.isPresent() == tokOpt.isPresent()) {
log.error("Provide exactly one of --licenseKey or --token");
- printUsage(EXIT_USAGE);
+ printUsage();
+ return EXIT_USAGE;
}
try {
- SignatureData payload;
- if (hasLicenseKey) {
- String licenseKey = licenseKeyOpt.orElseThrow();
- String licenseKeyHashB64 =
- base64Sha256(licenseKey); // FULL licenseKey hash (server ile aynı)
- payload =
- new SignatureData.Builder()
- .serviceId(serviceId)
- .serviceVersion(serviceVersion)
- .instanceId(instanceId)
- .encryptedLicenseKeyHash(licenseKeyHashB64)
- .build();
- } else {
- String token = tokenOpt.orElseThrow();
- String tokenHashB64 = base64Sha256(token);
- payload =
- new SignatureData.Builder()
- .serviceId(serviceId)
- .serviceVersion(serviceVersion)
- .instanceId(instanceId)
- .licenseTokenHash(tokenHashB64)
- .build();
- }
+ var svc = new SignatureService();
+ SignatureService.SignResult result =
+ licOpt.isPresent()
+ ? svc.signWithLicenseKey(
+ serviceId, serviceVersion, instanceId, licOpt.get(), privateKeyB64)
+ : svc.signWithToken(
+ serviceId, serviceVersion, instanceId, tokOpt.get(), privateKeyB64);
- String signatureB64 = SignatureGenerator.createSignature(payload, privateKeyB64);
-
- String json = payload.toJson();
log.info("Signed payload JSON:");
- log.info("{}", json);
- log.info("Signature (Base64): {}", signatureB64);
- System.exit(EXIT_OK);
- } catch (Exception e) {
+ log.info("{}", result.jsonPayload());
+ log.info("Signature (Base64): {}", result.signatureB64());
+ return EXIT_OK;
+
+ } catch (GeneralSecurityException | JsonProcessingException e) {
log.error("Signing error: {}", e.getMessage(), e);
- System.exit(EXIT_SIGN);
+ return EXIT_SIGN;
}
}
- private static void runVerify(List argv) {
- String dataJson = require(ARG_DATA_JSON, argv, "Missing --dataJson");
- String signatureB64 = require(ARG_SIGNATURE, argv, "Missing --signatureB64");
+ private static int runVerify(List argv) {
+ String dataJson = readRequired(argv, ARG_DATA_JSON, "Missing --dataJson");
+ String signatureB64 = readRequired(argv, ARG_SIGNATURE, "Missing --signatureB64");
String publicKeyB64 =
- require(ARG_PUBLIC_KEY, argv, "Missing --publicKey (SPKI Base64 Ed25519)");
+ readRequired(argv, ARG_PUBLIC_KEY, "Missing --publicKey (SPKI Base64 Ed25519)");
+
+ boolean missingRequired = isBlank(dataJson) || isBlank(signatureB64) || isBlank(publicKeyB64);
+ if (missingRequired) {
+ printUsage();
+ return EXIT_USAGE;
+ }
try {
- SignatureValidator validator = new SignatureValidator(publicKeyB64);
- boolean ok = validator.validateSignature(signatureB64, dataJson);
+ var svc = new SignatureService();
+ boolean ok = svc.verify(publicKeyB64, dataJson, signatureB64);
if (ok) {
log.info("Signature is VALID");
- System.exit(EXIT_OK);
+ return EXIT_OK;
} else {
log.error("Signature is INVALID");
- System.exit(EXIT_VERIFY);
+ return EXIT_VERIFY;
}
} catch (GeneralSecurityException e) {
log.error("Verification error: {}", e.getMessage(), e);
- System.exit(EXIT_VERIFY);
+ return EXIT_VERIFY;
}
}
- private static Optional read(String name, List argv) {
+ private static Optional readOptionValue(List argv, String name) {
int idx = argv.indexOf(name);
if (idx < 0) return Optional.empty();
int valIdx = idx + 1;
@@ -148,63 +152,58 @@ private static Optional read(String name, List argv) {
return Optional.empty();
}
- private static String require(String name, List argv, String onMissing) {
- return read(name, argv)
+ private static String readRequired(List argv, String name, String onMissing) {
+ return readOptionValue(argv, name)
.filter(s -> !s.isBlank())
.orElseGet(
() -> {
log.error(onMissing);
- printUsage(EXIT_USAGE);
return "";
});
}
- private static String base64Sha256(String text) throws Exception {
- return java.util.Base64.getEncoder()
- .encodeToString(
- java.security.MessageDigest.getInstance("SHA-256")
- .digest(text.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
+ private static boolean isBlank(String s) {
+ return s == null || s.isBlank();
}
- private static void printUsage(int exitCode) {
+ private static void printUsage() {
log.info(
"""
- Usage:
-
- # SIGN (Ed25519, PKCS#8 private key Base64)
- java -cp license-generator.jar io.github.bsayli.license.cli.SignatureCli \\
- --mode sign \\
- --serviceId --serviceVersion --instanceId \\
- --licenseKey \\
- --privateKey
-
- # or with token (exactly one of --licenseKey or --token)
- java -cp license-generator.jar io.github.bsayli.license.cli.SignatureCli \\
- --mode sign \\
- --serviceId --serviceVersion --instanceId \\
- --token \\
- --privateKey
-
- # VERIFY (Ed25519, SPKI public key Base64)
- java -cp license-generator.jar io.github.bsayli.license.cli.SignatureCli \\
- --mode verify \\
- --dataJson '{...}' \\
- --signatureB64 \\
- --publicKey
-
- Options:
- --mode sign|verify
- --serviceId
- --serviceVersion
- --instanceId
- --licenseKey (sign)
- --token (sign)
- --privateKey (sign)
- --publicKey (verify)
- --dataJson (verify)
- --signatureB64 (verify)
- --help, -h
- """);
- System.exit(exitCode);
+ Usage:
+
+ # SIGN (Ed25519, PKCS#8 private key Base64)
+ java -cp license-generator.jar io.github.bsayli.license.cli.SignatureCli \\
+ --mode sign \\
+ --serviceId --serviceVersion --instanceId \\
+ --licenseKey \\
+ --privateKey
+
+ # or with token (exactly one of --licenseKey or --token)
+ java -cp license-generator.jar io.github.bsayli.license.cli.SignatureCli \\
+ --mode sign \\
+ --serviceId --serviceVersion --instanceId \\
+ --token \\
+ --privateKey
+
+ # VERIFY (Ed25519, SPKI public key Base64)
+ java -cp license-generator.jar io.github.bsayli.license.cli.SignatureCli \\
+ --mode verify \\
+ --dataJson '{...}' \\
+ --signatureB64 \\
+ --publicKey
+
+ Options:
+ --mode sign|verify
+ --serviceId
+ --serviceVersion
+ --instanceId
+ --licenseKey (sign)
+ --token (sign)
+ --privateKey (sign)
+ --publicKey (verify)
+ --dataJson (verify)
+ --signatureB64 (verify)
+ --help, -h
+ """);
}
}
diff --git a/license-generator/src/main/java/io/github/bsayli/license/cli/service/Ed25519KeyService.java b/license-generator/src/main/java/io/github/bsayli/license/cli/service/Ed25519KeyService.java
new file mode 100644
index 0000000..a9645bf
--- /dev/null
+++ b/license-generator/src/main/java/io/github/bsayli/license/cli/service/Ed25519KeyService.java
@@ -0,0 +1,68 @@
+package io.github.bsayli.license.cli.service;
+
+import static io.github.bsayli.license.common.CryptoConstants.*;
+
+import java.io.IOException;
+import java.nio.file.*;
+import java.security.*;
+import java.security.spec.ECGenParameterSpec;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class Ed25519KeyService {
+
+ private static final Logger log = LoggerFactory.getLogger(Ed25519KeyService.class);
+
+ public KeyPairB64 generate() throws GeneralSecurityException {
+ ensureBouncyCastleIfNeeded();
+ KeyPair kp = generateEd25519KeyPair();
+
+ String pub = B64_ENC.encodeToString(kp.getPublic().getEncoded());
+ String priv = B64_ENC.encodeToString(kp.getPrivate().getEncoded());
+
+ return new KeyPairB64(pub, priv);
+ }
+
+ public void writeString(Path path, String content) throws IOException {
+ Path abs = path.toAbsolutePath();
+ Path parent = abs.getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
+ Files.writeString(abs, content);
+ log.info("Wrote {} bytes to {}", content.length(), abs);
+ }
+
+ private KeyPair generateEd25519KeyPair() throws GeneralSecurityException {
+ try {
+ KeyPairGenerator kpg = KeyPairGenerator.getInstance(ED25519_STD_ALGO);
+ log.debug("Using JDK provider for {} → {}", ED25519_STD_ALGO, kpg.getProvider().getName());
+ return kpg.generateKeyPair();
+ } catch (NoSuchAlgorithmException e) {
+ KeyPairGenerator kpg = KeyPairGenerator.getInstance(EDDSA_BC_ALGO, BC_PROVIDER);
+ kpg.initialize(new ECGenParameterSpec(ED25519_CURVE), new SecureRandom());
+ log.info(
+ "Falling back to BouncyCastle provider for Ed25519 ({}).", kpg.getProvider().getName());
+ return kpg.generateKeyPair();
+ }
+ }
+
+ void ensureBouncyCastleIfNeeded() {
+ if (Security.getProvider(BC_PROVIDER) != null) {
+ log.debug("BouncyCastle provider already present ({}).", BC_PROVIDER);
+ return;
+ }
+ try {
+ Class> bc = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider");
+ Provider p = (Provider) bc.getDeclaredConstructor().newInstance();
+ Security.addProvider(p);
+ log.info("BouncyCastle provider added: {}", p.getInfo());
+ } catch (ClassNotFoundException e) {
+ log.warn("BouncyCastle not found on classpath; continuing with JDK provider for Ed25519.", e);
+ } catch (ReflectiveOperationException | SecurityException e) {
+ log.warn("Failed to initialize BouncyCastle provider; continuing with JDK provider.", e);
+ }
+ }
+
+ public record KeyPairB64(String publicSpkiB64, String privatePkcs8B64) {}
+}
diff --git a/license-generator/src/main/java/io/github/bsayli/license/cli/service/KeygenService.java b/license-generator/src/main/java/io/github/bsayli/license/cli/service/KeygenService.java
new file mode 100644
index 0000000..629bb1f
--- /dev/null
+++ b/license-generator/src/main/java/io/github/bsayli/license/cli/service/KeygenService.java
@@ -0,0 +1,38 @@
+package io.github.bsayli.license.cli.service;
+
+import io.github.bsayli.license.common.CryptoUtils;
+import io.github.bsayli.license.securekey.generator.SecureEdDSAKeyPairGenerator;
+import io.github.bsayli.license.securekey.generator.SecureKeyGenerator;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.NoSuchAlgorithmException;
+import javax.crypto.SecretKey;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class KeygenService {
+
+ private static final Logger log = LoggerFactory.getLogger(KeygenService.class);
+
+ public AesKeyB64 generateAes(int sizeBits) throws NoSuchAlgorithmException {
+ if (sizeBits != 128 && sizeBits != 192 && sizeBits != 256) {
+ throw new IllegalArgumentException("--size must be one of 128, 192, 256");
+ }
+ SecretKey key = SecureKeyGenerator.generateAesKey(sizeBits);
+ String b64 = CryptoUtils.toBase64(key);
+ log.debug("Generated AES-{} key ({} chars base64)", sizeBits, b64.length());
+ return new AesKeyB64(b64, sizeBits);
+ }
+
+ public Ed25519KeysB64 generateEd25519() throws GeneralSecurityException {
+ KeyPair kp = SecureEdDSAKeyPairGenerator.generateKeyPair();
+ String pub = CryptoUtils.toBase64(kp.getPublic());
+ String prv = CryptoUtils.toBase64(kp.getPrivate());
+ log.debug("Generated Ed25519 pair (pub {} chars, priv {} chars)", pub.length(), prv.length());
+ return new Ed25519KeysB64(pub, prv);
+ }
+
+ public record AesKeyB64(String base64, int sizeBits) {}
+
+ public record Ed25519KeysB64(String publicSpkiB64, String privatePkcs8B64) {}
+}
diff --git a/license-generator/src/main/java/io/github/bsayli/license/cli/service/LicenseKeyService.java b/license-generator/src/main/java/io/github/bsayli/license/cli/service/LicenseKeyService.java
new file mode 100644
index 0000000..f79d192
--- /dev/null
+++ b/license-generator/src/main/java/io/github/bsayli/license/cli/service/LicenseKeyService.java
@@ -0,0 +1,35 @@
+package io.github.bsayli.license.cli.service;
+
+import io.github.bsayli.license.licensekey.encrypter.UserIdEncrypter;
+import io.github.bsayli.license.licensekey.generator.LicenseKeyGenerator;
+import io.github.bsayli.license.licensekey.model.LicenseKeyData;
+import java.security.GeneralSecurityException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class LicenseKeyService {
+
+ private static final Logger log = LoggerFactory.getLogger(LicenseKeyService.class);
+
+ public LicenseKeyResult generate(String userId) throws GeneralSecurityException {
+ if (userId == null || userId.isBlank()) {
+ throw new IllegalArgumentException("--userId must not be blank");
+ }
+
+ String encryptedUserId = UserIdEncrypter.encrypt(userId);
+ LicenseKeyData data = LicenseKeyGenerator.generateLicenseKey(encryptedUserId);
+ String licenseKey = data.generateLicenseKey();
+
+ log.debug(
+ "License key generated (len={}): prefix={}, randomLen={}, encryptedLen={}",
+ licenseKey.length(),
+ data.prefix(),
+ data.randomString().length(),
+ data.uuid().length());
+
+ return new LicenseKeyResult(licenseKey, data.prefix(), data.randomString(), data.uuid());
+ }
+
+ public record LicenseKeyResult(
+ String licenseKey, String prefix, String randomString, String encryptedUserId) {}
+}
diff --git a/license-generator/src/main/java/io/github/bsayli/license/cli/service/LicenseTokenService.java b/license-generator/src/main/java/io/github/bsayli/license/cli/service/LicenseTokenService.java
new file mode 100644
index 0000000..44cd6c5
--- /dev/null
+++ b/license-generator/src/main/java/io/github/bsayli/license/cli/service/LicenseTokenService.java
@@ -0,0 +1,28 @@
+package io.github.bsayli.license.cli.service;
+
+import io.github.bsayli.license.token.extractor.JwtTokenExtractor;
+import io.github.bsayli.license.token.extractor.model.LicenseValidationResult;
+import io.jsonwebtoken.JwtException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class LicenseTokenService {
+
+ private static final Logger log = LoggerFactory.getLogger(LicenseTokenService.class);
+
+ public LicenseValidationResult validate(String publicKeyB64, String token)
+ throws JwtException, IllegalArgumentException {
+
+ if (publicKeyB64 == null || publicKeyB64.isBlank()) {
+ throw new IllegalArgumentException("--publicKey must not be blank");
+ }
+ if (token == null || token.isBlank()) {
+ throw new IllegalArgumentException("--token must not be blank");
+ }
+
+ log.debug("Validating token (jwtLen={}, pkLen={})", token.length(), publicKeyB64.length());
+
+ JwtTokenExtractor extractor = new JwtTokenExtractor(publicKeyB64);
+ return extractor.validateAndGetToken(token);
+ }
+}
diff --git a/license-generator/src/main/java/io/github/bsayli/license/cli/service/SignatureService.java b/license-generator/src/main/java/io/github/bsayli/license/cli/service/SignatureService.java
new file mode 100644
index 0000000..9094c2c
--- /dev/null
+++ b/license-generator/src/main/java/io/github/bsayli/license/cli/service/SignatureService.java
@@ -0,0 +1,112 @@
+package io.github.bsayli.license.cli.service;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import io.github.bsayli.license.signature.generator.SignatureGenerator;
+import io.github.bsayli.license.signature.model.SignatureData;
+import io.github.bsayli.license.signature.validator.SignatureValidator;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.util.Base64;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class SignatureService {
+
+ private static final Logger log = LoggerFactory.getLogger(SignatureService.class);
+
+ private static void validateNotBlank(String v, String name) {
+ if (v == null || v.isBlank()) {
+ throw new IllegalArgumentException("--" + name + " must not be blank");
+ }
+ }
+
+ private static String base64Sha256(String text) {
+ byte[] digest = sha256(text.getBytes(StandardCharsets.UTF_8));
+ return Base64.getEncoder().encodeToString(digest);
+ }
+
+ private static byte[] sha256(byte[] input) {
+ try {
+ return MessageDigest.getInstance("SHA-256").digest(input);
+ } catch (Exception e) {
+ throw new IllegalStateException("SHA-256 not available", e);
+ }
+ }
+
+ public SignResult signWithLicenseKey(
+ String serviceId,
+ String serviceVersion,
+ String instanceId,
+ String fullLicenseKey,
+ String privateKeyPkcs8B64)
+ throws GeneralSecurityException, JsonProcessingException {
+
+ validateNotBlank(serviceId, "serviceId");
+ validateNotBlank(serviceVersion, "serviceVersion");
+ validateNotBlank(instanceId, "instanceId");
+ validateNotBlank(fullLicenseKey, "licenseKey");
+ validateNotBlank(privateKeyPkcs8B64, "privateKey");
+
+ String licenseKeyHashB64 = base64Sha256(fullLicenseKey);
+
+ SignatureData data =
+ new SignatureData.Builder()
+ .serviceId(serviceId)
+ .serviceVersion(serviceVersion)
+ .instanceId(instanceId)
+ .encryptedLicenseKeyHash(licenseKeyHashB64)
+ .build();
+
+ String signatureB64 = SignatureGenerator.createSignature(data, privateKeyPkcs8B64);
+ String json = data.toJson();
+
+ log.debug(
+ "Signed (licenseKeyHash) payloadLen={}, sigLen={}", json.length(), signatureB64.length());
+ return new SignResult(json, signatureB64);
+ }
+
+ public SignResult signWithToken(
+ String serviceId,
+ String serviceVersion,
+ String instanceId,
+ String jwtToken,
+ String privateKeyPkcs8B64)
+ throws GeneralSecurityException, JsonProcessingException {
+
+ validateNotBlank(serviceId, "serviceId");
+ validateNotBlank(serviceVersion, "serviceVersion");
+ validateNotBlank(instanceId, "instanceId");
+ validateNotBlank(jwtToken, "token");
+ validateNotBlank(privateKeyPkcs8B64, "privateKey");
+
+ String tokenHashB64 = base64Sha256(jwtToken);
+
+ SignatureData data =
+ new SignatureData.Builder()
+ .serviceId(serviceId)
+ .serviceVersion(serviceVersion)
+ .instanceId(instanceId)
+ .licenseTokenHash(tokenHashB64)
+ .build();
+
+ String signatureB64 = SignatureGenerator.createSignature(data, privateKeyPkcs8B64);
+ String json = data.toJson();
+
+ log.debug("Signed (tokenHash) payloadLen={}, sigLen={}", json.length(), signatureB64.length());
+ return new SignResult(json, signatureB64);
+ }
+
+ public boolean verify(String publicKeySpkiB64, String jsonPayload, String signatureB64)
+ throws GeneralSecurityException {
+
+ validateNotBlank(publicKeySpkiB64, "publicKey");
+ validateNotBlank(jsonPayload, "dataJson");
+ validateNotBlank(signatureB64, "signatureB64");
+
+ SignatureValidator validator = new SignatureValidator(publicKeySpkiB64);
+ return validator.validateSignature(signatureB64, jsonPayload);
+ }
+
+ public record SignResult(String jsonPayload, String signatureB64) {}
+}
diff --git a/license-generator/src/main/java/io/github/bsayli/license/cli/service/UserIdCryptoService.java b/license-generator/src/main/java/io/github/bsayli/license/cli/service/UserIdCryptoService.java
new file mode 100644
index 0000000..0eda49b
--- /dev/null
+++ b/license-generator/src/main/java/io/github/bsayli/license/cli/service/UserIdCryptoService.java
@@ -0,0 +1,32 @@
+package io.github.bsayli.license.cli.service;
+
+import io.github.bsayli.license.licensekey.encrypter.UserIdEncrypter;
+import java.security.GeneralSecurityException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class UserIdCryptoService {
+
+ private static final Logger log = LoggerFactory.getLogger(UserIdCryptoService.class);
+
+ public String encrypt(String userId) throws GeneralSecurityException {
+ if (userId == null || userId.isBlank()) {
+ throw new IllegalArgumentException("--userId must not be blank");
+ }
+ String enc = UserIdEncrypter.encrypt(userId);
+ log.debug("Encrypted userId ({} chars) -> {} chars base64", userId.length(), enc.length());
+ return enc;
+ }
+
+ public String decrypt(String ciphertextBase64) throws GeneralSecurityException {
+ if (ciphertextBase64 == null || ciphertextBase64.isBlank()) {
+ throw new IllegalArgumentException("--ciphertext must not be blank");
+ }
+ String plain = UserIdEncrypter.decrypt(ciphertextBase64);
+ log.debug(
+ "Decrypted ciphertext ({} chars base64) -> plain ({} chars)",
+ ciphertextBase64.length(),
+ plain.length());
+ return plain;
+ }
+}
diff --git a/license-generator/src/main/java/io/github/bsayli/license/signature/SignatureDemo.java b/license-generator/src/main/java/io/github/bsayli/license/signature/SignatureDemo.java
index 7a57e82..e36e592 100644
--- a/license-generator/src/main/java/io/github/bsayli/license/signature/SignatureDemo.java
+++ b/license-generator/src/main/java/io/github/bsayli/license/signature/SignatureDemo.java
@@ -2,31 +2,39 @@
import io.github.bsayli.license.signature.generator.SignatureGenerator;
import io.github.bsayli.license.signature.model.SignatureData;
+import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class SignatureDemo {
+ static final int EXIT_OK = 0;
+ static final int EXIT_USAGE = 2;
+ static final int EXIT_SIGN = 3;
private static final Logger log = LoggerFactory.getLogger(SignatureDemo.class);
-
- private static final String ARG_MODE = "--mode"; // sign-sample-key | sign-sample-token
+ private static final String ARG_MODE = "--mode";
private static final String MODE_SAMPLE_KEY = "sign-sample-key";
private static final String MODE_SAMPLE_TOKEN = "sign-sample-token";
-
- private static final String ARG_PRIVATE_KEY = "--privateKey"; // Base64 PKCS#8 Ed25519
+ private static final String ARG_PRIVATE_KEY = "--privateKey";
private SignatureDemo() {}
public static void main(String[] args) {
+ System.exit(run(args));
+ }
+
+ static int run(String[] args) {
String mode = readOpt(args, ARG_MODE).orElse(null);
String privateKeyB64 = readOpt(args, ARG_PRIVATE_KEY).orElse(null);
if (mode == null || privateKeyB64 == null) {
- usage(2);
+ printUsage();
+ return EXIT_USAGE;
}
if (!MODE_SAMPLE_KEY.equalsIgnoreCase(mode) && !MODE_SAMPLE_TOKEN.equalsIgnoreCase(mode)) {
log.error("Invalid --mode. Expected: {} or {}", MODE_SAMPLE_KEY, MODE_SAMPLE_TOKEN);
- usage(2);
+ printUsage();
+ return EXIT_USAGE;
}
try {
@@ -41,39 +49,36 @@ public static void main(String[] args) {
log.info("Signed payload JSON:\n{}", payload.toJson());
log.info("Signature (Base64): {}", sig);
}
- System.exit(0);
+ return EXIT_OK;
} catch (Exception e) {
log.error("Signing failed: {}", e.getMessage(), e);
- System.exit(3);
+ return EXIT_SIGN;
}
}
- private static java.util.Optional readOpt(String[] argv, String name) {
+ static Optional readOpt(String[] argv, String name) {
for (int i = 0; i < argv.length; i++) {
if (name.equals(argv[i]) && i + 1 < argv.length) {
String v = argv[i + 1];
- if (v != null && !v.startsWith("--") && !v.startsWith("-")) {
- return java.util.Optional.of(v);
- }
+ if (v != null && !v.startsWith("--") && !v.startsWith("-")) return Optional.of(v);
}
}
- return java.util.Optional.empty();
+ return Optional.empty();
}
- private static void usage(int code) {
+ private static void printUsage() {
log.info(
- """
- Usage:
- # Sign sample payload (encrypted license key variant)
- java -cp license-generator.jar io.github.bsayli.license.signature.SignatureDemo \\
- --mode sign-sample-key \\
- --privateKey
-
- # Sign sample payload (license token variant)
- java -cp license-generator.jar io.github.bsayli.license.signature.SignatureDemo \\
- --mode sign-sample-token \\
- --privateKey
- """);
- System.exit(code);
+ """
+ Usage:
+ # Sign sample payload (encrypted license key variant)
+ java -cp license-generator.jar io.github.bsayli.license.signature.SignatureDemo \\
+ --mode sign-sample-key \\
+ --privateKey
+
+ # Sign sample payload (license token variant)
+ java -cp license-generator.jar io.github.bsayli.license.signature.SignatureDemo \\
+ --mode sign-sample-token \\
+ --privateKey
+ """);
}
-}
+}
\ No newline at end of file
diff --git a/license-generator/src/test/java/io/github/bsayli/license/cli/Ed25519KeygenCliIT.java b/license-generator/src/test/java/io/github/bsayli/license/cli/Ed25519KeygenCliIT.java
new file mode 100644
index 0000000..0e523d1
--- /dev/null
+++ b/license-generator/src/test/java/io/github/bsayli/license/cli/Ed25519KeygenCliIT.java
@@ -0,0 +1,99 @@
+package io.github.bsayli.license.cli;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.github.stefanbirkner.systemlambda.SystemLambda;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Base64;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("it")
+@DisplayName("Integration Test: Ed25519KeygenCli")
+class Ed25519KeygenCliIT {
+
+ private static final Pattern PUB_LINE =
+ Pattern.compile("Public\\s*\\(SPKI, X\\.509\\):\\s*(\\S+)");
+ private static final Pattern PRIV_LINE =
+ Pattern.compile("Private\\s*\\(PKCS#8\\)\\s*:\\s*(\\S+)");
+
+ private static boolean isBase64(String s) {
+ try {
+ Base64.getDecoder().decode(s);
+ return true;
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ }
+
+ private static String extractFirst(Pattern p, String text, String errorIfMissing) {
+ var m = p.matcher(text);
+ if (m.find()) return m.groupCount() >= 1 && m.group(1) != null ? m.group(1) : m.group(0);
+ fail(errorIfMissing + "\n--- OUTPUT ---\n" + text);
+ return null;
+ }
+
+ @Test
+ @DisplayName("no args → prints Base64 keys (exit 0)")
+ void print_keys_ok() throws Exception {
+ String out =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = Ed25519KeygenCli.run(new String[] {});
+ assertEquals(0, code);
+ });
+
+ String pub = extractFirst(PUB_LINE, out, "Public key line not found");
+ String priv = extractFirst(PRIV_LINE, out, "Private key line not found");
+
+ assertTrue(isBase64(pub), "public key must be Base64");
+ assertTrue(isBase64(priv), "private key must be Base64");
+ }
+
+ @Test
+ @DisplayName("--outPublic/--outPrivate → writes files and logs paths (exit 0)")
+ void write_files_ok() throws Exception {
+ Path tmpDir = Files.createTempDirectory("ed25519-it-");
+ Path pubFile = tmpDir.resolve("pub.b64");
+ Path privFile = tmpDir.resolve("priv.b64");
+
+ String out =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code =
+ Ed25519KeygenCli.run(
+ new String[] {
+ "--outPublic", pubFile.toString(),
+ "--outPrivate", privFile.toString()
+ });
+ assertEquals(0, code);
+ });
+
+ assertTrue(Files.exists(pubFile), "public file should exist");
+ assertTrue(Files.exists(privFile), "private file should exist");
+
+ String pub = Files.readString(pubFile).trim();
+ String priv = Files.readString(privFile).trim();
+
+ assertTrue(isBase64(pub));
+ assertTrue(isBase64(priv));
+ assertTrue(out.contains("Written:"));
+ assertTrue(out.contains(pubFile.toString()));
+ assertTrue(out.contains(privFile.toString()));
+ }
+
+ @Test
+ @DisplayName("--help prints usage and exits 0")
+ void help_ok() throws Exception {
+ String out =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = Ed25519KeygenCli.run(new String[] {"--help"});
+ assertEquals(0, code);
+ });
+ assertTrue(out.contains("Usage:"));
+ }
+}
diff --git a/license-generator/src/test/java/io/github/bsayli/license/cli/EncryptUserIdCliIT.java b/license-generator/src/test/java/io/github/bsayli/license/cli/EncryptUserIdCliIT.java
new file mode 100644
index 0000000..5f66996
--- /dev/null
+++ b/license-generator/src/test/java/io/github/bsayli/license/cli/EncryptUserIdCliIT.java
@@ -0,0 +1,88 @@
+package io.github.bsayli.license.cli;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.github.stefanbirkner.systemlambda.SystemLambda;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("it")
+@DisplayName("Integration Test: EncryptUserIdCli")
+class EncryptUserIdCliIT {
+
+ private static final Pattern ENC_LINE = Pattern.compile("Encrypted userId:\\s*(\\S+)");
+ private static final Pattern DEC_LINE = Pattern.compile("Decrypted userId:\\s*(\\S+)");
+
+ private static String extractFirst(Pattern p, String text, String errorIfMissing) {
+ var m = p.matcher(text);
+ if (m.find()) return m.groupCount() >= 1 && m.group(1) != null ? m.group(1) : m.group(0);
+ fail(errorIfMissing + "\n--- OUTPUT ---\n" + text);
+ return null;
+ }
+
+ @Test
+ @DisplayName("encrypt -> decrypt roundtrip should succeed (exit 0)")
+ void encrypt_then_decrypt_ok() throws Exception {
+ String userId = "11111111-1111-1111-1111-111111111111";
+
+ String encOut =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = EncryptUserIdCli.run(new String[] {"encrypt", "--userId", userId});
+ assertEquals(0, code);
+ });
+ String ciphertext = extractFirst(ENC_LINE, encOut, "Encrypted line not found");
+
+ String decOut =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = EncryptUserIdCli.run(new String[] {"decrypt", "--ciphertext", ciphertext});
+ assertEquals(0, code);
+ });
+ String plain = extractFirst(DEC_LINE, decOut, "Decrypted line not found");
+
+ assertEquals(userId, plain, "roundtrip should return original userId");
+ }
+
+ @Test
+ @DisplayName("missing command or args should exit 2 and print usage")
+ void usage_errors_exit_2() throws Exception {
+ String out1 =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = EncryptUserIdCli.run(new String[] {});
+ assertEquals(2, code);
+ });
+ assertTrue(out1.contains("Usage:"));
+
+ String out2 =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = EncryptUserIdCli.run(new String[] {"encrypt"});
+ assertEquals(2, code);
+ });
+ assertTrue(out2.contains("Usage:"));
+
+ String out3 =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = EncryptUserIdCli.run(new String[] {"decrypt", "--ciphertext", ""});
+ assertEquals(2, code);
+ });
+ assertTrue(out3.contains("must not be blank"));
+ }
+
+ @Test
+ @DisplayName("--help should print usage and exit 0")
+ void help_exit_0() throws Exception {
+ String out =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = EncryptUserIdCli.run(new String[] {"--help"});
+ assertEquals(0, code);
+ });
+ assertTrue(out.contains("Usage:"));
+ }
+}
diff --git a/license-generator/src/test/java/io/github/bsayli/license/cli/KeygenCliIT.java b/license-generator/src/test/java/io/github/bsayli/license/cli/KeygenCliIT.java
new file mode 100644
index 0000000..f1685cb
--- /dev/null
+++ b/license-generator/src/test/java/io/github/bsayli/license/cli/KeygenCliIT.java
@@ -0,0 +1,124 @@
+package io.github.bsayli.license.cli;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.github.stefanbirkner.systemlambda.SystemLambda;
+import java.util.Base64;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("it")
+@DisplayName("Integration Test: KeygenCli")
+class KeygenCliIT {
+
+ private static final Pattern AES_LINE =
+ Pattern.compile("AES-(128|192|256) SecretKey \\(Base64\\):\\s*(\\S+)");
+ private static final Pattern PUB_LINE =
+ Pattern.compile("Ed25519 PublicKey\\s*\\(Base64\\):\\s*(\\S+)");
+ private static final Pattern PRIV_LINE =
+ Pattern.compile("Ed25519 PrivateKey\\s*\\(Base64\\):\\s*(\\S+)");
+
+ private static boolean isBase64(String s) {
+ try {
+ Base64.getDecoder().decode(s);
+ return true;
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ }
+
+ @Test
+ @DisplayName("--mode aes (default 256) should print Base64 key and exit 0")
+ void aes_default_256_ok() throws Exception {
+ String out =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = KeygenCli.run(new String[] {"--mode", "aes"});
+ assertEquals(0, code);
+ });
+
+ var m = AES_LINE.matcher(out);
+ assertTrue(m.find(), "AES output not found");
+ assertEquals("256", m.group(1));
+ assertTrue(isBase64(m.group(2)));
+ }
+
+ @Test
+ @DisplayName("--mode aes --size 128 and 192 should work")
+ void aes_explicit_sizes_ok() throws Exception {
+ String out128 =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = KeygenCli.run(new String[] {"--mode", "aes", "--size", "128"});
+ assertEquals(0, code);
+ });
+ var m128 = AES_LINE.matcher(out128);
+ assertTrue(m128.find());
+ assertEquals("128", m128.group(1));
+ assertTrue(isBase64(m128.group(2)));
+
+ String out192 =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = KeygenCli.run(new String[] {"--mode", "aes", "--size", "192"});
+ assertEquals(0, code);
+ });
+ var m192 = AES_LINE.matcher(out192);
+ assertTrue(m192.find());
+ assertEquals("192", m192.group(1));
+ assertTrue(isBase64(m192.group(2)));
+ }
+
+ @Test
+ @DisplayName("--mode ed25519 should print public and private keys and exit 0")
+ void ed25519_ok() throws Exception {
+ String out =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = KeygenCli.run(new String[] {"--mode", "ed25519"});
+ assertEquals(0, code);
+ });
+
+ var mpub = PUB_LINE.matcher(out);
+ var mprv = PRIV_LINE.matcher(out);
+ assertTrue(mpub.find(), "Public key line not found");
+ assertTrue(mprv.find(), "Private key line not found");
+ assertTrue(isBase64(mpub.group(1)));
+ assertTrue(isBase64(mprv.group(1)));
+ }
+
+ @Test
+ @DisplayName("invalid mode or size should exit 2 and print usage")
+ void usage_errors_exit_2() throws Exception {
+ String outMode =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = KeygenCli.run(new String[] {"--mode", "rsa"});
+ assertEquals(2, code);
+ });
+ assertTrue(outMode.contains("Usage:"));
+
+ String outSize =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = KeygenCli.run(new String[] {"--mode", "aes", "--size", "111"});
+ assertEquals(2, code);
+ });
+ assertTrue(outSize.contains("Usage:"));
+ assertTrue(outSize.contains("--size must be one of"));
+ }
+
+ @Test
+ @DisplayName("--help should print usage and exit 0")
+ void help_exit_0() throws Exception {
+ String out =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = KeygenCli.run(new String[] {"--help"});
+ assertEquals(0, code);
+ });
+ assertTrue(out.contains("Usage:"));
+ }
+}
diff --git a/license-generator/src/test/java/io/github/bsayli/license/cli/LicenseKeyGeneratorCliIT.java b/license-generator/src/test/java/io/github/bsayli/license/cli/LicenseKeyGeneratorCliIT.java
new file mode 100644
index 0000000..2cdac4e
--- /dev/null
+++ b/license-generator/src/test/java/io/github/bsayli/license/cli/LicenseKeyGeneratorCliIT.java
@@ -0,0 +1,75 @@
+package io.github.bsayli.license.cli;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.github.stefanbirkner.systemlambda.SystemLambda;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("it")
+@DisplayName("Integration Test: LicenseKeyGeneratorCli")
+class LicenseKeyGeneratorCliIT {
+
+ private static final Pattern LICENSE_LINE = Pattern.compile("License Key:\\s*(\\S+)");
+ private static final Pattern PREFIX_LINE = Pattern.compile("prefix\\s*:\\s*(\\S+)");
+ private static final Pattern RANDOM_LINE = Pattern.compile("randomString\\s*:\\s*(\\S+)");
+ private static final Pattern ENCUID_LINE = Pattern.compile("encryptedUserId\\s*:\\s*(\\S+)");
+
+ private static String extractFirst(Pattern p, String text, String errorIfMissing) {
+ var m = p.matcher(text);
+ if (m.find()) return m.groupCount() >= 1 && m.group(1) != null ? m.group(1) : m.group(0);
+ fail(errorIfMissing + "\n--- OUTPUT ---\n" + text);
+ return null;
+ }
+
+ @Test
+ @DisplayName("--userId with --printSegments should emit key and segments (exit 0)")
+ void generate_with_segments_ok() throws Exception {
+ String userId = "11111111-1111-1111-1111-111111111111";
+
+ String out =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code =
+ LicenseKeyGeneratorCli.run(new String[] {"--userId", userId, "--printSegments"});
+ assertEquals(0, code, "exit code should be 0");
+ });
+
+ String licenseKey = extractFirst(LICENSE_LINE, out, "License Key line not found");
+ String prefix = extractFirst(PREFIX_LINE, out, "prefix line not found");
+ String random = extractFirst(RANDOM_LINE, out, "randomString line not found");
+ String encUserId = extractFirst(ENCUID_LINE, out, "encryptedUserId line not found");
+
+ assertNotNull(licenseKey);
+ assertTrue(licenseKey.startsWith(prefix + "~"), "license should start with prefix + '~'");
+ assertTrue(licenseKey.contains("~" + random + "~"), "license should contain random segment");
+ assertNotNull(encUserId);
+ assertTrue(licenseKey.endsWith(encUserId), "license should end with encryptedUserId");
+ assertEquals("BSAYLI", prefix, "prefix should be BSAYLI");
+ assertNotNull(random);
+ assertFalse(random.isBlank());
+ assertFalse(encUserId.isBlank());
+ }
+
+ @Test
+ @DisplayName("missing/blank --userId should exit 2 and print usage")
+ void usage_errors_exit_2() throws Exception {
+ String out1 =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = LicenseKeyGeneratorCli.run(new String[] {});
+ assertEquals(2, code);
+ });
+ assertTrue(out1.contains("Usage:"), "should print usage when missing args");
+
+ String out2 =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = LicenseKeyGeneratorCli.run(new String[] {"--userId", ""});
+ assertEquals(2, code);
+ });
+ assertTrue(out2.contains("must not be blank"), "should mention blank userId");
+ }
+}
diff --git a/license-generator/src/test/java/io/github/bsayli/license/cli/LicenseTokenCliIT.java b/license-generator/src/test/java/io/github/bsayli/license/cli/LicenseTokenCliIT.java
new file mode 100644
index 0000000..2c47ccd
--- /dev/null
+++ b/license-generator/src/test/java/io/github/bsayli/license/cli/LicenseTokenCliIT.java
@@ -0,0 +1,101 @@
+package io.github.bsayli.license.cli;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.github.stefanbirkner.systemlambda.SystemLambda;
+import io.jsonwebtoken.Jwts;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Date;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("it")
+@DisplayName("Integration Test: LicenseTokenCli")
+class LicenseTokenCliIT {
+
+ @Test
+ @DisplayName("valid EdDSA token should pass validation (exit 0)")
+ void valid_token_ok() throws Exception {
+ KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519");
+ KeyPair kp = kpg.generateKeyPair();
+
+ String token =
+ Jwts.builder()
+ .subject("user123")
+ .claim("licenseStatus", "ACTIVE")
+ .claim("licenseTier", "PRO")
+ .claim("message", "ok")
+ .issuedAt(Date.from(Instant.now()))
+ .expiration(Date.from(Instant.now().plusSeconds(3600)))
+ .signWith(kp.getPrivate())
+ .compact();
+
+ String pubB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
+
+ String out =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code =
+ LicenseTokenCli.run(new String[] {"--publicKey", pubB64, "--token", token});
+ assertEquals(0, code, "exit code should be 0 for valid token");
+ });
+
+ assertTrue(out.contains("License token is VALID"));
+ assertTrue(out.contains("status"));
+ assertTrue(out.contains("tier"));
+ assertTrue(out.contains("expiration"));
+ }
+
+ @Test
+ @DisplayName("expired token should exit with 4 and log EXPIRED")
+ void expired_token_exits_4() throws Exception {
+ KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519");
+ KeyPair kp = kpg.generateKeyPair();
+
+ String expired =
+ Jwts.builder()
+ .subject("user123")
+ .claim("licenseStatus", "ACTIVE")
+ .claim("licenseTier", "PRO")
+ .issuedAt(Date.from(Instant.now().minusSeconds(7200)))
+ .expiration(Date.from(Instant.now().minusSeconds(3600)))
+ .signWith(kp.getPrivate())
+ .compact();
+
+ String pubB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
+
+ String out =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code =
+ LicenseTokenCli.run(new String[] {"--publicKey", pubB64, "--token", expired});
+ assertEquals(4, code, "expired token should exit with 4");
+ });
+
+ assertTrue(out.contains("EXPIRED"));
+ }
+
+ @Test
+ @DisplayName("usage errors: missing args should exit with 2 and print usage")
+ void usage_errors_exit_2() throws Exception {
+ String out1 =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = LicenseTokenCli.run(new String[] {"--token", "x"});
+ assertEquals(2, code);
+ });
+ assertTrue(out1.contains("Usage:"));
+
+ String out2 =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = LicenseTokenCli.run(new String[] {"--publicKey", "y"});
+ assertEquals(2, code);
+ });
+ assertTrue(out2.contains("Usage:"));
+ }
+}
diff --git a/license-generator/src/test/java/io/github/bsayli/license/cli/SignatureCliIT.java b/license-generator/src/test/java/io/github/bsayli/license/cli/SignatureCliIT.java
new file mode 100644
index 0000000..b834f72
--- /dev/null
+++ b/license-generator/src/test/java/io/github/bsayli/license/cli/SignatureCliIT.java
@@ -0,0 +1,112 @@
+package io.github.bsayli.license.cli;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.github.stefanbirkner.systemlambda.SystemLambda;
+import java.util.Base64;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("it")
+@DisplayName("Integration Test: SignatureCli")
+class SignatureCliIT {
+
+ private static final Pattern JSON_ANY = Pattern.compile("\\{[^\\r\\n]*\\}");
+ private static final Pattern SIG_ANY = Pattern.compile("Signature \\(Base64\\):\\s*(\\S+)");
+
+ private static String extractFirst(Pattern p, String text, String errorIfMissing) {
+ var m = p.matcher(text);
+ if (m.find()) return m.groupCount() >= 1 && m.group(1) != null ? m.group(1) : m.group(0);
+ fail(errorIfMissing + "\n--- OUTPUT ---\n" + text);
+ return null;
+ }
+
+ @Test
+ @DisplayName("sign (licenseKey) → verify should succeed (exit 0)")
+ void sign_then_verify_ok() throws Exception {
+ var kpg = java.security.KeyPairGenerator.getInstance("Ed25519");
+ var kp = kpg.generateKeyPair();
+ String privB64 = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
+ String pubB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
+
+ String[] signArgs = {
+ "--mode", "sign",
+ "--serviceId", "crm",
+ "--serviceVersion", "1.2.3",
+ "--instanceId", "crm~mac~00:11:22:33:44:55",
+ "--licenseKey", "BSAYLI~RANDOM~encUserId",
+ "--privateKey", privB64
+ };
+
+ String signOut =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = SignatureCli.run(signArgs);
+ assertEquals(0, code, "sign exit code must be 0");
+ });
+ assertTrue(signOut.contains("Signed payload JSON:"), "sign output should contain header");
+
+ String json = extractFirst(JSON_ANY, signOut, "JSON payload not found in output");
+ String signatureB64 = extractFirst(SIG_ANY, signOut, "Signature not found in output");
+
+ String[] verifyArgs = {
+ "--mode", "verify",
+ "--dataJson", json,
+ "--signatureB64", signatureB64,
+ "--publicKey", pubB64
+ };
+
+ String verifyOut =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = SignatureCli.run(verifyArgs);
+ assertEquals(0, code, "verify exit code must be 0");
+ });
+ assertTrue(verifyOut.contains("Signature is VALID"), "verify should log VALID");
+ }
+
+ @Test
+ @DisplayName("verify with wrong public key should fail (exit 4)")
+ void verify_with_wrong_pubkey_fails() throws Exception {
+ var kpg = java.security.KeyPairGenerator.getInstance("Ed25519");
+ var kp = kpg.generateKeyPair();
+ String privB64 = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
+
+ String[] signArgs = {
+ "--mode", "sign",
+ "--serviceId", "svc",
+ "--serviceVersion", "2.0.0",
+ "--instanceId", "svc~node~aa:bb:cc:dd:ee:ff",
+ "--licenseKey", "BSAYLI~X~enc",
+ "--privateKey", privB64
+ };
+
+ String signOut =
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = SignatureCli.run(signArgs);
+ assertEquals(0, code);
+ });
+
+ String json = extractFirst(JSON_ANY, signOut, "JSON payload not found");
+ String sig = extractFirst(SIG_ANY, signOut, "Signature not found");
+
+ var wrong = kpg.generateKeyPair();
+ String wrongPubB64 = Base64.getEncoder().encodeToString(wrong.getPublic().getEncoded());
+
+ String[] verifyArgs = {
+ "--mode", "verify",
+ "--dataJson", json,
+ "--signatureB64", sig,
+ "--publicKey", wrongPubB64
+ };
+
+ SystemLambda.tapSystemOutNormalized(
+ () -> {
+ int code = SignatureCli.run(verifyArgs);
+ assertEquals(4, code, "verify with wrong pubkey should exit with 4");
+ });
+ }
+}
diff --git a/license-generator/src/test/java/io/github/bsayli/license/cli/service/Ed25519KeyServiceTest.java b/license-generator/src/test/java/io/github/bsayli/license/cli/service/Ed25519KeyServiceTest.java
new file mode 100644
index 0000000..797bb34
--- /dev/null
+++ b/license-generator/src/test/java/io/github/bsayli/license/cli/service/Ed25519KeyServiceTest.java
@@ -0,0 +1,77 @@
+package io.github.bsayli.license.cli.service;
+
+import static io.github.bsayli.license.common.CryptoConstants.B64_DEC;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.*;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+@Tag("unit")
+@DisplayName("Unit Test: Ed25519KeyService")
+class Ed25519KeyServiceTest {
+
+ private final Ed25519KeyService service = new Ed25519KeyService();
+
+ @Test
+ @DisplayName("generate() should return Base64 encodings decodable to valid Ed25519 keys")
+ void generate_keys_areValid_andDecodable() throws Exception {
+ var keys = service.generate();
+
+ assertNotNull(keys);
+ assertNotNull(keys.publicSpkiB64());
+ assertNotNull(keys.privatePkcs8B64());
+ assertFalse(keys.publicSpkiB64().isBlank());
+ assertFalse(keys.privatePkcs8B64().isBlank());
+
+ byte[] pubDer = B64_DEC.decode(keys.publicSpkiB64());
+ byte[] privDer = B64_DEC.decode(keys.privatePkcs8B64());
+
+ KeyFactory kf = KeyFactory.getInstance("Ed25519");
+
+ var pub = kf.generatePublic(new X509EncodedKeySpec(pubDer));
+ var priv = kf.generatePrivate(new PKCS8EncodedKeySpec(privDer));
+
+ assertTrue(
+ java.util.Set.of("Ed25519", "EdDSA").contains(pub.getAlgorithm()),
+ "Algorithm should be Ed25519/EdDSA");
+ assertTrue(
+ java.util.Set.of("Ed25519", "EdDSA").contains(priv.getAlgorithm()),
+ "Algorithm should be Ed25519/EdDSA");
+
+ assertArrayEquals(pubDer, pub.getEncoded());
+ assertArrayEquals(privDer, priv.getEncoded());
+ }
+
+ @Test
+ @DisplayName("writeString() should create parent dirs and write exact content")
+ void writeString_createsDirs_andWrites(@TempDir Path tmp) throws IOException {
+ Path nested = tmp.resolve("keys/out/public.txt");
+ String content = "hello-world";
+
+ service.writeString(nested, content);
+
+ assertTrue(Files.exists(nested), "File must exist");
+ assertEquals(content, Files.readString(nested));
+ }
+
+ @Test
+ @DisplayName("ensureBouncyCastleIfNeeded() is idempotent and never throws")
+ void ensureBouncyCastle_isIdempotent_andNoThrow() {
+ assertDoesNotThrow(service::ensureBouncyCastleIfNeeded);
+ assertDoesNotThrow(service::ensureBouncyCastleIfNeeded);
+ }
+
+ @Test
+ @DisplayName("generate() works with either JDK or BouncyCastle provider")
+ void generate_worksWithEitherProvider() {
+ assertDoesNotThrow(service::generate);
+ }
+}
diff --git a/license-generator/src/test/java/io/github/bsayli/license/cli/service/KeygenServiceTest.java b/license-generator/src/test/java/io/github/bsayli/license/cli/service/KeygenServiceTest.java
new file mode 100644
index 0000000..e62f1ec
--- /dev/null
+++ b/license-generator/src/test/java/io/github/bsayli/license/cli/service/KeygenServiceTest.java
@@ -0,0 +1,60 @@
+package io.github.bsayli.license.cli.service;
+
+import static io.github.bsayli.license.common.CryptoConstants.B64_DEC;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("unit")
+@DisplayName("Unit Test: KeygenService")
+class KeygenServiceTest {
+
+ private final KeygenService svc = new KeygenService();
+
+ @Test
+ @DisplayName("AES sizes 128/192/256 should generate decodable Base64")
+ void aes_sizes_ok() throws NoSuchAlgorithmException {
+ for (int size : new int[] {128, 192, 256}) {
+ var out = svc.generateAes(size);
+ assertEquals(size, out.sizeBits());
+ assertNotNull(out.base64());
+ assertFalse(out.base64().isBlank());
+ assertDoesNotThrow(() -> B64_DEC.decode(out.base64()));
+ }
+ }
+
+ @Test
+ @DisplayName("Invalid AES size should throw")
+ void aes_invalid_size_throws() {
+ assertThrows(IllegalArgumentException.class, () -> svc.generateAes(64));
+ assertThrows(IllegalArgumentException.class, () -> svc.generateAes(999));
+ }
+
+ @Test
+ @DisplayName("Ed25519 pair should be decodable to valid keys")
+ void ed25519_ok() throws Exception {
+ var pair = svc.generateEd25519();
+ assertNotNull(pair.publicSpkiB64());
+ assertNotNull(pair.privatePkcs8B64());
+
+ byte[] pubDer = B64_DEC.decode(pair.publicSpkiB64());
+ byte[] privDer = B64_DEC.decode(pair.privatePkcs8B64());
+
+ var kf = KeyFactory.getInstance("Ed25519");
+ var pub = kf.generatePublic(new X509EncodedKeySpec(pubDer));
+ var prv = kf.generatePrivate(new PKCS8EncodedKeySpec(privDer));
+
+ // JDK bazı ortamlarda "EdDSA" döndürebiliyor — her ikisini de kabul et.
+ assertTrue(java.util.Set.of("Ed25519", "EdDSA").contains(pub.getAlgorithm()));
+ assertTrue(java.util.Set.of("Ed25519", "EdDSA").contains(prv.getAlgorithm()));
+
+ assertArrayEquals(pubDer, pub.getEncoded());
+ assertArrayEquals(privDer, prv.getEncoded());
+ }
+}
diff --git a/license-generator/src/test/java/io/github/bsayli/license/cli/service/LicenseKeyServiceTest.java b/license-generator/src/test/java/io/github/bsayli/license/cli/service/LicenseKeyServiceTest.java
new file mode 100644
index 0000000..9950e1c
--- /dev/null
+++ b/license-generator/src/test/java/io/github/bsayli/license/cli/service/LicenseKeyServiceTest.java
@@ -0,0 +1,45 @@
+package io.github.bsayli.license.cli.service;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import io.github.bsayli.license.common.LicenseConstants;
+import java.security.GeneralSecurityException;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("unit")
+@DisplayName("Unit Test: LicenseKeyService")
+class LicenseKeyServiceTest {
+
+ private final LicenseKeyService svc = new LicenseKeyService();
+
+ @Test
+ @DisplayName("generate() should return a 3-part key and consistent segments")
+ void generate_ok() throws GeneralSecurityException {
+ String userId = "11111111-2222-3333-4444-555555555555";
+
+ var res = svc.generate(userId);
+
+ assertNotNull(res);
+ assertNotNull(res.licenseKey());
+ assertFalse(res.licenseKey().isBlank());
+
+ String[] parts = res.licenseKey().split(LicenseConstants.LICENSE_DELIMITER);
+ assertEquals(3, parts.length, "License key must have 3 segments");
+ assertEquals(res.prefix(), parts[0]);
+ assertEquals(res.randomString(), parts[1]);
+ assertEquals(res.encryptedUserId(), parts[2]);
+
+ Pattern urlSafe = Pattern.compile("^[A-Za-z0-9_-]+$");
+ assertTrue(urlSafe.matcher(res.randomString()).matches(), "Random must be URL-safe Base64");
+ }
+
+ @Test
+ @DisplayName("Blank userId should throw IllegalArgumentException")
+ void blank_userId_throws() {
+ assertThrows(IllegalArgumentException.class, () -> svc.generate(" "));
+ assertThrows(IllegalArgumentException.class, () -> svc.generate(null));
+ }
+}
diff --git a/license-generator/src/test/java/io/github/bsayli/license/cli/service/LicenseTokenServiceTest.java b/license-generator/src/test/java/io/github/bsayli/license/cli/service/LicenseTokenServiceTest.java
new file mode 100644
index 0000000..fc90c7a
--- /dev/null
+++ b/license-generator/src/test/java/io/github/bsayli/license/cli/service/LicenseTokenServiceTest.java
@@ -0,0 +1,37 @@
+package io.github.bsayli.license.cli.service;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("unit")
+@DisplayName("Unit Test: LicenseTokenService")
+class LicenseTokenServiceTest {
+
+ private final LicenseTokenService svc = new LicenseTokenService();
+
+ @Test
+ @DisplayName("Blank inputs should throw IllegalArgumentException")
+ void blank_inputs_throw() {
+ assertThrows(IllegalArgumentException.class, () -> svc.validate(null, "t"));
+ assertThrows(IllegalArgumentException.class, () -> svc.validate(" ", "t"));
+ assertThrows(IllegalArgumentException.class, () -> svc.validate("pk", null));
+ assertThrows(IllegalArgumentException.class, () -> svc.validate("pk", " "));
+ }
+
+ @Test
+ @DisplayName("Invalid key or malformed JWT should raise an exception from extractor")
+ void invalid_inputs_bubble_up() {
+
+ String fakeSpkiB64 =
+ Base64.getEncoder().encodeToString("not-a-real-spki".getBytes(StandardCharsets.UTF_8));
+
+ String badJwt = "aaa.bbb.ccc";
+
+ assertThrows(RuntimeException.class, () -> svc.validate(fakeSpkiB64, badJwt));
+ }
+}
diff --git a/license-generator/src/test/java/io/github/bsayli/license/cli/service/SignatureServiceTest.java b/license-generator/src/test/java/io/github/bsayli/license/cli/service/SignatureServiceTest.java
new file mode 100644
index 0000000..0a64035
--- /dev/null
+++ b/license-generator/src/test/java/io/github/bsayli/license/cli/service/SignatureServiceTest.java
@@ -0,0 +1,38 @@
+package io.github.bsayli.license.cli.service;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("unit")
+@DisplayName("Unit Test: SignatureService")
+class SignatureServiceTest {
+
+ private final SignatureService svc = new SignatureService();
+
+ @Test
+ @DisplayName("Blank inputs should throw IllegalArgumentException")
+ void blank_inputs_throw() {
+ assertThrows(
+ IllegalArgumentException.class, () -> svc.signWithLicenseKey("", "v", "i", "k", "pk"));
+ assertThrows(IllegalArgumentException.class, () -> svc.signWithToken("s", "", "i", "t", "pk"));
+ assertThrows(IllegalArgumentException.class, () -> svc.verify("", "{}", "sig"));
+ }
+
+ @Test
+ @DisplayName("Invalid keys should bubble up as GeneralSecurityException from sign/verify")
+ void invalid_keys_bubble_up() {
+ String fakePriv = "not-a-real-pkcs8";
+ String fakePub = "not-a-real-spki";
+
+ assertThrows(
+ IllegalArgumentException.class, () -> svc.signWithLicenseKey("s", "v", "i", "K", fakePriv));
+
+ assertThrows(
+ IllegalArgumentException.class, () -> svc.signWithToken("s", "v", "i", "T", fakePriv));
+
+ assertThrows(IllegalArgumentException.class, () -> svc.verify(fakePub, "{}", "sig"));
+ }
+}
diff --git a/license-generator/src/test/java/io/github/bsayli/license/cli/service/UserIdCryptoServiceTest.java b/license-generator/src/test/java/io/github/bsayli/license/cli/service/UserIdCryptoServiceTest.java
new file mode 100644
index 0000000..1d388c9
--- /dev/null
+++ b/license-generator/src/test/java/io/github/bsayli/license/cli/service/UserIdCryptoServiceTest.java
@@ -0,0 +1,60 @@
+package io.github.bsayli.license.cli.service;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.security.GeneralSecurityException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("unit")
+@DisplayName("Unit Test: UserIdCryptoService")
+class UserIdCryptoServiceTest {
+
+ private final UserIdCryptoService svc = new UserIdCryptoService();
+
+ @Test
+ @DisplayName("encrypt/decrypt round-trip should succeed")
+ void roundTrip_ok() throws Exception {
+ String userId = "2f0f0a2a-1111-2222-3333-abcdefabcdef";
+ String enc = svc.encrypt(userId);
+ assertNotNull(enc);
+ assertNotEquals(userId, enc);
+
+ String dec = svc.decrypt(enc);
+ assertEquals(userId, dec);
+ }
+
+ @Test
+ @DisplayName("encrypt with blank userId should throw IAE")
+ void encrypt_blank_throws() {
+ assertThrows(IllegalArgumentException.class, () -> svc.encrypt(" "));
+ assertThrows(IllegalArgumentException.class, () -> svc.encrypt(null));
+ }
+
+ @Test
+ @DisplayName("decrypt with blank ciphertext should throw IAE")
+ void decrypt_blank_throws() {
+ assertThrows(IllegalArgumentException.class, () -> svc.decrypt(" "));
+ assertThrows(IllegalArgumentException.class, () -> svc.decrypt(null));
+ }
+
+ @Test
+ @DisplayName("decrypt with malformed Base64 should throw IAE")
+ void decrypt_malformedBase64_throwsIAE() {
+ assertThrows(IllegalArgumentException.class, () -> svc.decrypt("not-base64!!"));
+ }
+
+ @Test
+ @DisplayName("decrypt with tampered ciphertext should throw GSE")
+ void decrypt_tampered_throwsGSE() throws Exception {
+ String userId = "2f0f0a2a-1111-2222-3333-abcdefabcdef";
+ String enc = svc.encrypt(userId);
+
+ byte[] bytes = java.util.Base64.getDecoder().decode(enc);
+ bytes[bytes.length - 1] ^= 0x01;
+ String tampered = java.util.Base64.getEncoder().encodeToString(bytes);
+
+ assertThrows(GeneralSecurityException.class, () -> svc.decrypt(tampered));
+ }
+}
diff --git a/license-generator/src/test/java/io/github/bsayli/license/signature/SignatureDemoTest.java b/license-generator/src/test/java/io/github/bsayli/license/signature/SignatureDemoTest.java
new file mode 100644
index 0000000..7190e57
--- /dev/null
+++ b/license-generator/src/test/java/io/github/bsayli/license/signature/SignatureDemoTest.java
@@ -0,0 +1,113 @@
+package io.github.bsayli.license.signature;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.util.Base64;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+
+@Tag("unit")
+@DisplayName("Unit Test: SignatureDemo")
+class SignatureDemoTest {
+
+ private static final Pattern JSON_ANY = Pattern.compile("\\{[\\s\\S]*?\\}", Pattern.DOTALL);
+ private static final Pattern SIG_ANY = Pattern.compile("Signature \\(Base64\\):\\s*(\\S+)");
+
+ private static KeyPair ed25519() throws Exception {
+ return KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
+ }
+
+ private static String b64(byte[] bytes) {
+ return Base64.getEncoder().encodeToString(bytes);
+ }
+
+ private static ListAppender attachAppender() {
+ Logger logger = (Logger) LoggerFactory.getLogger(SignatureDemo.class);
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ logger.addAppender(appender);
+ return appender;
+ }
+
+ private static String logsToString(ListAppender appender) {
+ StringBuilder sb = new StringBuilder();
+ for (ILoggingEvent e : appender.list) sb.append(e.getFormattedMessage()).append('\n');
+ return sb.toString();
+ }
+
+ @Test
+ @DisplayName("sign-sample-key: returns 0, logs JSON with encryptedLicenseKeyHash and signature")
+ void sign_sample_key_ok() throws Exception {
+ var kp = ed25519();
+ String privB64 = b64(kp.getPrivate().getEncoded());
+
+ var app = attachAppender();
+ int code =
+ SignatureDemo.run(new String[] {"--mode", "sign-sample-key", "--privateKey", privB64});
+ String out = logsToString(app);
+
+ assertEquals(0, code);
+ assertTrue(out.contains("Signed payload JSON"));
+ assertTrue(JSON_ANY.matcher(out).find());
+ var m = SIG_ANY.matcher(out);
+ assertTrue(m.find());
+ assertDoesNotThrow(() -> Base64.getDecoder().decode(m.group(1)));
+ assertTrue(out.contains("\"encryptedLicenseKeyHash\""));
+ assertFalse(out.contains("\"licenseTokenHash\""));
+ }
+
+ @Test
+ @DisplayName("sign-sample-token: returns 0, logs JSON with licenseTokenHash and signature")
+ void sign_sample_token_ok() throws Exception {
+ var kp = ed25519();
+ String privB64 = b64(kp.getPrivate().getEncoded());
+
+ var app = attachAppender();
+ int code =
+ SignatureDemo.run(new String[] {"--mode", "sign-sample-token", "--privateKey", privB64});
+ String out = logsToString(app);
+
+ assertEquals(0, code);
+ assertTrue(out.contains("Signed payload JSON"));
+ assertTrue(JSON_ANY.matcher(out).find());
+ var m = SIG_ANY.matcher(out);
+ assertTrue(m.find());
+ assertDoesNotThrow(() -> Base64.getDecoder().decode(m.group(1)));
+ assertTrue(out.contains("\"licenseTokenHash\""));
+ assertFalse(out.contains("\"encryptedLicenseKeyHash\""));
+ }
+
+ @Test
+ @DisplayName("missing args: returns 2 and prints usage")
+ void missing_args_exit_2() {
+ var app = attachAppender();
+ int code = SignatureDemo.run(new String[] {});
+ String out = logsToString(app);
+
+ assertEquals(2, code);
+ assertTrue(out.contains("Usage:"));
+ }
+
+ @Test
+ @DisplayName("invalid mode: returns 2, logs error and usage")
+ void invalid_mode_exit_2() throws Exception {
+ var kp = ed25519();
+ String privB64 = b64(kp.getPrivate().getEncoded());
+
+ var app = attachAppender();
+ int code = SignatureDemo.run(new String[] {"--mode", "bad-mode", "--privateKey", privB64});
+ String out = logsToString(app);
+
+ assertEquals(2, code);
+ assertTrue(out.contains("Invalid --mode"));
+ assertTrue(out.contains("Usage:"));
+ }
+}