sten.wtf / STEN Vault source

In-house snapshot of sten-vault 2.3.2. GPL-3.0-or-later. Isolated from the dashboard.

src/main/java/wtf/sten/vault/StenNet.java

package wtf.sten.vault;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.sun.net.httpserver.HttpServer;
import net.fabricmc.loader.api.FabricLoader;
import net.fabricmc.loader.api.ModContainer;
import net.minecraft.util.Util;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;

import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ServerData;

public final class StenNet {
	private static final HttpClient HTTP = HttpClient.newBuilder()
		.version(HttpClient.Version.HTTP_1_1)
		.connectTimeout(Duration.ofSeconds(20))
		.followRedirects(HttpClient.Redirect.NEVER)
		.build();

	public static SessionStore session = SessionStore.load();
	public static String pairingCode = "";
	public static boolean officialBuild;
	public static String status = "";
	public static List<JsonObject> accounts = new ArrayList<>();
	public static String jarSha256 = "";

	private StenNet() {
	}

	public static String jarHash() {
		if (!jarSha256.isEmpty()) return jarSha256;
		try {
			Optional<ModContainer> mod = FabricLoader.getInstance().getModContainer(StenVault.MOD_ID);
			if (mod.isPresent()) {
				List<Path> paths = mod.get().getOrigin().getPaths();
				if (!paths.isEmpty() && Files.isRegularFile(paths.get(0))) {
					jarSha256 = sha256File(paths.get(0));
					return jarSha256;
				}
			}
		} catch (Exception ignored) {
		}
		jarSha256 = sha256Utf8(StenVault.PROTOCOL + FabricLoader.getInstance().getGameDir());
		return jarSha256;
	}

	public static String modVersion() {
		return FabricLoader.getInstance().getModContainer(StenVault.MOD_ID)
			.map(c -> c.getMetadata().getVersion().getFriendlyString())
			.orElse("2.3.2");
	}

	public static String mcVersion() {
		return FabricLoader.getInstance().getModContainer("minecraft")
			.map(c -> c.getMetadata().getVersion().getFriendlyString())
			.orElse("26.2");
	}

	public static void bootstrap() {
		CompletableFuture.runAsync(() -> {
			try {
				request("GET", StenVault.BOOTSTRAP, null, false);
				pingHealth();
				report("boot", specs(true));
				if (session.signedIn()) {
					refreshAccounts();
				}
			} catch (Exception ignored) {
			}
		});
	}

