Main class

package app.lukittu.lukkittujavaexample;

import org.bukkit.plugin.java.JavaPlugin;

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

/**
 * Main plugin class for the Lukittu Java Example.
 * This class handles license verification and periodic heartbeat checks.
 * Note: Both this main class and the license verification class should be
 * heavily obfuscated and native obfuscated for security purposes.
 *
 * Recommended obfuscators:
 * - Zelix KlassMaster (https://www.zelix.com/)
 * - JNIC (https://jnic.dev/)
 */
public final class LukkittuJavaExample extends JavaPlugin {

    /**
     * Static instance of the plugin for global access.
     */
    public static LukkittuJavaExample INSTANCE;

    /**
     * Flag indicating whether the license is valid.
     */
    public boolean valid;

    /**
     * Called when the plugin is enabled.
     * Initializes the plugin instance, verifies the license key,
     * and sets up periodic heartbeat checks.
     */
    @Override
    public void onEnable() {
        INSTANCE = this;

        String licenseKey = "KEY";

        // Verify the license key on startup
        LukittuLicenseVerify.verifyKey(licenseKey);

        if (!valid) {
            // Handle invalid license case
            // Implementation should be added here
            return;
        }

        // Set up periodic heartbeat checks every 15 minutes
        setupHeartbeatScheduler(licenseKey);
    }

    /**
     * Sets up a scheduled executor to send periodic heartbeat requests.
     *
     * @param licenseKey The license key to validate in heartbeat requests
     */
    private void setupHeartbeatScheduler(String licenseKey) {
        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
        scheduler.scheduleAtFixedRate(() -> {
            try {
                LukittuLicenseVerify.sendHeartbeat("TEAM_ID", licenseKey, "PRODUCT_ID");
            } catch (Exception ignored) {
                // Heartbeat failures are silently ignored
            }
        }, 15, 15, TimeUnit.MINUTES);
    }

    /**
     * Called when the plugin is disabled.
     * Current implementation contains no shutdown logic.
     */
    @Override
    public void onDisable() {
        // Plugin shutdown logic
    }
}

License class

package app.lukittu.lukkittujavaexample;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.Reader;
import java.lang.reflect.Field;
import java.net.HttpURLConnection;
import java.net.InetAddress;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;

/**
 * Handles license verification and validation for the Lukittu licensing system.
 * This class should be natively obfuscated along with the main class of the JAR
 * for better security. It is recommended to encrypt the PUBLIC_KEY_BASE_64
 * using
 * your own encryption method, as most public obfuscators have reverse tools for
 * string obfuscation.
 */
public class LukittuLicenseVerify {

    private static final String R_KEY = "re" + "su" + "lt";
    private static final String V_KEY = "va" + "li" + "d";
    private static final Map<String, String> ERROR_MESSAGES;
    public static String DEVICE_IDENTIFIER;

    static {
        Map<String, String> messages = new HashMap<>();
        messages.put("RELEASE_NOT_FOUND", "Invalid version specified in config.");
        messages.put("LICENSE_NOT_FOUND", "License not specified in config.yml, or it is invalid.");
        messages.put("IP_LIMIT_REACHED",
                "License's IP address limit has been reached. Contact support if you have issues with this.");
        messages.put("MAXIMUM_CONCURRENT_SEATS", "Maximum devices connected from the same license.");
        messages.put("RATE_LIMIT",
                "Too many connections in a short time from the same IP address. Please wait a while!");
        messages.put("LICENSE_EXPIRED", "The license has expired.");
        messages.put("INTERNAL_SERVER_ERROR", "Upstream service has issues. Please notify support!");
        ERROR_MESSAGES = Collections.unmodifiableMap(messages);
    }

    /**
     * Verifies a license key by making an API call to the Lukittu verification
     * service.
     * Generates a random challenge and validates the response signature.
     *
     * @param key The license key to verify
     */
    public static void verifyKey(String key) {
        DEVICE_IDENTIFIER = getHardwareIdentifier();
        String PUBLIC_KEY_BASE_64 = "YOUR_PUBLIC_KEY";

        // Generate a random challenge
        SecureRandom secureRandom = new SecureRandom();
        byte[] randomBytes = new byte[32];
        secureRandom.nextBytes(randomBytes);

        String challenge = bytesToHex(randomBytes);

        // Construct the URL for the API call with team ID
        String TEAM_ID = "YOUR_TEAM_ID";
        String url = "https://app.lukittu.com/api/v1/client/teams/${TEAM_ID}/verification/verify"
                .replace("${TEAM_ID}", TEAM_ID);

        String jsonBody = String.format("{\n" +
                "  \"licenseKey\": \"%s\",\n" +
                "  \"productId\": \"%s\",\n" +
                "  \"challenge\": \"%s\",\n" +
                "  \"version\": \"%s\",\n" +
                "  \"deviceIdentifier\": \"%s\"\n" +
                "}", key, "YOUR_PRODUCT_ID", challenge, "1.0.0", DEVICE_IDENTIFIER);

        fetchAndHandleResponse(url, jsonBody, PUBLIC_KEY_BASE_64, challenge);
    }

