diff --git a/src/main/java/swiss/qpq/gajumaru/core/crypto/Vault.java b/src/main/java/swiss/qpq/gajumaru/core/crypto/Vault.java index d640c73..08ae5c3 100644 --- a/src/main/java/swiss/qpq/gajumaru/core/crypto/Vault.java +++ b/src/main/java/swiss/qpq/gajumaru/core/crypto/Vault.java @@ -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); + } } } diff --git a/src/main/java/swiss/qpq/gajumaru/core/tools/Grids.java b/src/main/java/swiss/qpq/gajumaru/core/tools/Grids.java index 862ae1a..736fa78 100644 --- a/src/main/java/swiss/qpq/gajumaru/core/tools/Grids.java +++ b/src/main/java/swiss/qpq/gajumaru/core/tools/Grids.java @@ -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 makeRequest(String type, Object payload, String publicId, String networkId) { Map req = new LinkedHashMap<>(); diff --git a/src/main/java/swiss/qpq/gajumaru/core/tools/Http.java b/src/main/java/swiss/qpq/gajumaru/core/tools/Http.java new file mode 100644 index 0000000..2818a08 --- /dev/null +++ b/src/main/java/swiss/qpq/gajumaru/core/tools/Http.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026 QPQ AG . All rights reserved. + * Project: Gajumaru Core Java Libraries + * + * 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 + * + * 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()); + } + } +} diff --git a/src/main/java/swiss/qpq/gajumaru/core/tools/NodeClient.java b/src/main/java/swiss/qpq/gajumaru/core/tools/NodeClient.java index 63d5a34..f6ead5d 100644 --- a/src/main/java/swiss/qpq/gajumaru/core/tools/NodeClient.java +++ b/src/main/java/swiss/qpq/gajumaru/core/tools/NodeClient.java @@ -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 endpoints; - private int timeout = 5000; public NodeClient(List 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 status() throws Exception { return (Map) 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; - } } diff --git a/src/main/java/swiss/qpq/gajumaru/core/tools/TransactionService.java b/src/main/java/swiss/qpq/gajumaru/core/tools/TransactionService.java index 642415c..6bb8afb 100644 --- a/src/main/java/swiss/qpq/gajumaru/core/tools/TransactionService.java +++ b/src/main/java/swiss/qpq/gajumaru/core/tools/TransactionService.java @@ -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()); } } diff --git a/src/main/java/swiss/qpq/gajumaru/core/tools/VarInt.java b/src/main/java/swiss/qpq/gajumaru/core/tools/VarInt.java new file mode 100644 index 0000000..daabf53 --- /dev/null +++ b/src/main/java/swiss/qpq/gajumaru/core/tools/VarInt.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026 QPQ AG . All rights reserved. + * Project: Gajumaru Core Java Libraries + * + * 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 + * + * 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(); + } + } +}