Proof of concept, now to figure out how it works!

This commit is contained in:
thetrouper
2025-03-25 06:15:10 -05:00
parent 8a755bd642
commit 96c758edd0
23 changed files with 608 additions and 18 deletions

32
SentinelAuth/build.gradle Normal file
View File

@@ -0,0 +1,32 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.4'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'me.trouper.sentinel'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
bootJar {
mainClass = 'me.trouper.sentinel.auth.SentinelAuth' // Replace with your actual main class
}

View File

@@ -0,0 +1,44 @@
package me.trouper.sentinel.auth;
import me.trouper.sentinel.auth.data.AuthRequest;
import me.trouper.sentinel.auth.data.AuthResponse;
import me.trouper.sentinel.auth.services.JarService;
import me.trouper.sentinel.auth.services.LicenseService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
@RestController
public class AuthController {
private final LicenseService licenseService;
private final JarService jarService;
public AuthController(LicenseService ls, JarService js) {
this.licenseService = ls;
this.jarService = js;
}
@PostMapping("/authenticate")
public ResponseEntity<Map<String, String>> authenticate(@RequestBody AuthRequest request) {
for (String key : licenseService.getLicenseKeys()) {
String serverCode = LicenseService.generateAuthCode(key);
if (serverCode.equals(request.authCode())) {
Map<String, String> response = new HashMap<>();
response.put("decryptionKey", jarService.getDecryptionKey());
if (!jarService.getJarHash().equals(request.clientHash())) {
response.put("jarData",
Base64.getEncoder().encodeToString(jarService.getEncryptedJar()));
}
return ResponseEntity.ok(response);
}
}
return ResponseEntity.status(401).build();
}
}

View File

@@ -0,0 +1,13 @@
package me.trouper.sentinel.auth;
import me.trouper.sentinel.auth.services.JarService;
import me.trouper.sentinel.auth.services.LicenseService;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SentinelAuth {
public static void main(String[] args) {
SpringApplication.run(SentinelAuth.class, args);
}
}

View File

@@ -0,0 +1,5 @@
package me.trouper.sentinel.auth.data;
public record AuthRequest(String authCode, String clientHash) {
}

View File

@@ -0,0 +1,5 @@
package me.trouper.sentinel.auth.data;
public record AuthResponse(byte[] jarData, String decryptionKey) {
}

View File

@@ -0,0 +1,59 @@
package me.trouper.sentinel.auth.services;
import jakarta.annotation.PostConstruct;
import me.trouper.sentinel.auth.utils.DigestUtils;
import org.springframework.stereotype.Service;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Base64;
@Service
public class JarService {
private byte[] unencryptedJar;
private byte[] encryptedJar; // Store encrypted JAR
private String jarHash;
private String decryptionKey; // Will be initialized
private final Path unencryptedJarPath = Path.of("storage/SentinelPlugin-1.0-SNAPSHOT.jar");
@PostConstruct
public void init() throws IOException, GeneralSecurityException {
if (Files.exists(unencryptedJarPath)) {
this.unencryptedJar = Files.readAllBytes(unencryptedJarPath);
// Set encryption key and decrypt key
String encryptionKey = "AES_KEY_12345678";
this.decryptionKey = encryptionKey; // Initialize decryptionKey
// Encrypt once during startup
this.encryptedJar = encryptJar(unencryptedJar, encryptionKey);
this.jarHash = DigestUtils.getSHA256(encryptedJar);
} else {
throw new FileNotFoundException("Unencrypted plugin JAR not found at: " + unencryptedJarPath);
}
}
public byte[] getEncryptedJar() {
return encryptedJar;
}
private byte[] encryptJar(byte[] input, String encryptionKey) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
SecretKeySpec keySpec = new SecretKeySpec(encryptionKey.getBytes(StandardCharsets.UTF_8), "AES");
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
return cipher.doFinal(input);
}
public String getJarHash() { return jarHash; }
public String getDecryptionKey() { return decryptionKey; }
}

View File

