Compare commits

..
1 Commits
Author SHA1 Message Date
zxq9 64c592285a WIP: Lots of networking niggly bits and JNI stuff and uuugh 2026-08-25 20:52:43 +09:00
6 changed files with 302 additions and 99 deletions
@@ -20,6 +20,7 @@
package swiss.qpq.gajumaru.core.crypto;
import java.security.SecureRandom;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
@@ -32,6 +33,7 @@ public final class Vault {
private static final String AES_GCM = "AES/GCM/NoPadding";
private static final int GCM_TAG_LENGTH = 128; // Bits
private static final int IV_LENGTH = 12; // Bytes
private Vault() {}
@@ -53,33 +55,53 @@ public final class Vault {
/**
* Encrypts plaintext using AES/GCM.
* Letting the provider generate the IV (required by Android Keystore).
* Manually generates the IV to ensure deterministic tag length and cross-provider consistency.
*/
public static Ciphertext encrypt(SecretKey key, byte[] plaintext) throws Exception {
Cipher cipher = Cipher.getInstance(AES_GCM);
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] iv = cipher.getIV();
byte[] ciphertext = cipher.doFinal(plaintext);
return new Ciphertext(iv, ciphertext);
try {
byte[] iv = new byte[IV_LENGTH];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance(AES_GCM);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, key, spec);
byte[] ciphertext = cipher.doFinal(plaintext);
return new Ciphertext(iv, ciphertext);
} catch (Exception e) {
String msg = e.getMessage();
if (msg == null) msg = e.toString();
throw new Exception("Vault.encrypt failed: " + msg, e);
}
}
/**
* Encrypts plaintext using AES/GCM with a specific IV.
*/
public static byte[] encrypt(SecretKey key, byte[] iv, byte[] plaintext) throws Exception {
Cipher cipher = Cipher.getInstance(AES_GCM);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, key, spec);
return cipher.doFinal(plaintext);
try {
Cipher cipher = Cipher.getInstance(AES_GCM);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, key, spec);
return cipher.doFinal(plaintext);
} catch (Exception e) {
throw new Exception("Vault.encrypt(iv) failed: " + e.getClass().getName() + ": " + e.getMessage(), e);
}
}
/**
* Decrypts ciphertext using AES/GCM.
*/
public static byte[] decrypt(SecretKey key, byte[] iv, byte[] ciphertext) throws Exception {
Cipher cipher = Cipher.getInstance(AES_GCM);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, key, spec);
return cipher.doFinal(ciphertext);
try {
Cipher cipher = Cipher.getInstance(AES_GCM);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, key, spec);
return cipher.doFinal(ciphertext);
} catch (Exception e) {
String msg = e.getMessage();
if (msg == null) msg = e.toString();
throw new Exception("Vault.decrypt failed: " + msg, e);
}
}
}
@@ -79,6 +79,7 @@ public final class Grids {
}
public static ParseResult parse(String gridsUrl) {
android.util.Log.i("Grids", "Parsing URL: " + gridsUrl);
try {
URI uri = new URI(gridsUrl);
String scheme = uri.getScheme();
@@ -86,6 +87,8 @@ public final class Grids {
String path = uri.getPath();
String query = uri.getQuery();
android.util.Log.i("Grids", "Components: scheme=" + scheme + ", host=" + host + ", path=" + path);
if (!"grids".equals(scheme) && !"grid".equals(scheme)) {
throw new IllegalArgumentException("Invalid scheme: " + scheme);
}
@@ -104,10 +107,10 @@ public final class Grids {
String location = host + (uri.getPort() != -1 ? ":" + uri.getPort() : "");
return ParseResult.transfer(Context.NODE, location, recipient, amount, payload);
} else if (path.startsWith("/1/d/")) {
String subPath = path.substring(5);
String subPath = path.substring(4); // Includes the leading slash
String httpScheme = "grids".equals(scheme) ? "https" : "http";
String baseUrl = httpScheme + "://" + host + (uri.getPort() != -1 ? ":" + uri.getPort() : "") + subPath;
String httpUrl = query != null ? baseUrl + "?" + query : baseUrl;
String hostPart = host + (uri.getPort() != -1 ? ":" + uri.getPort() : "");
String httpUrl = httpScheme + "://" + hostPart + subPath + (query != null ? "?" + query : "");
return ParseResult.sign("grids".equals(scheme) ? Context.HTTPS : Context.HTTP, httpUrl);
} else {
throw new IllegalArgumentException("Unknown verb in path: " + path);
@@ -118,8 +121,8 @@ public final class Grids {
}
public static final String REQ_TYPE_MESSAGE = "message";
public static final String REQ_TYPE_BINARY = "binary";
public static final String REQ_TYPE_TX = "tx";
public static final String REQ_TYPE_ACK = "ack";
public static Map<String, Object> makeRequest(String type, Object payload, String publicId, String networkId) {
Map<String, Object> req = new LinkedHashMap<>();
@@ -0,0 +1,112 @@
/*
* Copyright (c) 2026 QPQ AG <info@qpq.swiss>. All rights reserved.
* Project: Gajumaru Core Java Libraries <gajumaru.io>
*
* This program is dual-licensed:
* 1) Under the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version (AGPL-3.0-or-later).
*
* 2) Under a commercial/proprietary license available directly from QPQ AG.
* If you wish to use this software outside the strict constraints of the
* AGPLv3 (e.g. within a closed-source or proprietary product), you must
* purchase a commercial license from QPQ AG.
*
* Authors:
* - Craig Everett <craigeverett@qpq.swiss>
*
* SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-QPQ-Commercial
*/
package swiss.qpq.gajumaru.core.tools;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import swiss.qpq.gajumaru.core.encoding.ZJ;
// Minimal HTTP client for GRIDS and Node interaction.
public final class Http {
private Http() {}
public static Object get(String url) throws Exception {
return request(url, "GET", null);
}
public static Object post(String url, Object body) throws Exception {
return request(url, "POST", body);
}
private static Object request(String urlStr, String method, Object body) throws Exception {
android.util.Log.i("Http", "Request [" + method + "]: " + urlStr);
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod(method);
conn.setConnectTimeout(15000);
conn.setReadTimeout(15000);
conn.setInstanceFollowRedirects(true);
// Emulate a standard browser agent to avoid being blocked by strict servers
conn.setRequestProperty("Accept", "application/json, text/plain, */*");
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 10) GajuMobile/1.0");
if (body != null) {
conn.setRequestProperty("Content-Type", "application/json");
conn.setDoOutput(true);
try (OutputStream os = conn.getOutputStream()) {
byte[] input;
if (body instanceof String) {
input = ((String) body).getBytes(StandardCharsets.UTF_8);
} else {
input = ZJ.encode(body).getBytes(StandardCharsets.UTF_8);
}
os.write(input, 0, input.length);
}
}
int code = conn.getResponseCode();
android.util.Log.i("Http", "Response Code for " + urlStr + ": " + code);
StringBuilder response = new StringBuilder();
try (java.io.InputStream is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream();
BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
response.append(line).append("\n");
}
}
String responseStr = response.toString().trim();
if (code < 200 || code >= 300) {
String reason = "HTTP " + code;
try {
Object decoded = ZJ.decode(responseStr);
if (decoded instanceof Map) {
Object r = ((Map<?, ?>) decoded).get("reason");
if (r != null) reason = r.toString();
}
} catch (Exception ignored) {}
throw new Exception(reason + " (URL: " + urlStr + ")");
}
if (responseStr.isEmpty()) return null;
try {
return ZJ.decode(responseStr);
} catch (Exception e) {
if (responseStr.startsWith("<")) {
android.util.Log.e("Http", "Server returned HTML for " + urlStr + ". Body preview: " +
(responseStr.length() > 500 ? responseStr.substring(0, 500) : responseStr));
throw new Exception("Server returned HTML instead of JSON. Full URL and body logged to Logcat.");
}
throw new Exception("JSON parse failed for " + urlStr + ": " + e.getMessage());
}
}
}
@@ -20,32 +20,14 @@
package swiss.qpq.gajumaru.core.tools;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import swiss.qpq.gajumaru.core.encoding.ZJ;
// NodeClient provides a simple interface to query Gajumaru nodes.
// Ported from hz.erl
//
// Conceptually very similar to Hakuzaru's main interface, but (at least currently)
// more limited (no contract deployment, call, etc.). The most obvious difference is that
// instead of gm-java being a stateful service the way hz can be, this is a pure library
// and the caller is expected to be the one that is keeping track of a list of possible
// endpoints.
//
// This design will evolve in the future as the concept of network and inter-network
// crawling evolves (and obviously as ACs are introduced). In this iteration, though,
// my goal is to keep things as simple as possible, and that is why this is probably
// somewhat unidiomatic for Java (I don't *really* know, but this works).
/**
* NodeClient provides a simple interface to query Gajumaru nodes.
* Ported from hz.erl
*/
public final class NodeClient {
public static final class Endpoint {
@@ -65,7 +47,6 @@ public final class NodeClient {
}
private final List<Endpoint> endpoints;
private int timeout = 5000;
public NodeClient(List<Endpoint> endpoints) {
if (endpoints == null || endpoints.isEmpty()) {
@@ -74,10 +55,6 @@ public final class NodeClient {
this.endpoints = endpoints;
}
public void setTimeout(int timeout) {
this.timeout = timeout;
}
public Map<String, Object> status() throws Exception {
return (Map<String, Object>) request("/v3/status", "GET", null);
}
@@ -121,15 +98,19 @@ public final class NodeClient {
Exception lastException = null;
for (Endpoint endpoint : endpoints) {
try {
String protocol = endpoint.useTls ? "https://" : "http://";
String url = protocol + endpoint.host + ":" + endpoint.port + path;
if (endpoint.useTls) {
try {
return performRequest(endpoint, path, method, body, true);
return "GET".equals(method) ? Http.get(url) : Http.post(url, body);
} catch (Exception e) {
// Fallback to HTTP
return performRequest(endpoint, path, method, body, false);
String httpUrl = "http://" + endpoint.host + ":" + endpoint.port + path;
return "GET".equals(method) ? Http.get(httpUrl) : Http.post(httpUrl, body);
}
} else {
return performRequest(endpoint, path, method, body, false);
return "GET".equals(method) ? Http.get(url) : Http.post(url, body);
}
} catch (Exception e) {
lastException = e;
@@ -137,46 +118,4 @@ public final class NodeClient {
}
throw new Exception("All endpoints failed. Last error: " + (lastException != null ? lastException.getMessage() : "unknown"), lastException);
}
private Object performRequest(Endpoint endpoint, String path, String method, Object body, boolean useTls) throws Exception {
String protocol = useTls ? "https://" : "http://";
String urlStr = protocol + endpoint.host + ":" + endpoint.port + path;
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod(method);
conn.setConnectTimeout(timeout);
conn.setReadTimeout(timeout);
conn.setRequestProperty("Accept", "application/json");
if (body != null) {
conn.setRequestProperty("Content-Type", "application/json");
conn.setDoOutput(true);
try (OutputStream os = conn.getOutputStream()) {
byte[] input = ZJ.encode(body).getBytes(StandardCharsets.UTF_8);
os.write(input, 0, input.length);
}
}
int code = conn.getResponseCode();
StringBuilder response = new StringBuilder();
try (BufferedReader br = new BufferedReader(new InputStreamReader(
code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream(),
StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
response.append(line.trim());
}
}
Object decoded = ZJ.decode(response.toString());
if (code < 200 || code >= 300) {
String reason = "HTTP " + code;
if (decoded instanceof Map) {
Object r = ((Map<?, ?>) decoded).get("reason");
if (r != null) reason = r.toString();
}
throw new Exception(reason);
}
return decoded;
}
}
@@ -24,19 +24,23 @@ import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.List;
import swiss.qpq.gajumaru.core.crypto.Blake2b;
import swiss.qpq.gajumaru.core.crypto.Ed25519;
import swiss.qpq.gajumobile.security.GajuNative;
import swiss.qpq.gajumaru.core.data.Id;
import swiss.qpq.gajumaru.core.data.SignedTx;
import swiss.qpq.gajumaru.core.data.SpendTx;
import swiss.qpq.gajumaru.core.encoding.ApiEncoder;
// This is where the gnarly mess used to generate transactions lives for now.
// They might not stay here, but this is where they are for now.
/**
* TransactionService provides high-level tools to build and sign transactions
* and other chain data.
*/
public final class TransactionService {
private TransactionService() {}
/**
* Builds and signs a Spend transaction using a 32-byte seed.
*/
public static String buildSpendTx(
String networkId,
String senderId,
@@ -47,12 +51,11 @@ public final class TransactionService {
long ttl,
long nonce,
String payload,
byte[] seckey
byte[] seed
) {
// 1. Decode IDs
Id sender = new Id(Id.Tag.ACCOUNT, ApiEncoder.decode(senderId).payload());
// Recipient can be ak_ (account) or ct_ (contract)
ApiEncoder.DecodeResult recipientResult = ApiEncoder.decode(recipientId);
Id.Tag recipientTag = recipientResult.type() == ApiEncoder.Type.CONTRACT_PUBKEY ? Id.Tag.CONTRACT : Id.Tag.ACCOUNT;
Id recipient = new Id(recipientTag, recipientResult.payload());
@@ -69,13 +72,78 @@ public final class TransactionService {
System.arraycopy(networkIdBytes, 0, networkHash, 0, networkIdBytes.length);
System.arraycopy(txHash, 0, networkHash, networkIdBytes.length, txHash.length);
// 4. Sign
byte[] signature = Ed25519.sign(seckey, networkHash);
// 4. Expand Seed and Sign
byte[] keypair = GajuNative.cryptoSignSeedKeypair(seed);
if (keypair == null) throw new RuntimeException("Native keypair generation failed");
byte[] secretKey = new byte[64];
System.arraycopy(keypair, 32, secretKey, 0, 64);
byte[] signature = GajuNative.cryptoSignDetached(networkHash, secretKey);
GajuNative.memzero(keypair);
GajuNative.memzero(secretKey);
if (signature == null) throw new RuntimeException("Native signing failed");
// 5. Wrap in SignedTx
SignedTx signedTx = new SignedTx(List.of(signature), serializedTx);
// 6. Encode for API
return ApiEncoder.encode(ApiEncoder.Type.TRANSACTION, signedTx.serialize());
}
/**
* Signs a message using the Gajumaru standard (prefixed and hashed).
* @param secretKey 64-byte expanded secret key.
*/
public static byte[] signMessage(String message, byte[] secretKey) {
byte[] msgBytes = message.getBytes(StandardCharsets.UTF_8);
byte[] prefix = "Gajumaru Signed Message:\n".getBytes(StandardCharsets.UTF_8);
byte[] pSize = VarInt.encode(prefix.length);
byte[] mSize = VarInt.encode(msgBytes.length);
byte[] smashed = new byte[pSize.length + prefix.length + mSize.length + msgBytes.length];
int pos = 0;
System.arraycopy(pSize, 0, smashed, pos, pSize.length); pos += pSize.length;
System.arraycopy(prefix, 0, smashed, pos, prefix.length); pos += prefix.length;
System.arraycopy(mSize, 0, smashed, pos, mSize.length); pos += mSize.length;
System.arraycopy(msgBytes, 0, smashed, pos, msgBytes.length);
byte[] hashed = Blake2b.hash(smashed, 32);
return GajuNative.cryptoSignDetached(hashed, secretKey);
}
/**
* Signs binary data using the Gajumaru standard (prefixed and hashed).
* @param secretKey 64-byte expanded secret key.
*/
public static byte[] signBinary(byte[] data, byte[] secretKey) {
byte[] prefix = "Gajumaru Signed Binary:".getBytes(StandardCharsets.UTF_8);
byte[] target = new byte[prefix.length + data.length];
System.arraycopy(prefix, 0, target, 0, prefix.length);
System.arraycopy(data, 0, target, prefix.length, data.length);
byte[] hashed = Blake2b.hash(target, 32);
return GajuNative.cryptoSignDetached(hashed, secretKey);
}
/**
* Signs a transaction.
* @param secretKey 64-byte expanded secret key.
*/
public static String signTx(byte[] txData, String networkId, byte[] secretKey) {
byte[] nidBytes = networkId.getBytes(StandardCharsets.UTF_8);
byte[] hash = Blake2b.hash(txData, 32);
byte[] networkHash = new byte[nidBytes.length + hash.length];
System.arraycopy(nidBytes, 0, networkHash, 0, nidBytes.length);
System.arraycopy(hash, 0, networkHash, nidBytes.length, hash.length);
byte[] signature = GajuNative.cryptoSignDetached(networkHash, secretKey);
if (signature == null) throw new RuntimeException("Native signing failed");
SignedTx signedTx = new SignedTx(List.of(signature), txData);
return ApiEncoder.encode(ApiEncoder.Type.TRANSACTION, signedTx.serialize());
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2026 QPQ AG <info@qpq.swiss>. All rights reserved.
* Project: Gajumaru Core Java Libraries <gajumaru.io>
*
* This program is dual-licensed:
* 1) Under the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version (AGPL-3.0-or-later).
*
* 2) Under a commercial/proprietary license available directly from QPQ AG.
* If you wish to use this software outside the strict constraints of the
* AGPLv3 (e.g. within a closed-source or proprietary product), you must
* purchase a commercial license from QPQ AG.
*
* Authors:
* - Craig Everett <craigeverett@qpq.swiss>
*
* SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-QPQ-Commercial
*/
package swiss.qpq.gajumaru.core.tools;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
// Bitcoin-style Variable-length integer encoding.
// Port of vencode in hz.erl.
public final class VarInt {
private VarInt() {}
public static byte[] encode(long n) {
if (n < 0) {
throw new IllegalArgumentException("Negative values not supported: " + n);
}
if (n < 0xfd) {
return new byte[]{(byte) n};
} else if (n <= 0xffff) {
ByteBuffer bb = ByteBuffer.allocate(3);
bb.order(ByteOrder.LITTLE_ENDIAN);
bb.put((byte) 0xfd);
bb.putShort((short) n);
return bb.array();
} else if (n <= 0xffffffffL) {
ByteBuffer bb = ByteBuffer.allocate(5);
bb.order(ByteOrder.LITTLE_ENDIAN);
bb.put((byte) 0xfe);
bb.putInt((int) n);
return bb.array();
} else {
ByteBuffer bb = ByteBuffer.allocate(9);
bb.order(ByteOrder.LITTLE_ENDIAN);
bb.put((byte) 0xff);
bb.putLong(n);
return bb.array();
}
}
}