org.projectlombok
diff --git a/src/main/java/org/openautonomousconnection/webserver/WebServer.java b/src/main/java/org/openautonomousconnection/webserver/WebServer.java
index ac02798..a58a7e6 100644
--- a/src/main/java/org/openautonomousconnection/webserver/WebServer.java
+++ b/src/main/java/org/openautonomousconnection/webserver/WebServer.java
@@ -1,8 +1,8 @@
package org.openautonomousconnection.webserver;
import dev.unlegitdqrk.unlegitlibrary.network.system.utils.Transport;
+import lombok.Getter;
import org.openautonomousconnection.protocol.annotations.ProtocolInfo;
-import org.openautonomousconnection.protocol.packets.v1_0_0.beta.*;
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebRequestPacket;
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket;
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.stream.WebStreamChunkPacket;
@@ -11,10 +11,10 @@ import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.stream.WebS
import org.openautonomousconnection.protocol.side.web.ConnectedWebClient;
import org.openautonomousconnection.protocol.side.web.ProtocolWebServer;
import org.openautonomousconnection.protocol.side.web.managers.RuleManager;
-import org.openautonomousconnection.protocol.side.web.managers.SessionManager;
import org.openautonomousconnection.protocol.versions.ProtocolVersion;
import org.openautonomousconnection.webserver.api.SessionContext;
import org.openautonomousconnection.webserver.runtime.JavaPageDispatcher;
+import org.openautonomousconnection.webserver.utils.WebHasher;
import java.io.*;
import java.nio.file.Files;
@@ -26,9 +26,26 @@ public final class WebServer extends ProtocolWebServer {
private static final int STREAM_CHUNK_SIZE = 64 * 1024;
private static final long STREAM_THRESHOLD = 2L * 1024 * 1024;
- public WebServer(File authFile, File rulesFile, int tcpPort, int udpPort,
- int sessionExpire, int maxUpload) throws Exception {
+ @Getter
+ private final WebHasher hasher;
+
+ public WebServer(
+ File authFile,
+ File rulesFile,
+ int tcpPort,
+ int udpPort,
+ int sessionExpire,
+ int maxUpload
+ ) throws Exception {
super(authFile, rulesFile, tcpPort, udpPort, sessionExpire, maxUpload);
+
+ // NOTE: Values chosen as safe defaults.
+ // move them to Main and pass them in here (no hidden assumptions).
+ this.hasher = new WebHasher(
+ 120_000, // PBKDF2 iterations
+ 16, // salt bytes
+ 32 // key bytes
+ );
}
@Override
@@ -36,11 +53,15 @@ public final class WebServer extends ProtocolWebServer {
try {
String path = request.getPath() == null ? "/" : request.getPath();
- if (RuleManager.isDenied(path)) return new WebResponsePacket(403, "text/plain", Map.of(), "Forbidden".getBytes());
+ if (RuleManager.isDenied(path)) {
+ return new WebResponsePacket(403, "text/plain; charset=utf-8", Map.of(), "Forbidden".getBytes());
+ }
if (RuleManager.requiresAuth(path)) {
SessionContext ctx = SessionContext.from(client, this, request.getHeaders());
- if (!ctx.isValid()) return new WebResponsePacket(401, "text/plain", Map.of(), "Authentication required".getBytes());
+ if (!ctx.isValid()) {
+ return new WebResponsePacket(401, "text/plain; charset=utf-8", Map.of(), "Authentication required".getBytes());
+ }
}
WebResponsePacket javaResp = JavaPageDispatcher.dispatch(client, this, request);
@@ -49,7 +70,8 @@ public final class WebServer extends ProtocolWebServer {
return serveFile(client, path);
} catch (Exception e) {
- return new WebResponsePacket(500, "text/plain", Map.of(), ("Internal Error: " + e.getMessage()).getBytes());
+ return new WebResponsePacket(500, "text/plain; charset=utf-8", Map.of(),
+ ("Internal Error: " + e.getMessage()).getBytes());
}
}
@@ -60,8 +82,8 @@ public final class WebServer extends ProtocolWebServer {
File root = getContentFolder().getCanonicalFile();
File file = new File(root, path).getCanonicalFile();
- if (!file.getPath().startsWith(root.getPath())) return new WebResponsePacket(403, "text/plain", Map.of(), "Forbidden".getBytes());
- if (!file.exists() || !file.isFile()) return new WebResponsePacket(404, "text/plain", Map.of(), "Not found".getBytes());
+ if (!file.getPath().startsWith(root.getPath())) return new WebResponsePacket(403, "text/plain; charset=utf-8", Map.of(), "Forbidden".getBytes());
+ if (!file.exists() || !file.isFile()) return new WebResponsePacket(404, "text/plain; charset=utf-8", Map.of(), "Not found".getBytes());
String contentType = ContentTypeResolver.resolve(file.getName());
long size = file.length();
@@ -86,8 +108,10 @@ public final class WebServer extends ProtocolWebServer {
int r;
while ((r = in.read(buf)) != -1) {
byte[] chunk = (r == buf.length) ? buf : Arrays.copyOf(buf, r);
- client.getConnection().sendPacket(new WebStreamChunkPacket(seq++, chunk),
- ContentTypeResolver.isVideoFile(file.getName()) ? Transport.UDP : Transport.TCP);
+ client.getConnection().sendPacket(
+ new WebStreamChunkPacket(seq++, chunk),
+ ContentTypeResolver.isVideoFile(file.getName()) ? Transport.UDP : Transport.TCP
+ );
}
}
diff --git a/src/main/java/org/openautonomousconnection/webserver/api/Route.java b/src/main/java/org/openautonomousconnection/webserver/api/Route.java
new file mode 100644
index 0000000..771ed36
--- /dev/null
+++ b/src/main/java/org/openautonomousconnection/webserver/api/Route.java
@@ -0,0 +1,18 @@
+package org.openautonomousconnection.webserver.api;
+
+import java.lang.annotation.*;
+
+/**
+ * Declares a route path for a server-side Java WebPage.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface Route {
+
+ /**
+ * The absolute route path (must start with '/').
+ *
+ * @return route path
+ */
+ String path();
+}
diff --git a/src/main/java/org/openautonomousconnection/webserver/api/WebPageContext.java b/src/main/java/org/openautonomousconnection/webserver/api/WebPageContext.java
index 587126d..c2ce374 100644
--- a/src/main/java/org/openautonomousconnection/webserver/api/WebPageContext.java
+++ b/src/main/java/org/openautonomousconnection/webserver/api/WebPageContext.java
@@ -3,19 +3,31 @@ package org.openautonomousconnection.webserver.api;
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebRequestPacket;
import org.openautonomousconnection.protocol.side.web.ConnectedWebClient;
import org.openautonomousconnection.protocol.side.web.ProtocolWebServer;
+import org.openautonomousconnection.webserver.utils.WebHasher;
+import org.openautonomousconnection.webserver.utils.RequestParams;
/**
- * Context passed to Java WebPages (client, request, session).
+ * Context passed to Java WebPages (client, request, session, params, hasher).
*/
public final class WebPageContext {
public final ConnectedWebClient client;
public final WebRequestPacket request;
public final SessionContext session;
+ public final RequestParams params;
+ public final WebHasher hasher;
- public WebPageContext(ConnectedWebClient client, ProtocolWebServer server, WebRequestPacket request) throws Exception {
+ public WebPageContext(
+ ConnectedWebClient client,
+ ProtocolWebServer server,
+ WebRequestPacket request,
+ RequestParams params,
+ WebHasher hasher
+ ) throws Exception {
this.client = client;
this.request = request;
this.session = SessionContext.from(client, server, request.getHeaders());
+ this.params = params;
+ this.hasher = hasher;
}
}
diff --git a/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageCompiler.java b/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageCompiler.java
index d0ea48b..b78b06f 100644
--- a/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageCompiler.java
+++ b/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageCompiler.java
@@ -4,12 +4,15 @@ import javax.tools.*;
import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
import java.util.List;
/**
- * Compiles and loads Java web pages at runtime.
+ * Compiles and loads Java pages at runtime.
*
- * NOTE: Requires running with a JDK (ToolProvider.getSystemJavaCompiler != null).
+ * Supports packages by deriving the fully qualified class name from source.
+ *
NOTE: Requires a JDK (ToolProvider.getSystemJavaCompiler != null).
*/
public final class JavaPageCompiler {
@@ -20,10 +23,8 @@ public final class JavaPageCompiler {
if (compiler == null) throw new IllegalStateException("JDK required (JavaCompiler not available)");
StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);
-
Iterable extends JavaFileObject> units = fileManager.getJavaFileObjects(javaFile);
- // Compile in-place (class next to the .java file)
List options = List.of("-classpath", System.getProperty("java.class.path"));
boolean success = compiler.getTask(null, fileManager, null, options, null, units).call();
@@ -31,9 +32,32 @@ public final class JavaPageCompiler {
if (!success) throw new RuntimeException("Compilation failed: " + javaFile.getName());
+ String fqcn = deriveFqcn(javaFile);
+
+ // Root must be the folder containing the package root (javaFile parent is fine if classes output there).
URLClassLoader cl = new URLClassLoader(new URL[]{ javaFile.getParentFile().toURI().toURL() });
- String className = javaFile.getName().replace(".java", "");
- return cl.loadClass(className);
+ return cl.loadClass(fqcn);
+ }
+
+ private static String deriveFqcn(File javaFile) throws Exception {
+ String src = Files.readString(javaFile.toPath(), StandardCharsets.UTF_8);
+
+ String pkg = null;
+ for (String line : src.split("\\R")) {
+ String t = line.trim();
+ if (t.startsWith("package ") && t.endsWith(";")) {
+ pkg = t.substring("package ".length(), t.length() - 1).trim();
+ break;
+ }
+ // Stop scanning early if class appears before package (invalid anyway)
+ if (t.startsWith("public class") || t.startsWith("class ") || t.startsWith("public final class")) {
+ break;
+ }
+ }
+
+ String simple = javaFile.getName().replace(".java", "");
+ if (pkg == null || pkg.isBlank()) return simple;
+ return pkg + "." + simple;
}
}
diff --git a/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageDispatcher.java b/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageDispatcher.java
index 1dc654c..c1022ba 100644
--- a/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageDispatcher.java
+++ b/src/main/java/org/openautonomousconnection/webserver/runtime/JavaPageDispatcher.java
@@ -4,14 +4,22 @@ import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebRequestP
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket;
import org.openautonomousconnection.protocol.side.web.ConnectedWebClient;
import org.openautonomousconnection.protocol.side.web.ProtocolWebServer;
+import org.openautonomousconnection.webserver.WebServer;
import org.openautonomousconnection.webserver.api.WebPage;
import org.openautonomousconnection.webserver.api.WebPageContext;
+import org.openautonomousconnection.webserver.utils.WebHasher;
+import org.openautonomousconnection.webserver.utils.RequestParams;
import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+/**
+ * Dispatches Java WebPages using @Route annotation.
+ */
public final class JavaPageDispatcher {
- private static final JavaPageCache CACHE = new JavaPageCache();
+ private static final JavaRouteRegistry ROUTES = new JavaRouteRegistry();
private JavaPageDispatcher() {}
@@ -23,18 +31,53 @@ public final class JavaPageDispatcher {
if (request.getPath() == null) return null;
- String p = request.getPath().startsWith("/") ? request.getPath().substring(1) : request.getPath();
- File javaFile = new File(server.getContentFolder(), p + ".java");
+ String route = request.getPath();
+ if (!route.startsWith("/")) route = "/" + route;
- if (!javaFile.exists() || !javaFile.isFile()) return null;
+ File contentRoot = server.getContentFolder();
+ ROUTES.refreshIfNeeded(contentRoot);
- Class> clazz = CACHE.getOrCompile(javaFile);
+ JavaRouteRegistry.RouteLookupResult found = ROUTES.find(route);
+
+ // If no @Route match, still detect "requested a .java-backed path by filename"
+ // (legacy behavior): if a matching *.java exists, compile/load but return error.
+ if (found == null) {
+ String p = route.startsWith("/") ? route.substring(1) : route;
+ File javaFile = new File(contentRoot, p + ".java");
+ if (javaFile.exists() && javaFile.isFile()) {
+ // Compile/load but do not serve.
+ new JavaPageCache().getOrCompile(javaFile);
+ return error(501, "Java class exists but has no @Route: " + route);
+ }
+ return null; // not a Java route -> let file server handle it
+ }
+
+ // If it has @Route but is not a WebPage, compile/load but return error.
+ if (!found.routable()) {
+ return error(500, "Class has @Route but is not a WebPage: " + found.fqcn());
+ }
+
+ // Load and execute
+ Class> clazz = Class.forName(found.fqcn());
Object instance = clazz.getDeclaredConstructor().newInstance();
- if (!(instance instanceof WebPage page))
- throw new IllegalStateException("Java page must implement WebPage");
+ if (!(instance instanceof WebPage page)) {
+ return error(500, "Routed class is not a WebPage: " + found.fqcn());
+ }
- WebPageContext ctx = new WebPageContext(client, server, request);
+ WebHasher hasher = (server instanceof WebServer ws) ? ws.getHasher() : null;
+ if (hasher == null) return error(500, "WebHasher missing on server instance.");
+
+ WebPageContext ctx = new WebPageContext(client, server, request, new RequestParams(request), hasher);
return page.handle(ctx);
}
+
+ private static WebResponsePacket error(int code, String msg) {
+ return new WebResponsePacket(
+ code,
+ "text/plain; charset=utf-8",
+ Map.of(),
+ msg.getBytes(StandardCharsets.UTF_8)
+ );
+ }
}
diff --git a/src/main/java/org/openautonomousconnection/webserver/runtime/JavaRouteRegistry.java b/src/main/java/org/openautonomousconnection/webserver/runtime/JavaRouteRegistry.java
new file mode 100644
index 0000000..e1df163
--- /dev/null
+++ b/src/main/java/org/openautonomousconnection/webserver/runtime/JavaRouteRegistry.java
@@ -0,0 +1,139 @@
+package org.openautonomousconnection.webserver.runtime;
+
+import org.openautonomousconnection.webserver.api.Route;
+import org.openautonomousconnection.webserver.api.WebPage;
+
+import java.io.File;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Scans the content folder for .java files, compiles them and maps @Route paths to classes/files.
+ *
+ * Behavior:
+ *
+ * - If class has @Route AND implements WebPage => routable
+ * - If class is compiled but not routable => still cached as loaded, but never served
+ *
+ */
+public final class JavaRouteRegistry {
+
+ private static final class RouteEntry {
+ final File sourceFile;
+ final long lastModified;
+ final String fqcn;
+ final boolean routable;
+
+ RouteEntry(File sourceFile, long lastModified, String fqcn, boolean routable) {
+ this.sourceFile = sourceFile;
+ this.lastModified = lastModified;
+ this.fqcn = fqcn;
+ this.routable = routable;
+ }
+ }
+
+ private final JavaPageCache cache = new JavaPageCache();
+ private final ConcurrentHashMap routes = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap scanState = new ConcurrentHashMap<>();
+
+ /**
+ * Refreshes registry when content folder changed.
+ *
+ * @param contentRoot content root
+ */
+ public void refreshIfNeeded(File contentRoot) {
+ if (contentRoot == null) return;
+ long lm = folderLastModified(contentRoot);
+
+ Long prev = scanState.get("lm");
+ if (prev != null && prev == lm && !routes.isEmpty()) return;
+
+ scanState.put("lm", lm);
+ rebuild(contentRoot);
+ }
+
+ /**
+ * Finds a routable class for a route.
+ *
+ * @param route route path
+ * @return entry or null
+ */
+ public RouteLookupResult find(String route) {
+ RouteEntry e = routes.get(route);
+ if (e == null) return null;
+ return new RouteLookupResult(e.sourceFile, e.fqcn, e.routable);
+ }
+
+ private void rebuild(File contentRoot) {
+ routes.clear();
+
+ List javaFiles = listJavaFiles(contentRoot);
+ for (File f : javaFiles) {
+ try {
+ long lm = f.lastModified();
+ Class> clazz = cache.getOrCompile(f);
+
+ Route r = clazz.getAnnotation(Route.class);
+ boolean isWebPage = WebPage.class.isAssignableFrom(clazz);
+ boolean routable = (r != null && isWebPage);
+
+ if (r != null) {
+ String path = normalizeRoute(r.path());
+ routes.put(path, new RouteEntry(f, lm, clazz.getName(), routable));
+ }
+
+ } catch (Exception ignored) {
+ // Compilation errors are handled later when requested or during next refresh.
+ }
+ }
+ }
+
+ private static String normalizeRoute(String value) {
+ if (value == null) return "/";
+ String v = value.trim();
+ if (v.isEmpty()) return "/";
+ if (!v.startsWith("/")) v = "/" + v;
+ return v;
+ }
+
+ private static List listJavaFiles(File root) {
+ ArrayList out = new ArrayList<>();
+ Deque stack = new ArrayDeque<>();
+ stack.push(root);
+
+ while (!stack.isEmpty()) {
+ File cur = stack.pop();
+ File[] children = cur.listFiles();
+ if (children == null) continue;
+
+ for (File c : children) {
+ if (c.isDirectory()) {
+ stack.push(c);
+ } else if (c.isFile() && c.getName().endsWith(".java")) {
+ out.add(c);
+ }
+ }
+ }
+
+ return out;
+ }
+
+ private static long folderLastModified(File folder) {
+ long lm = folder.lastModified();
+ File[] children = folder.listFiles();
+ if (children == null) return lm;
+ for (File c : children) {
+ lm = Math.max(lm, c.isDirectory() ? folderLastModified(c) : c.lastModified());
+ }
+ return lm;
+ }
+
+ /**
+ * Lookup result for a route.
+ *
+ * @param sourceFile Java source file
+ * @param fqcn fully qualified class name
+ * @param routable true if @Route + implements WebPage
+ */
+ public record RouteLookupResult(File sourceFile, String fqcn, boolean routable) {}
+}
diff --git a/src/main/java/org/openautonomousconnection/webserver/utils/Html.java b/src/main/java/org/openautonomousconnection/webserver/utils/Html.java
new file mode 100644
index 0000000..1d461c5
--- /dev/null
+++ b/src/main/java/org/openautonomousconnection/webserver/utils/Html.java
@@ -0,0 +1,72 @@
+package org.openautonomousconnection.webserver.utils;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Small HTML helpers for server-side rendering.
+ */
+public final class Html {
+
+ private Html() {}
+
+ /**
+ * Escapes text for HTML.
+ *
+ * @param s input
+ * @return escaped
+ */
+ public static String esc(String s) {
+ if (s == null) return "";
+ return s.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """);
+ }
+
+ /**
+ * Encodes to UTF-8 bytes.
+ *
+ * @param html html
+ * @return bytes
+ */
+ public static byte[] utf8(String html) {
+ return (html == null ? "" : html).getBytes(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Simple page wrapper.
+ *
+ * @param title title
+ * @param body body html
+ * @return full html
+ */
+ public static String page(String title, String body) {
+ return """
+
+
+
+
+
+ %s
+
+
+
+ %s
+
+
+ """.formatted(esc(title), body == null ? "" : body);
+ }
+}
diff --git a/src/main/java/org/openautonomousconnection/webserver/api/HttpsProxy.java b/src/main/java/org/openautonomousconnection/webserver/utils/HttpsProxy.java
similarity index 98%
rename from src/main/java/org/openautonomousconnection/webserver/api/HttpsProxy.java
rename to src/main/java/org/openautonomousconnection/webserver/utils/HttpsProxy.java
index 02f4485..c579bbc 100644
--- a/src/main/java/org/openautonomousconnection/webserver/api/HttpsProxy.java
+++ b/src/main/java/org/openautonomousconnection/webserver/utils/HttpsProxy.java
@@ -1,4 +1,4 @@
-package org.openautonomousconnection.webserver.api;
+package org.openautonomousconnection.webserver.utils;
import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket;
import org.openautonomousconnection.webserver.api.WebPageContext;
diff --git a/src/main/java/org/openautonomousconnection/webserver/utils/RequestParams.java b/src/main/java/org/openautonomousconnection/webserver/utils/RequestParams.java
new file mode 100644
index 0000000..b66b827
--- /dev/null
+++ b/src/main/java/org/openautonomousconnection/webserver/utils/RequestParams.java
@@ -0,0 +1,99 @@
+package org.openautonomousconnection.webserver.utils;
+
+import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebRequestPacket;
+
+import java.util.Collections;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Reads parameters from WebRequestPacket headers (case-insensitive).
+ *
+ * Additionally provides hashing helpers via a supplied {@link WebHasher}.
+ */
+public final class RequestParams {
+
+ private final Map headers;
+
+ /**
+ * Creates a param reader.
+ *
+ * @param request request
+ */
+ public RequestParams(WebRequestPacket request) {
+ Map h = request != null ? request.getHeaders() : null;
+ this.headers = (h == null) ? Collections.emptyMap() : h;
+ }
+
+ /**
+ * Gets a header (case-insensitive).
+ *
+ * @param key key
+ * @return value or null
+ */
+ public String get(String key) {
+ if (key == null) return null;
+ String needle = key.toLowerCase(Locale.ROOT);
+ for (Map.Entry e : headers.entrySet()) {
+ if (e.getKey() != null && e.getKey().toLowerCase(Locale.ROOT).equals(needle)) {
+ return e.getValue();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Gets a header or default.
+ *
+ * @param key key
+ * @param def default
+ * @return value or default
+ */
+ public String getOr(String key, String def) {
+ String v = get(key);
+ return (v == null) ? def : v;
+ }
+
+ /**
+ * Parses an int header.
+ *
+ * @param key key
+ * @param def default
+ * @return int
+ */
+ public int getInt(String key, int def) {
+ String v = get(key);
+ if (v == null) return def;
+ try {
+ return Integer.parseInt(v.trim());
+ } catch (Exception ignored) {
+ return def;
+ }
+ }
+
+ /**
+ * Returns a trimmed header value.
+ *
+ * @param key key
+ * @return trimmed value or null
+ */
+ public String getTrimmed(String key) {
+ String v = get(key);
+ if (v == null) return null;
+ String t = v.trim();
+ return t.isEmpty() ? null : t;
+ }
+
+ /**
+ * Returns SHA-256 hex of a header value.
+ *
+ * @param hasher hasher
+ * @param key header key
+ * @return sha256 hex (or null if missing)
+ */
+ public String getSha256Hex(WebHasher hasher, String key) {
+ String v = getTrimmed(key);
+ if (v == null) return null;
+ return hasher.sha256Hex(v);
+ }
+}
diff --git a/src/main/java/org/openautonomousconnection/webserver/utils/WebHasher.java b/src/main/java/org/openautonomousconnection/webserver/utils/WebHasher.java
new file mode 100644
index 0000000..e89bee6
--- /dev/null
+++ b/src/main/java/org/openautonomousconnection/webserver/utils/WebHasher.java
@@ -0,0 +1,139 @@
+package org.openautonomousconnection.webserver.utils;
+
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.PBEKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.util.Base64;
+import java.util.Objects;
+
+/**
+ * Provides hashing utilities for the registrar frontend.
+ *
+ * Username: SHA-256 hex
+ *
Password: PBKDF2WithHmacSHA256 storage format:
+ * PBKDF2$sha256$$$
+ */
+public final class WebHasher {
+
+ private final SecureRandom rng = new SecureRandom();
+ private final int pbkdf2Iterations;
+ private final int pbkdf2SaltBytes;
+ private final int pbkdf2KeyBytes;
+
+ /**
+ * Creates a hasher.
+ *
+ * @param pbkdf2Iterations iterations (recommended >= 100k)
+ * @param pbkdf2SaltBytes salt bytes
+ * @param pbkdf2KeyBytes derived key bytes
+ */
+ public WebHasher(int pbkdf2Iterations, int pbkdf2SaltBytes, int pbkdf2KeyBytes) {
+ if (pbkdf2Iterations < 10_000) throw new IllegalArgumentException("pbkdf2Iterations too low");
+ if (pbkdf2SaltBytes < 8) throw new IllegalArgumentException("pbkdf2SaltBytes too low");
+ if (pbkdf2KeyBytes < 16) throw new IllegalArgumentException("pbkdf2KeyBytes too low");
+ this.pbkdf2Iterations = pbkdf2Iterations;
+ this.pbkdf2SaltBytes = pbkdf2SaltBytes;
+ this.pbkdf2KeyBytes = pbkdf2KeyBytes;
+ }
+
+ /**
+ * SHA-256 hashes text (lowercase hex).
+ *
+ * @param text input
+ * @return sha256 hex
+ */
+ public String sha256Hex(String text) {
+ if (text == null) text = "";
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(text.getBytes(StandardCharsets.UTF_8));
+ return toHexLower(digest);
+ } catch (Exception e) {
+ throw new IllegalStateException("SHA-256 not available", e);
+ }
+ }
+
+ /**
+ * PBKDF2-hashes a raw password into storage format.
+ *
+ * @param password raw password
+ * @return encoded storage string
+ */
+ public String pbkdf2Hash(String password) {
+ Objects.requireNonNull(password, "password");
+ byte[] salt = new byte[pbkdf2SaltBytes];
+ rng.nextBytes(salt);
+
+ byte[] dk = derive(password.toCharArray(), salt, pbkdf2Iterations, pbkdf2KeyBytes);
+
+ return "PBKDF2$sha256$" + pbkdf2Iterations + "$" +
+ Base64.getEncoder().encodeToString(salt) + "$" +
+ Base64.getEncoder().encodeToString(dk);
+ }
+
+ /**
+ * Verifies a raw password against a stored PBKDF2 string.
+ *
+ * @param password raw password
+ * @param stored stored format
+ * @return true if valid
+ */
+ public boolean pbkdf2Verify(String password, String stored) {
+ if (password == null || stored == null) return false;
+
+ String[] parts = stored.split("\\$");
+ if (parts.length != 5) return false;
+ if (!"PBKDF2".equals(parts[0])) return false;
+ if (!"sha256".equalsIgnoreCase(parts[1])) return false;
+
+ int it;
+ try {
+ it = Integer.parseInt(parts[2]);
+ } catch (NumberFormatException e) {
+ return false;
+ }
+
+ byte[] salt;
+ byte[] expected;
+ try {
+ salt = Base64.getDecoder().decode(parts[3]);
+ expected = Base64.getDecoder().decode(parts[4]);
+ } catch (Exception e) {
+ return false;
+ }
+
+ byte[] actual = derive(password.toCharArray(), salt, it, expected.length);
+ return constantTimeEquals(expected, actual);
+ }
+
+ private static byte[] derive(char[] password, byte[] salt, int iterations, int keyBytes) {
+ try {
+ PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, keyBytes * 8);
+ SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
+ return skf.generateSecret(spec).getEncoded();
+ } catch (Exception e) {
+ throw new IllegalStateException("PBKDF2 not available", e);
+ }
+ }
+
+ private static boolean constantTimeEquals(byte[] a, byte[] b) {
+ if (a == null || b == null) return false;
+ if (a.length != b.length) return false;
+ int r = 0;
+ for (int i = 0; i < a.length; i++) r |= (a[i] ^ b[i]);
+ return r == 0;
+ }
+
+ private static String toHexLower(byte[] data) {
+ final char[] hex = "0123456789abcdef".toCharArray();
+ char[] out = new char[data.length * 2];
+ int i = 0;
+ for (byte b : data) {
+ out[i++] = hex[(b >>> 4) & 0x0F];
+ out[i++] = hex[b & 0x0F];
+ }
+ return new String(out);
+ }
+}