@@ -0,0 +1,41 @@
package me.trouper.sentinel.auth.services;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
@Service
public class LicenseService {
private final List<String> licenseKeys = List.of("SECRET_KEY_12345"); // In real use, load from secure config
public List<String> getLicenseKeys() {
return licenseKeys;
}
public static String generateAuthCode(String licenseKey) {
try {
byte[] key = licenseKey.getBytes(StandardCharsets.UTF_8);
long counter = Instant.now().getEpochSecond() / 10;
byte[] counterBytes = ByteBuffer.allocate(8).putLong(counter).array();
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(key, "HmacSHA1"));
byte[] hmac = mac.doFinal(counterBytes);
int offset = hmac[hmac.length - 1] & 0xF;
int binary = ((hmac[offset] & 0x7F) << 24)
| ((hmac[offset + 1] & 0xFF) << 16)
| ((hmac[offset + 2] & 0xFF) << 8)
| (hmac[offset + 3] & 0xFF);
return String.format("%06d", binary % 1_000_000);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,29 @@
package me.trouper.sentinel.auth.utils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class DigestUtils {
public static String getSHA256(byte[] bytes) {
MessageDigest md = null;
try {
md = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
byte[] digestBytes = md.digest(bytes);
return bytesToHex(digestBytes);
}
private static String bytesToHex(byte[] bytes) {
StringBuilder hexString = new StringBuilder();
for (byte b : bytes) {
String hex = String.format("%02x", b);
hexString.append(hex);
}
return hexString.toString();
}
}

View File

@@ -0,0 +1 @@
spring.application.name=SentinelAuth

View File

@@ -0,0 +1,49 @@
plugins {
id 'java'
}
group = 'me.trouper.sentinel'
version = '1.0-SNAPSHOT'
repositories {
mavenCentral()
maven {
name = "papermc-repo"
url = "https://repo.papermc.io/repository/maven-public/"
}
maven {
name = "sonatype"
url = "https://oss.sonatype.org/content/groups/public/"
}
}
dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT")
}
def targetJavaVersion = 21
java {
def javaVersion = JavaVersion.toVersion(targetJavaVersion)
sourceCompatibility = javaVersion
targetCompatibility = javaVersion
if (JavaVersion.current() < javaVersion) {
toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion)
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) {
options.release.set(targetJavaVersion)
}
}
processResources {
def props = [version: version]
inputs.properties props
filteringCharset 'UTF-8'
filesMatching('paper-plugin.yml') {
expand props
}
}

View File

View File

@@ -0,0 +1 @@
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip

View File

@@ -0,0 +1 @@
rootProject.name = 'SentinelPlugin'

View File

@@ -0,0 +1,25 @@
package me.trouper.sentinel.plugin;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandMap;
import org.bukkit.command.CommandSender;
import org.bukkit.command.PluginCommand;
import org.bukkit.command.defaults.BukkitCommand;
import org.bukkit.plugin.java.JavaPlugin;
public final class SentinelPlugin extends JavaPlugin {
public static void initialize(JavaPlugin loaderPlugin) {
loaderPlugin.getLogger().info("Hello from dynamically loaded Plugin!");
Bukkit.getScheduler().runTask(loaderPlugin, () -> {
PluginCommand command = Bukkit.getPluginCommand("demo");
if (command != null) {
command.setExecutor((sender, cmd, label, args) -> {
sender.sendMessage("Hello from dynamically loaded plugin!");
return true;
});
}
});
}
}

View File

@@ -2,7 +2,7 @@ plugins {
id 'java'
}
group = 'me.trouper'
group = 'me.trouper.sentinel'
version = '1.0-SNAPSHOT'
repositories {

View File

@@ -1 +1,3 @@
rootProject.name = 'SentinelLoader'
include 'SentinelAuth'
include 'SentinelPlugin'

View File

@@ -0,0 +1,221 @@
package me.trouper.sentinel.loader;
import me.trouper.sentinel.loader.data.AuthResponse;
import me.trouper.sentinel.loader.utils.AuthUtils;
import me.trouper.sentinel.loader.utils.DigestUtils;
import org.bukkit.plugin.java.JavaPlugin;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.jar.JarEntry;
import java.util.jar.JarInputStream;
import java.util.logging.Level;
public final class SentinelLoader extends JavaPlugin {
private String licenseKey = "SECRET_KEY_12345";
private Path encryptedJarPath;
private static SentinelLoader instance;
@Override
public void onEnable() {
instance = this;
try {
// Ensure data folder exists
if (!getDataFolder().exists() && !getDataFolder().mkdirs()) {
getLogger().severe("Failed to create plugin data folder!");
return;
}
encryptedJarPath = getDataFolder().toPath().resolve("encrypted-plugin.jar");
// Get client hash safely
String clientHash = "";
if (Files.exists(encryptedJarPath)) {
try {
clientHash = DigestUtils.getSHA256(encryptedJarPath);
} catch (IOException e) {
getLogger().log(Level.WARNING, "Corrupted JAR file detected, will re-download", e);
try {
Files.deleteIfExists(encryptedJarPath);
} catch (IOException ex) {
getLogger().log(Level.SEVERE, "Failed to delete corrupted JAR file", ex);
}
}
}
String authCode = AuthUtils.generateAuthCode(licenseKey);
getLogger().info("Sending auth Auth Code: " + authCode);
AuthResponse response = sendAuthRequest(authCode, clientHash);
if (response == null) {
getLogger().severe("Authentication failed");
return;
}
// Handle JAR download
if (response.jarData() != null) {
try {
Files.createDirectories(encryptedJarPath.getParent());
Files.write(encryptedJarPath, response.jarData());
getLogger().info("Successfully updated plugin JAR");
} catch (IOException e) {
getLogger().log(Level.SEVERE, "Failed to save encrypted JAR", e);
return;
}
}
// Load the plugin
try {
byte[] decryptedJar = decrypt(
Files.readAllBytes(encryptedJarPath),
response.decryptionKey()
);
getLogger().info("Trying to load plugin...");
loadPlugin(decryptedJar);
} catch (IOException | GeneralSecurityException e) {
getLogger().log(Level.SEVERE, "Failed to decrypt or load plugin", e);
try {
Files.deleteIfExists(encryptedJarPath);
} catch (IOException ex) {
getLogger().log(Level.SEVERE, "Failed to cleanup invalid JAR", ex);
}
}
} catch (Exception e) {
getLogger().log(Level.SEVERE, "Critical error during initialization", e);
}
}
private AuthResponse sendAuthRequest(String authCode, String clientHash) {
try {
URL url = new URL("http://localhost:8080/authenticate");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json");
conn.setDoOutput(true);
// Write request body
String requestBody = String.format(
"{\"authCode\":\"%s\",\"clientHash\":\"%s\"}",
authCode,
clientHash
);
try (OutputStream os = conn.getOutputStream()) {
byte[] input = requestBody.getBytes(StandardCharsets.UTF_8);
os.write(input, 0, input.length);
}
// Read response
if (conn.getResponseCode() != 200) {
SentinelLoader.getInstance().getLogger().warning("Auth server responded with code: " + conn.getResponseCode());
return null;
}
try (BufferedReader br = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
StringBuilder response = new StringBuilder();
String responseLine;
while ((responseLine = br.readLine()) != null) {
response.append(responseLine.trim());
}
// Simple JSON parsing (for production use a proper library like Gson)
String json = response.toString();
boolean hasJar = json.contains("\"jarData\"");
String decryptionKey = extractJsonValue(json, "decryptionKey");
if (hasJar) {
String jarDataBase64 = extractJsonValue(json, "jarData");
byte[] jarData = Base64.getDecoder().decode(jarDataBase64);
return new AuthResponse(jarData, decryptionKey);
} else {
return new AuthResponse(null, decryptionKey);
}
}
} catch (Exception e) {
SentinelLoader.getInstance().getLogger().log(Level.SEVERE, "Failed to authenticate with server", e);
return null;
}
}
// Helper method to extract values from simple JSON
private String extractJsonValue(String json, String key) {
int start = json.indexOf("\"" + key + "\":") + key.length() + 3;
int end = json.indexOf("\"", start + 1);
if (end == -1) end = json.indexOf(",", start);
if (end == -1) end = json.indexOf("}", start);
return json.substring(start, end).replace("\"", "");
}
private byte[] decrypt(byte[] data, String key) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.getBytes(), "AES"));
return cipher.doFinal(data);
}
private void loadPlugin(byte[] decryptedJar) throws Exception {
new InMemoryClassLoader(getClassLoader(), decryptedJar)
.loadClass("me.trouper.sentinel.plugin.SentinelPlugin")
.getMethod("initialize", JavaPlugin.class)
.invoke(null, this);
}
private static class InMemoryClassLoader extends ClassLoader {
private final Map<String, Class<?>> classes = new HashMap<>();
private final byte[] jarBytes;
public InMemoryClassLoader(ClassLoader parent, byte[] jarBytes) {
super(parent);
this.jarBytes = jarBytes;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
if (classes.containsKey(name)) return classes.get(name);
try (JarInputStream jar = new JarInputStream(new ByteArrayInputStream(jarBytes))) {
JarEntry entry;
while ((entry = jar.getNextJarEntry()) != null) {
if (!entry.getName().endsWith(".class")) continue;
String className = entry.getName()
.replace(".class", "")
.replace('/', '.');
if (!className.equals(name)) continue;
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = jar.read(buffer)) != -1) {
bos.write(buffer, 0, len);
}
byte[] bytes = bos.toByteArray();
Class<?> clazz = defineClass(className, bytes, 0, bytes.length);
classes.put(className, clazz);
return clazz;
}
} catch (IOException e) {
throw new ClassNotFoundException("Class not found: " + name, e);
}
throw new ClassNotFoundException();
}
}
public static SentinelLoader getInstance() {
return instance;
}
}

