From bcddb869deba0fb24482ceeec3061fd07fde122d Mon Sep 17 00:00:00 2001 From: barissayli Date: Sun, 14 Sep 2025 13:34:29 -0600 Subject: [PATCH] test(license-generator): add missing unit & integration tests for CLI and signature flows - Added integration tests for LicenseTokenCli, LicenseKeyGeneratorCli, KeygenCli, EncryptUserIdCli, and Ed25519KeygenCli - Added unit test for SignatureDemo to cover sample signing flows - Refactored CLIs to expose run() methods for testability - Improved validation and error handling coverage Next steps: - Raise overall test coverage toward 70%+ target - Add Vault integration for secrets management - Extend Keycloak integration for license lifecycle operations --- license-generator/pom.xml | 8 + .../bsayli/license/cli/Ed25519KeygenCli.java | 114 ++++----- .../bsayli/license/cli/EncryptUserIdCli.java | 172 ++++++------- .../github/bsayli/license/cli/KeygenCli.java | 106 ++++---- .../license/cli/LicenseKeyGeneratorCli.java | 90 +++---- .../bsayli/license/cli/LicenseTokenCli.java | 92 +++---- .../bsayli/license/cli/SignatureCli.java | 237 +++++++++--------- .../cli/service/Ed25519KeyService.java | 68 +++++ .../license/cli/service/KeygenService.java | 38 +++ .../cli/service/LicenseKeyService.java | 35 +++ .../cli/service/LicenseTokenService.java | 28 +++ .../license/cli/service/SignatureService.java | 112 +++++++++ .../cli/service/UserIdCryptoService.java | 32 +++ .../license/signature/SignatureDemo.java | 61 ++--- .../license/cli/Ed25519KeygenCliIT.java | 99 ++++++++ .../license/cli/EncryptUserIdCliIT.java | 88 +++++++ .../bsayli/license/cli/KeygenCliIT.java | 124 +++++++++ .../license/cli/LicenseKeyGeneratorCliIT.java | 75 ++++++ .../bsayli/license/cli/LicenseTokenCliIT.java | 101 ++++++++ .../bsayli/license/cli/SignatureCliIT.java | 112 +++++++++ .../cli/service/Ed25519KeyServiceTest.java | 77 ++++++ .../cli/service/KeygenServiceTest.java | 60 +++++ .../cli/service/LicenseKeyServiceTest.java | 45 ++++ .../cli/service/LicenseTokenServiceTest.java | 37 +++ .../cli/service/SignatureServiceTest.java | 38 +++ .../cli/service/UserIdCryptoServiceTest.java | 60 +++++ .../license/signature/SignatureDemoTest.java | 113 +++++++++ 27 files changed, 1753 insertions(+), 469 deletions(-) create mode 100644 license-generator/src/main/java/io/github/bsayli/license/cli/service/Ed25519KeyService.java create mode 100644 license-generator/src/main/java/io/github/bsayli/license/cli/service/KeygenService.java create mode 100644 license-generator/src/main/java/io/github/bsayli/license/cli/service/LicenseKeyService.java create mode 100644 license-generator/src/main/java/io/github/bsayli/license/cli/service/LicenseTokenService.java create mode 100644 license-generator/src/main/java/io/github/bsayli/license/cli/service/SignatureService.java create mode 100644 license-generator/src/main/java/io/github/bsayli/license/cli/service/UserIdCryptoService.java create mode 100644 license-generator/src/test/java/io/github/bsayli/license/cli/Ed25519KeygenCliIT.java create mode 100644 license-generator/src/test/java/io/github/bsayli/license/cli/EncryptUserIdCliIT.java create mode 100644 license-generator/src/test/java/io/github/bsayli/license/cli/KeygenCliIT.java create mode 100644 license-generator/src/test/java/io/github/bsayli/license/cli/LicenseKeyGeneratorCliIT.java create mode 100644 license-generator/src/test/java/io/github/bsayli/license/cli/LicenseTokenCliIT.java create mode 100644 license-generator/src/test/java/io/github/bsayli/license/cli/SignatureCliIT.java create mode 100644 license-generator/src/test/java/io/github/bsayli/license/cli/service/Ed25519KeyServiceTest.java create mode 100644 license-generator/src/test/java/io/github/bsayli/license/cli/service/KeygenServiceTest.java create mode 100644 license-generator/src/test/java/io/github/bsayli/license/cli/service/LicenseKeyServiceTest.java create mode 100644 license-generator/src/test/java/io/github/bsayli/license/cli/service/LicenseTokenServiceTest.java create mode 100644 license-generator/src/test/java/io/github/bsayli/license/cli/service/SignatureServiceTest.java create mode 100644 license-generator/src/test/java/io/github/bsayli/license/cli/service/UserIdCryptoServiceTest.java create mode 100644 license-generator/src/test/java/io/github/bsayli/license/signature/SignatureDemoTest.java 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:")); + } +}