    /**
     * Converts a byte array to its hexadecimal string representation.
     *
     * @param bytes The byte array to convert
     * @return A string containing the hexadecimal representation of the bytes
     */
    public static String bytesToHex(byte[] bytes) {
        StringBuilder result = new StringBuilder(bytes.length * 2);
        for (byte b : bytes) {
            result.append(String.format("%02x", b));
        }
        return result.toString();
    }

    /**
     * Makes an HTTP request to the Lukittu API and handles the response.
     *
     * @param urlString          The URL to send the request to
     * @param jsonBody           The JSON request body
     * @param PUBLIC_KEY_BASE_64 The public key used for response verification
     * @param challenge          The challenge string to verify in the response
     */
    public static void fetchAndHandleResponse(String urlString, String jsonBody, String PUBLIC_KEY_BASE_64,
            String challenge) {
        HttpURLConnection connection = null;
        try {
            URL url = new URL(urlString);
            connection = (HttpURLConnection) url.openConnection();
            connection.setRequestMethod("POST");
            connection.setRequestProperty("Content-Type", "application/json");
            connection.setRequestProperty("User-Agent", buildUserAgent());
            connection.setDoOutput(true);

            try (OutputStream os = connection.getOutputStream()) {
                byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
                os.write(input, 0, input.length);
            }

            if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) {
                handleJsonResponse(connection.getInputStream(), PUBLIC_KEY_BASE_64, challenge);
            }
        } catch (Exception e) {
            try {
                handleJsonResponse(connection.getErrorStream(), null, null);
            } catch (IOException e1) {
                System.out.println("ERROR: JSON response: " + e1.getMessage());
            }
        } finally {
            if (connection != null) {
                connection.disconnect();
            }
        }
    }

    /**
     * Processes the JSON response from the API and validates its contents.
     *
     * @param inputStream The input stream containing the JSON response
     * @param publickey   The public key for signature verification
     * @param challenge   The original challenge to verify
     * @throws IOException If there's an error reading the response
     */
    private static void handleJsonResponse(InputStream inputStream, String publickey, String challenge)
            throws IOException {
        if (inputStream == null) {
            throw new IOException("Input stream is null");
        }

        final Gson gson = new GsonBuilder()
                .disableHtmlEscaping()
                .setPrettyPrinting()
                .create();

        try (Reader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
            JsonObject json = gson.fromJson(reader, JsonObject.class);

            if (validateResponse(json) && validateChallenge(json, challenge, publickey)) {
                setValidState();
                return;
            }

            String resp = gson.toJson(json);
            logResponse(resp);
            handleErrorCodes(resp);
        }
    }

    /**
     * Validates the challenge response from the server.
     *
     * @param response          The JSON response object from the server
     * @param originalChallenge The original challenge string sent to the server
     * @param base64PublicKey   The base64-encoded public key for signature
     *                          verification
     * @return true if the challenge response is valid, false otherwise
     */
    public static boolean validateChallenge(JsonObject response, String originalChallenge, String base64PublicKey) {
        try {
            if (!validateResponse(response)) {
                return false;
            }

            String signedChallenge = response.getAsJsonObject("result")
                    .get("challengeResponse").getAsString();

            return verifySignature(originalChallenge, signedChallenge, base64PublicKey);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * Verifies the digital signature of the challenge response.
     *
     * @param challenge       The original challenge string
     * @param signatureHex    The hexadecimal signature to verify
     * @param base64PublicKey The base64-encoded public key
     * @return true if the signature is valid, false otherwise
     */
    public static boolean verifySignature(String challenge, String signatureHex, String base64PublicKey) {
        try {
            byte[] signatureBytes = hexStringToByteArray(signatureHex);
            byte[] decodedKeyBytes = Base64.getDecoder().decode(base64PublicKey);

            String decodedKeyString = new String(decodedKeyBytes)
                    .replace("-----BEGIN PUBLIC KEY-----", "")
                    .replace("-----END PUBLIC KEY-----", "")
                    .replaceAll("\\s", "");

            byte[] publicKeyBytes = Base64.getDecoder().decode(decodedKeyString);
            X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyBytes);
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            PublicKey publicKey = keyFactory.generatePublic(keySpec);

            Signature signature = Signature.getInstance("SHA256withRSA");
            signature.initVerify(publicKey);
            signature.update(challenge.getBytes());

            return signature.verify(signatureBytes);
        } catch (IllegalArgumentException e) {
            System.err.println("Invalid Base64 input for public key.");
            e.printStackTrace();
            return false;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * Converts a hexadecimal string to a byte array.
     *
     * @param hex The hexadecimal string to convert
     * @return The resulting byte array
     */
    private static byte[] hexStringToByteArray(String hex) {
        int len = hex.length();
        byte[] data = new byte[len / 2];
        for (int i = 0; i < len; i += 2) {
            data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
                    + Character.digit(hex.charAt(i + 1), 16));
        }
        return data;
    }

    /**
     * Validates the structure and content of the API response.
     *
     * @param json The JSON response object to validate
     * @return true if the response is valid, false otherwise
     */
    private static boolean validateResponse(JsonObject json) {
        try {
            JsonObject result = json.getAsJsonObject(R_KEY);
            return result != null &&
                    result.has(V_KEY) &&
                    result.get(V_KEY).getAsBoolean();
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * Sets the valid state in the main application class.
     */
    private static void setValidState() {
        try {
            Field validField = LukkittuJavaExample.class.getDeclaredField("valid");
            validField.setAccessible(true);
            validField.set(LukkittuJavaExample.INSTANCE, true);
        } catch (Exception ignored) {
        }
    }

    /**
     * Builds the User-Agent string for API requests.
     *
     * @return The formatted User-Agent string
     */
    private static String buildUserAgent() {
        return String.format("LukittuLoader/%s (%s %s; %s)",
                "1.0",
                System.getProperty("os.name"),
                System.getProperty("os.version"),
                System.getProperty("os.arch"));
    }

    /**
     * Logs the API response for debugging purposes.
     *
     * @param response The response string to log
     */
    private static void logResponse(String response) {
        if (response != null) {
            System.out.println("Received JSON response (pretty printed):");
            System.out.println(response);
        }
    }

    /**
     * Handles error codes from the API response and prints appropriate messages.
     *
     * @param response The response string to check for error codes
     */
    private static void handleErrorCodes(final String response) {
        if (response == null)
            return;

        ERROR_MESSAGES.entrySet().stream()
                .filter(entry -> response.contains(entry.getKey()))
                .findFirst()
                .ifPresent(entry -> System.err.println(entry.getValue()));
    }

    /**
     * Sends a heartbeat request to the Lukittu verification service.
     *
     * @param TEAM_ID     The team ID for the API request
     * @param LICENSE_KEY The license key to validate
     * @param PRODUCT_ID  The product ID associated with the license
     * @throws Exception If there's an error sending the heartbeat
     */
    public static void sendHeartbeat(String TEAM_ID, String LICENSE_KEY, String PRODUCT_ID) throws Exception {
        String urlString = "https://app.lukittu.com/api/v1/client/teams/${TEAM_ID}/verification/heartbeat"
                .replace("${TEAM_ID}", TEAM_ID);
        URL url = new URL(urlString);
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        connection.setRequestMethod("POST");
        connection.setRequestProperty("Content-Type", "application/json");
        connection.setRequestProperty("User-Agent", buildUserAgent());
        connection.setDoOutput(true);

        String jsonBody = String.format("{" +
                "\"licenseKey\":\"%s\"," +
                "\"productId\":\"%s\"," +
                "\"deviceIdentifier\":\"%s\"" +
                "}", LICENSE_KEY, PRODUCT_ID, DEVICE_IDENTIFIER);

        try (OutputStream os = connection.getOutputStream()) {
            byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
            os.write(input, 0, input.length);
        }

        int responseCode = connection.getResponseCode();

        try (InputStream is = (responseCode < HttpURLConnection.HTTP_BAD_REQUEST) ? connection.getInputStream()
                : connection.getErrorStream();
                BufferedReader br = new BufferedReader(new InputStreamReader(is))) {

            StringBuilder response = new StringBuilder();
            String line;
            while ((line = br.readLine()) != null) {
                response.append(line);
            }
        } catch (IOException ignored) {
        }

        connection.disconnect();
    }

    /**
     * Generates a hardware identifier based on system properties.
     * Note: This method may not work reliably in Docker environments.
     *
     * @return A unique hardware identifier string
     */
    public static String getHardwareIdentifier() {
        try {
            String osName = System.getProperty("os.name");
            String osVersion = System.getProperty("os.version");
            String osArch = System.getProperty("os.arch");
            String hostname = InetAddress.getLocalHost().getHostName();

            String combinedIdentifier = osName + osVersion + osArch + hostname;
            return UUID.nameUUIDFromBytes(combinedIdentifier.getBytes()).toString();
        } catch (Exception e) {
            LukkittuJavaExample.INSTANCE.getLogger().info("Hostname getting failed, contact support");
            return UUID.randomUUID().toString();
        }
    }
}