View File

@@ -0,0 +1,5 @@
package me.trouper.sentinel.loader.data;
public record AuthRequest(String authCode, String clientHash) {
}

View File

@@ -0,0 +1,5 @@
package me.trouper.sentinel.loader.data;
public record AuthResponse(byte[] jarData, String decryptionKey) {
}

View File

@@ -0,0 +1,43 @@
package me.trouper.sentinel.loader.utils;
import me.trouper.sentinel.loader.SentinelLoader;
import me.trouper.sentinel.loader.data.AuthResponse;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.logging.Level;
public class AuthUtils {
public static String generateAuthCode(String licenseKey) {
try {
byte[] key = licenseKey.getBytes(StandardCharsets.UTF_8);
long counter = Instant.now().getEpochSecond() / 10;
byte[] counterBytes = ByteBuffer.allocate(8).putLong(counter).array();
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(key, "HmacSHA1"));
byte[] hmac = mac.doFinal(counterBytes);
int offset = hmac[hmac.length - 1] & 0xF;
int binary = ((hmac[offset] & 0x7F) << 24)
| ((hmac[offset + 1] & 0xFF) << 16)
| ((hmac[offset + 2] & 0xFF) << 8)
| (hmac[offset + 3] & 0xFF);
return String.format("%06d", binary % 1_000_000);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,25 @@
package me.trouper.sentinel.loader.utils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class DigestUtils {
public static String getSHA256(Path filePath) throws IOException, NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] fileBytes = Files.readAllBytes(filePath);
byte[] digestBytes = md.digest(fileBytes);
return bytesToHex(digestBytes);
}
private static String bytesToHex(byte[] bytes) {
StringBuilder hexString = new StringBuilder();
for (byte b : bytes) {
String hex = String.format("%02x", b);
hexString.append(hex);
}
return hexString.toString();
}
}

View File

@@ -1,16 +0,0 @@
package me.trouper.sentinelLoader;
import org.bukkit.plugin.java.JavaPlugin;
public final class SentinelLoader extends JavaPlugin {
@Override
public void onEnable() {
// Plugin startup logic
}
@Override
public void onDisable() {
// Plugin shutdown logic
}
}

View File

@@ -1,6 +1,6 @@
name: SentinelLoader
version: '1.0-SNAPSHOT'
main: me.trouper.sentinelLoader.SentinelLoader
main: me.trouper.sentinel.loader.SentinelLoader
api-version: '1.21'
prefix: SentinelLoader
load: STARTUP