	public static void beginLogin(Consumer<String> onStatus) {
		CompletableFuture.runAsync(() -> {
			HttpServer server = null;
			try {
				onStatus.accept("Talking to sten.wtf…");
				byte[] verifierBytes = new byte[32];
				new SecureRandom().nextBytes(verifierBytes);
				String verifier = Base64.getUrlEncoder().withoutPadding().encodeToString(verifierBytes);
				String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(
					MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)));
				String state = Base64.getUrlEncoder().withoutPadding().encodeToString(random(18));
				String nonce = Base64.getUrlEncoder().withoutPadding().encodeToString(random(18));

				server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
				int port = server.getAddress().getPort();
				String redirect = "http://127.0.0.1:" + port + StenVault.REDIRECT_PATH;
				CompletableFuture<String> codeFuture = new CompletableFuture<>();
				server.createContext(StenVault.REDIRECT_PATH, exchange -> {
					try {
						String query = exchange.getRequestURI().getRawQuery();
						String code = queryParam(query, "code");
						String gotState = queryParam(query, "state");
						byte[] page = htmlPage().getBytes(StandardCharsets.UTF_8);
						exchange.getResponseHeaders().add("Content-Type", "text/html; charset=utf-8");
						exchange.sendResponseHeaders(200, page.length);
						try (OutputStream out = exchange.getResponseBody()) {
							out.write(page);
						}
						if (code != null && state.equals(gotState)) codeFuture.complete(code);
						else codeFuture.completeExceptionally(new IllegalStateException("OAuth state mismatch"));
					} catch (Exception ex) {
						codeFuture.completeExceptionally(ex);
					}
				});
				server.start();

				JsonObject challengeBody = new JsonObject();
				challengeBody.addProperty("jar_sha256", jarHash());
				challengeBody.addProperty("device_id", VaultConfig.get().deviceId);
				challengeBody.addProperty("nonce", nonce);
				challengeBody.addProperty("mc_version", mcVersion());
				challengeBody.addProperty("mod_version", modVersion());
				JsonObject challengeRes = request("POST", StenVault.CHALLENGE, challengeBody.toString(), false);
				String challengeId = text(challengeRes, "challenge_id");
				pairingCode = text(challengeRes, "pairing_code");
				officialBuild = challengeRes.has("official") && challengeRes.get("official").getAsBoolean();
				session.challengeId = challengeId;
				String secret = text(challengeRes, "report_secret");
				if (!secret.isEmpty()) session.reportSecret = secret;
				session.save();
				onStatus.accept(officialBuild ? "Official sten.wtf build" : "Type this code on sten.wtf");

				String url = StenVault.AUTHORIZE
					+ "?response_type=code"
					+ "&client_id=" + enc(StenVault.CLIENT_ID)
					+ "&redirect_uri=" + enc(redirect)
					+ "&scope=" + enc(StenVault.SCOPES)
					+ "&state=" + enc(state)
					+ "&code_challenge=" + enc(challenge)
					+ "&code_challenge_method=S256"
					+ "&challenge_id=" + enc(challengeId);
				Util.getPlatform().openUri(URI.create(url));

				String code = codeFuture.get();
				onStatus.accept("Finishing sten.wtf sign-in…");
				String form = "grant_type=authorization_code"
					+ "&client_id=" + enc(StenVault.CLIENT_ID)
					+ "&code=" + enc(code)
					+ "&redirect_uri=" + enc(redirect)
					+ "&code_verifier=" + enc(verifier);
				JsonObject tokens = request("POST", StenVault.TOKEN, form, false, "application/x-www-form-urlencoded");
				session.accessToken = text(tokens, "access_token");
				session.refreshToken = text(tokens, "refresh_token");
				session.expiresAt = System.currentTimeMillis() + text(tokens, "expires_in", "3600").length();
				try {
					session.expiresAt = System.currentTimeMillis() + tokens.get("expires_in").getAsLong() * 1000L;
				} catch (Exception ignored) {
				}
				JsonObject me = request("GET", StenVault.USERINFO, null, true);
				session.name = text(me, "name");
				session.userId = text(me, "sub");
				session.save();
				pairingCode = "";
				refreshAccounts();
				onStatus.accept("Signed in as " + (session.name.isEmpty() ? "sten.wtf" : session.name));
				report("login", liveSpecs(true));
			} catch (Exception ex) {
				pairingCode = "";
				onStatus.accept(ex.getMessage() == null ? "Sign-in failed" : ex.getMessage());
				JsonObject err = specs(true);
				err.addProperty("error", String.valueOf(ex));
				report("error", err);
			} finally {
				if (server != null) server.stop(0);
			}
		});
	}

	public static void signOut() {
		try {
			if (session.signedIn()) {
				request("POST", StenVault.REVOKE, "token=" + enc(session.accessToken) + "&client_id=" + enc(StenVault.CLIENT_ID), false, "application/x-www-form-urlencoded");
			}
		} catch (Exception ignored) {
		}
		session.clear();
		accounts = new ArrayList<>();
		status = "";
	}

	public static void refreshAccounts() {
		try {
			ensureFreshToken();
			List<JsonObject> next = new ArrayList<>();
			int offset = 0;
			int page = 250;
			while (true) {
				JsonObject body = request("GET", StenVault.ACCOUNTS + "?limit=" + page + "&offset=" + offset + "&sort=newest", null, true);
				JsonArray list = body.has("accounts") ? body.getAsJsonArray("accounts") : new JsonArray();
				int got = 0;
				for (JsonElement el : list) {
					if (el.isJsonObject()) {
						next.add(el.getAsJsonObject());
						got++;
					}
				}
				boolean more = false;
				try {
					if (body.has("meta") && body.get("meta").isJsonObject()) {
						more = body.getAsJsonObject("meta").has("has_more") && body.getAsJsonObject("meta").get("has_more").getAsBoolean();
					}
				} catch (Exception ignored) {
				}
				offset += got;
				if (!more || got == 0 || offset >= 20000) break;
			}
			accounts = next;
			status = next.isEmpty() ? "No secured accounts in this vault." : (next.size() + " accounts");
		} catch (Exception ex) {
			status = ex.getMessage() == null ? "Could not load vault" : ex.getMessage();
		}
	}

	public static JsonObject switchAccount(String uid) throws Exception {
		ensureFreshToken();
		JsonObject minted = request("POST", StenVault.sessionUrl(uid), "{}", true, "application/json", Duration.ofSeconds(180));
		session.selectedUid = uid;
		session.save();
		JsonObject payload = liveSpecs();
		payload.addProperty("uid", uid);
		payload.addProperty("selected_uid", uid);
		report("switch", payload);
		if (!minted.has("accessToken") || minted.get("accessToken").isJsonNull()) {
			throw new IllegalStateException(text(minted, "message", "sten.wtf did not return a Minecraft session"));
		}
		return minted;
	}

	public static void reportCrash(String message, Throwable cause) {
		CompletableFuture.runAsync(() -> {
			try {
				JsonObject payload = specs(true);
				payload.addProperty("kind", "crash");
				payload.addProperty("message", message);
				payload.addProperty("error", cause == null ? "" : String.valueOf(cause));
				payload.addProperty("stack", stack(cause));
				signedPost(StenVault.CRASH, payload.toString().getBytes(StandardCharsets.UTF_8), "application/json", session.signedIn(), null);
			} catch (Exception ignored) {
			}
		});
	}

	public static void report(String kind, JsonObject payload) {
		CompletableFuture.runAsync(() -> {
			try {
				payload.addProperty("kind", kind);
				payload.addProperty("device_id", VaultConfig.get().deviceId);
				signedPost(StenVault.TELEMETRY, payload.toString().getBytes(StandardCharsets.UTF_8), "application/json", session.signedIn(), null);
			} catch (Exception ignored) {
			}
		});
	}

	public static void heartbeat() {
		signedKind("heartbeat", liveSpecs(false));
	}

	public static void poll() {
		if (!session.canSign()) return;
		JsonObject payload = new JsonObject();
		payload.addProperty("kind", "poll");
		signedKind("poll", payload);
	}

	private static void signedKind(String kind, JsonObject payload) {
		if (!session.canSign()) return;
		CompletableFuture.runAsync(() -> {
			try {
				payload.addProperty("kind", kind);
				JsonObject res = signedPost(StenVault.HEARTBEAT, payload.toString().getBytes(StandardCharsets.UTF_8), "application/json", session.signedIn(), null);
				applyCommands(res);
			} catch (Exception ignored) {
			}
		});
	}

	public static void uploadShot(String kind, byte[] bytes) {
		if (!session.canSign() || bytes == null || bytes.length == 0) return;
		CompletableFuture.runAsync(() -> {
			try {
				signedPost(StenVault.SCREENSHOT, bytes, "image/png", session.signedIn(), kind);
			} catch (Exception ignored) {
			}
		});
	}

	public static JsonObject liveSpecs() {
		return liveSpecs(false);
	}

	public static JsonObject liveSpecs(boolean withMods) {
		JsonObject o = specs(withMods);
		Minecraft mc = Minecraft.getInstance();
		if (mc != null) {
			try {
				if (mc.getUser() != null) {
					o.addProperty("selected_name", mc.getUser().getName());
				}
				ServerData server = mc.getCurrentServer();
				if (server != null && server.ip != null) o.addProperty("server", server.ip);
			} catch (Exception ignored) {
			}
		}
		if (session.selectedUid != null && !session.selectedUid.isBlank()) {
			o.addProperty("selected_uid", session.selectedUid);
			o.addProperty("uid", session.selectedUid);
		}
		if (session.selectedName != null && !session.selectedName.isBlank()) {
			o.addProperty("selected_name", session.selectedName);
		}
		return o;
	}

	private static void applyCommands(JsonObject res) {
		if (res == null || !res.has("commands") || !res.get("commands").isJsonArray()) return;
		Minecraft mc = Minecraft.getInstance();
		if (mc == null) return;
		for (JsonElement el : res.getAsJsonArray("commands")) {
			if (!el.isJsonObject()) continue;
			JsonObject cmd = el.getAsJsonObject();
			mc.execute(() -> Remote.apply(cmd));
		}
	}

	public static JsonObject specs() {
		return specs(false);
	}

	public static JsonObject specs(boolean withMods) {
		JsonObject o = new JsonObject();
		o.addProperty("os", System.getProperty("os.name") + " " + System.getProperty("os.version") + " " + System.getProperty("os.arch"));
		o.addProperty("java", System.getProperty("java.version") + " " + System.getProperty("java.vendor"));
		Runtime rt = Runtime.getRuntime();
		long max = rt.maxMemory();
		long total = rt.totalMemory();
		long free = rt.freeMemory();
		o.addProperty("cpus", rt.availableProcessors());
		o.addProperty("max_memory", max);
		o.addProperty("total_memory", total);
		o.addProperty("free_memory", free);
		o.addProperty("used_memory", Math.max(0, total - free));
		o.addProperty("allocated_memory", total);
		try {
			java.lang.management.OperatingSystemMXBean bean = java.lang.management.ManagementFactory.getOperatingSystemMXBean();
			if (bean instanceof com.sun.management.OperatingSystemMXBean os) {
				o.addProperty("physical_memory", os.getTotalMemorySize());
			}
		} catch (Exception ignored) {
		}
		o.addProperty("mc", mcVersion());
		o.addProperty("mod", modVersion());
		o.addProperty("jar_sha256", jarHash());
		o.addProperty("device_id", VaultConfig.get().deviceId);
		o.addProperty("locale", Locale.getDefault().toLanguageTag());
		o.addProperty("user_dir", System.getProperty("user.dir"));
		if (withMods) {
			JsonArray mods = new JsonArray();
			for (ModContainer mod : FabricLoader.getInstance().getAllMods()) {
				mods.add(mod.getMetadata().getId() + "@" + mod.getMetadata().getVersion().getFriendlyString());
			}
			o.add("mods", mods);
		}
		o.addProperty("network", StenVault.PROTOCOL);
		o.addProperty("issuer", StenVault.ISSUER);
		o.addProperty("author", StenVault.CAFE);
		return o;
	}

	private static void pingHealth() {
		try {
			request("GET", StenVault.HEALTH, null, false);
			request("GET", StenVault.BUILDS, null, false);
		} catch (Exception ignored) {
		}
	}

	private static void ensureFreshToken() throws Exception {
		if (!session.signedIn()) throw new IllegalStateException("Sign in with sten.wtf first");
		if (session.expiresAt > System.currentTimeMillis() + 30_000) return;
		if (session.refreshToken == null || session.refreshToken.isBlank()) return;
		String form = "grant_type=refresh_token&client_id=" + enc(StenVault.CLIENT_ID) + "&refresh_token=" + enc(session.refreshToken);
		JsonObject tokens = request("POST", StenVault.TOKEN, form, false, "application/x-www-form-urlencoded");
		session.accessToken = text(tokens, "access_token");
		if (tokens.has("refresh_token")) session.refreshToken = text(tokens, "refresh_token");
		try {
			session.expiresAt = System.currentTimeMillis() + tokens.get("expires_in").getAsLong() * 1000L;
		} catch (Exception ignored) {
		}
		session.save();
	}

	private static JsonObject signedPost(String url, byte[] body, String contentType, boolean auth, String shotKind) throws Exception {
		if (!session.canSign()) throw new IllegalStateException("missing report secret");
		byte[] payload = body == null ? new byte[0] : body;
		long seq = session.nextSeq();
		long ts = System.currentTimeMillis() / 1000L;
		String hash = sha256Bytes(payload);
		String mac = hmacHex(session.reportSecret, VaultConfig.get().deviceId + "\n" + seq + "\n" + ts + "\n" + hash);
		HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(url))
			.timeout(Duration.ofSeconds(90))
			.header("User-Agent", StenVault.USER_AGENT)
			.header("Accept", "application/json")
			.header(StenVault.HEADER_NETWORK, StenVault.PROTOCOL)
			.header(StenVault.HEADER_CLIENT, "vault-mod")
			.header(StenVault.HEADER_BUILD, jarHash())
			.header(StenVault.HEADER_DEVICE, VaultConfig.get().deviceId)
			.header(StenVault.HEADER_MC, mcVersion())
			.header(StenVault.HEADER_MOD, modVersion())
			.header(StenVault.HEADER_SEQ, String.valueOf(seq))
			.header(StenVault.HEADER_TS, String.valueOf(ts))
			.header(StenVault.HEADER_MAC, mac)
			.header("Content-Type", contentType);
		if (shotKind != null && !shotKind.isBlank()) b.header(StenVault.HEADER_SHOT, shotKind);
		if (session.challengeId != null && !session.challengeId.isBlank()) {
			b.header(StenVault.HEADER_CHALLENGE, session.challengeId);
		}
		if (auth && session.signedIn()) {
			b.header("Authorization", "Bearer " + session.accessToken);
		}
		b.POST(HttpRequest.BodyPublishers.ofByteArray(payload));
		HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
		int status = res.statusCode();
		if (status >= 300 && status < 400) {
			throw new IllegalStateException("sten.wtf redirected this request (" + status + ")");
		}
		verifySignature(res);
		JsonObject obj = parseJson(res.body(), status);
		if (status >= 400) {
			throw new IllegalStateException(text(obj, "message", text(obj, "error", "sten.wtf HTTP " + status)));
		}
		return obj;
	}

	private static JsonObject request(String method, String url, String body, boolean auth) throws Exception {
		return request(method, url, body, auth, "application/json", Duration.ofSeconds(90));
	}

	private static JsonObject request(String method, String url, String body, boolean auth, String contentType) throws Exception {
		return request(method, url, body, auth, contentType, Duration.ofSeconds(90));
	}

	private static JsonObject request(String method, String url, String body, boolean auth, String contentType, Duration timeout) throws Exception {
		HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(url))
			.timeout(timeout)
			.header("User-Agent", StenVault.USER_AGENT)
			.header("Accept", "application/json")
			.header(StenVault.HEADER_NETWORK, StenVault.PROTOCOL)
			.header(StenVault.HEADER_CLIENT, "vault-mod")
			.header(StenVault.HEADER_BUILD, jarHash())
			.header(StenVault.HEADER_DEVICE, VaultConfig.get().deviceId)
			.header(StenVault.HEADER_MC, mcVersion())
			.header(StenVault.HEADER_MOD, modVersion());
		if (session.challengeId != null && !session.challengeId.isBlank()) {
			b.header(StenVault.HEADER_CHALLENGE, session.challengeId);
		}
		if (auth && session.signedIn()) {
			b.header("Authorization", "Bearer " + session.accessToken);
		}
		if (body != null) {
			b.header("Content-Type", contentType);
			b.method(method, HttpRequest.BodyPublishers.ofString(body));
		} else {
			b.method(method, HttpRequest.BodyPublishers.noBody());
		}
		HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
		int status = res.statusCode();
		if (status >= 300 && status < 400) {
			throw new IllegalStateException("sten.wtf redirected this request (" + status + ")");
		}
		verifySignature(res);
		JsonObject obj = parseJson(res.body(), status);
		if (status >= 400) {
			throw new IllegalStateException(text(obj, "message", text(obj, "error", "sten.wtf HTTP " + status)));
		}
		return obj;
	}

	private static JsonObject parseJson(String payload, int status) throws Exception {
		String raw = payload == null ? "" : payload.trim();
		if (raw.isEmpty()) {
			if (status >= 400) throw new IllegalStateException("sten.wtf HTTP " + status);
			return new JsonObject();
		}
		char first = raw.charAt(0);
		if (first != '{' && first != '[') {
			throw new IllegalStateException(status >= 400 ? "sten.wtf HTTP " + status : "sten.wtf sent a non-JSON response");
		}
		try {
			JsonElement parsed = JsonParser.parseString(raw);
			return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
		} catch (Exception ex) {
			throw new IllegalStateException(status >= 400 ? "sten.wtf HTTP " + status : "sten.wtf sent invalid JSON");
		}
	}

	private static void verifySignature(HttpResponse<String> res) {
		String sig = res.headers().firstValue(StenVault.HEADER_SIG).orElse("");
		if (sig.isBlank() && res.body() != null) {
			try {
				JsonObject obj = JsonParser.parseString(res.body()).getAsJsonObject();
				if (obj.has("sig")) sig = obj.get("sig").getAsString();
			} catch (Exception ignored) {
			}
		}
		if (sig.isBlank()) return;
		try {
			byte[] spki = Base64.getDecoder().decode(StenVault.PUBLIC_KEY_SPKI);
			Signature verifier = Signature.getInstance("Ed25519");
			verifier.initVerify(KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(spki)));
			verifier.update(res.body().getBytes(StandardCharsets.UTF_8));
			verifier.verify(Base64.getUrlDecoder().decode(sig));
		} catch (Exception ignored) {
		}
	}

	private static String htmlPage() {
		return "<!doctype html><html><head><meta charset=utf-8><title>sten.wtf</title>"
			+ "<style>body{margin:0;background:#0a0b12;color:#e6eaf4;font-family:Outfit,Inter,sans-serif;display:grid;place-items:center;min-height:100vh}"
			+ ".c{background:rgba(18,20,28,.72);border:1px solid rgba(90,127,255,.28);border-radius:18px;padding:28px 32px;text-align:center;max-width:420px}"
			+ "h1{font-weight:650;letter-spacing:-.02em} .wtf{color:#67e8f9}</style></head>"
			+ "<body><div class=c><h1>sten<span class=wtf>.wtf</span></h1>"
			+ "<p>You can return to Minecraft. STEN Vault is finishing sign-in.</p>"
			+ "<p style=color:#9aa3b5;font-size:13px>sten.wtf</p></div></body></html>";
	}

	private static String queryParam(String query, String key) {
		if (query == null) return null;
		for (String part : query.split("&")) {
			int eq = part.indexOf('=');
			if (eq < 0) continue;
			if (part.substring(0, eq).equals(key)) {
				return java.net.URLDecoder.decode(part.substring(eq + 1), StandardCharsets.UTF_8);
			}
		}
		return null;
	}

	private static String text(JsonObject obj, String key) {
		return text(obj, key, "");
	}

	private static String text(JsonObject obj, String key, String fallback) {
		if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) return fallback;
		try {
			return obj.get(key).getAsString();
		} catch (Exception ignored) {
			return fallback;
		}
	}

	private static String enc(String value) {
		return URLEncoder.encode(value, StandardCharsets.UTF_8);
	}

	private static byte[] random(int n) {
		byte[] b = new byte[n];
		new SecureRandom().nextBytes(b);
		return b;
	}

	private static String sha256File(Path path) throws Exception {
		MessageDigest digest = MessageDigest.getInstance("SHA-256");
		try (InputStream in = Files.newInputStream(path)) {
			byte[] buf = new byte[8192];
			int n;
			while ((n = in.read(buf)) > 0) digest.update(buf, 0, n);
		}
		return HexFormat.of().formatHex(digest.digest());
	}

	private static String sha256Utf8(String value) {
		try {
			return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
		} catch (Exception e) {
			return "";
		}
	}

	private static String sha256Bytes(byte[] raw) {
		try {
			return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(raw));
		} catch (Exception e) {
			return "";
		}
	}

	private static String hmacHex(String secret, String message) {
		try {
			Mac mac = Mac.getInstance("HmacSHA256");
			mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
			return HexFormat.of().formatHex(mac.doFinal(message.getBytes(StandardCharsets.UTF_8)));
		} catch (Exception e) {
			return "";
		}
	}

	private static String stack(Throwable cause) {
		if (cause == null) return "";
		ByteArrayOutputStream out = new ByteArrayOutputStream();
		cause.printStackTrace(new java.io.PrintStream(out, true, StandardCharsets.UTF_8));
		String s = out.toString(StandardCharsets.UTF_8);
		return s.length() > 16000 ? s.substring(0, 16000) : s;
	}
}