diff --git a/pom.xml b/pom.xml index 89b9667..833aa27 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.openautonomousconnection WebServer - 1.0.0-BETA.1.4 + 1.0.0-BETA.1.5 Open Autonomous Connection https://open-autonomous-connection.org/ @@ -112,7 +112,7 @@ org.openautonomousconnection Protocol - 1.0.0-BETA.6.0 + 1.0.0-BETA.6.1 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 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: + *

+ */ +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); + } +}