From 77dea4f3cf7cd70168c4a5e8376b56c10dbc608f Mon Sep 17 00:00:00 2001 From: Craig Everett Date: Sun, 23 Aug 2026 10:15:59 +0900 Subject: [PATCH] WIP --- .../swiss/qpq/gajumaru/core/crypto/Vault.java | 55 ++-- .../qpq/gajumaru/core/encoding/Mnemonic.java | 2 +- .../swiss/qpq/gajumaru/core/encoding/ZJ.java | 278 ++++++++++++++++++ .../swiss/qpq/gajumaru/core/tools/Grids.java | 140 +++++++++ test/Testinator.java | 59 ++++ 5 files changed, 509 insertions(+), 25 deletions(-) create mode 100644 src/main/java/swiss/qpq/gajumaru/core/encoding/ZJ.java create mode 100644 src/main/java/swiss/qpq/gajumaru/core/tools/Grids.java 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 13efbc0..d640c73 100644 --- a/src/main/java/swiss/qpq/gajumaru/core/crypto/Vault.java +++ b/src/main/java/swiss/qpq/gajumaru/core/crypto/Vault.java @@ -25,17 +25,9 @@ import javax.crypto.SecretKey; import javax.crypto.spec.GCMParameterSpec; -// Vault provides secure AES/GCM encryption and decryption with memory hygiene. -// It uses standard JCE providers but ensures that application-level buffers can be wiped. -// -// Purpose: -// This provides a way to use opaque handles to javax.crypto.SecretKey objects in discrete memory -// instead of exposing your plaintext secret keys to other parts of the system that might leave -// dead references on the heap somewhere that are vulnerable until GC finally hits. -// -// The important thing to remember is that there is still a burden on the caller to take advantage -// of whatever the current platform's best key management facilities are and stick to them. - +/** + * Vault provides secure AES/GCM encryption and decryption with memory hygiene. + */ public final class Vault { private static final String AES_GCM = "AES/GCM/NoPadding"; @@ -43,14 +35,36 @@ public final class Vault { private Vault() {} + /** + * Holds the results of an encryption operation. + */ + public static final class Ciphertext { + private final byte[] iv; + private final byte[] data; + + public Ciphertext(byte[] iv, byte[] data) { + this.iv = iv; + this.data = data; + } + + public byte[] getIv() { return iv; } + public byte[] getData() { return data; } + } + /** * Encrypts plaintext using AES/GCM. - * - * @param key The SecretKey to use. - * @param iv The initialization vector (should be 12 bytes). - * @param plaintext The data to encrypt. - * @return The ciphertext including the authentication tag. - * @throws Exception if encryption fails. + * Letting the provider generate the IV (required by Android Keystore). + */ + 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); + } + + /** + * 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); @@ -61,13 +75,6 @@ public final class Vault { /** * Decrypts ciphertext using AES/GCM. - * - * @param key The SecretKey to use. - * @param iv The initialization vector used during encryption. - * @param ciphertext The data to decrypt (including the authentication tag). - * @return The plaintext data. The caller is RESPONSIBLE for wiping this buffer - * using CryptoUtils.wipe() once it is no longer needed. - * @throws Exception if decryption or authentication fails. */ public static byte[] decrypt(SecretKey key, byte[] iv, byte[] ciphertext) throws Exception { Cipher cipher = Cipher.getInstance(AES_GCM); diff --git a/src/main/java/swiss/qpq/gajumaru/core/encoding/Mnemonic.java b/src/main/java/swiss/qpq/gajumaru/core/encoding/Mnemonic.java index fb5b0e2..a2e2cb2 100644 --- a/src/main/java/swiss/qpq/gajumaru/core/encoding/Mnemonic.java +++ b/src/main/java/swiss/qpq/gajumaru/core/encoding/Mnemonic.java @@ -36,7 +36,7 @@ public final class Mnemonic { private static final int WIDTH = 12; // bits per word private static final int MAX_DATA_CHUNKS = 22; // For 256-bit seed - private static final String[] WORDS = { + public static final String[] WORDS = { "aardvark", "abacus", "abalone", "abandon", "abbey", "abdomen", "abduct", "abhor", "abide", "ability", "able", "abnormal", "aboard", "abolish", "abort", "above", "abrasive", "abridged", "abroad", "abrupt", "abscond", "absence", "absinthe", "absorb", "abstract", "absurd", "abundant", diff --git a/src/main/java/swiss/qpq/gajumaru/core/encoding/ZJ.java b/src/main/java/swiss/qpq/gajumaru/core/encoding/ZJ.java new file mode 100644 index 0000000..92b4af0 --- /dev/null +++ b/src/main/java/swiss/qpq/gajumaru/core/encoding/ZJ.java @@ -0,0 +1,278 @@ +package swiss.qpq.gajumaru.core.encoding; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * ZJ: The tiny JSON parser, ported from zj.erl. + */ +public final class ZJ { + + private ZJ() {} + + public static Object decode(String json) { + if (json == null || json.isEmpty()) return null; + return new Parser(json).parse(); + } + + public static String encode(Object value) { + StringBuilder sb = new StringBuilder(); + encodeValue(value, sb); + return sb.toString(); + } + + private static void encodeValue(Object value, StringBuilder sb) { + if (value == null) { + sb.append("null"); + } else if (value instanceof String) { + sb.append('"'); + escape((String) value, sb); + sb.append('"'); + } else if (value instanceof Boolean) { + sb.append(value); + } else if (value instanceof Number) { + sb.append(value); + } else if (value instanceof Map) { + encodeMap((Map) value, sb); + } else if (value instanceof List) { + encodeList((List) value, sb); + } else if (value instanceof Object[]) { + encodeArray((Object[]) value, sb); + } else { + // Fallback for other objects, though not strictly in ZJ + sb.append('"'); + escape(value.toString(), sb); + sb.append('"'); + } + } + + private static void encodeMap(Map map, StringBuilder sb) { + sb.append('{'); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) sb.append(','); + first = false; + sb.append('"'); + escape(entry.getKey().toString(), sb); + sb.append("\":"); + encodeValue(entry.getValue(), sb); + } + sb.append('}'); + } + + private static void encodeList(List list, StringBuilder sb) { + sb.append('['); + boolean first = true; + for (Object item : list) { + if (!first) sb.append(','); + first = false; + encodeValue(item, sb); + } + sb.append(']'); + } + + private static void encodeArray(Object[] array, StringBuilder sb) { + sb.append('['); + boolean first = true; + for (Object item : array) { + if (!first) sb.append(','); + first = false; + encodeValue(item, sb); + } + sb.append(']'); + } + + private static void escape(String s, StringBuilder sb) { + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\b' -> sb.append("\\b"); + case '\f' -> sb.append("\\f"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (c < 32 || c > 126) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + } + } + + private static class Parser { + private final String json; + private int pos = 0; + + Parser(String json) { + this.json = json; + } + + Object parse() { + seek(); + if (pos >= json.length()) return null; + return value(); + } + + private Object value() { + char c = json.charAt(pos); + return switch (c) { + case '{' -> object(); + case '[' -> array(); + case '"' -> string(); + case 't' -> bool(true); + case 'f' -> bool(false); + case 'n' -> nil(); + default -> { + if (c == '-' || (c >= '0' && c <= '9')) { + yield number(); + } + throw new RuntimeException("Unexpected character at " + pos + ": " + c); + } + }; + } + + private Map object() { + pos++; // skip '{' + Map map = new HashMap<>(); + seek(); + if (pos < json.length() && json.charAt(pos) == '}') { + pos++; + return map; + } + while (true) { + seek(); + String key = string(); + seek(); + if (pos >= json.length() || json.charAt(pos) != ':') { + throw new RuntimeException("Expected ':' at " + pos); + } + pos++; + seek(); + Object val = value(); + map.put(key, val); + seek(); + if (pos < json.length() && json.charAt(pos) == '}') { + pos++; + break; + } + if (pos >= json.length() || json.charAt(pos) != ',') { + throw new RuntimeException("Expected ',' or '}' at " + pos); + } + pos++; + } + return map; + } + + private List array() { + pos++; // skip '[' + List list = new ArrayList<>(); + seek(); + if (pos < json.length() && json.charAt(pos) == ']') { + pos++; + return list; + } + while (true) { + seek(); + list.add(value()); + seek(); + if (pos < json.length() && json.charAt(pos) == ']') { + pos++; + break; + } + if (pos >= json.length() || json.charAt(pos) != ',') { + throw new RuntimeException("Expected ',' or ']' at " + pos); + } + pos++; + } + return list; + } + + private String string() { + if (json.charAt(pos) != '"') throw new RuntimeException("Expected '\"' at " + pos); + pos++; + StringBuilder sb = new StringBuilder(); + while (pos < json.length()) { + char c = json.charAt(pos++); + if (c == '"') return sb.toString(); + if (c == '\\') { + if (pos >= json.length()) throw new RuntimeException("Unterminated escape at " + pos); + char esc = json.charAt(pos++); + switch (esc) { + case '"' -> sb.append('"'); + case '\\' -> sb.append('\\'); + case '/' -> sb.append('/'); + case 'b' -> sb.append('\b'); + case 'f' -> sb.append('\f'); + case 'n' -> sb.append('\n'); + case 'r' -> sb.append('\r'); + case 't' -> sb.append('\t'); + case 'u' -> { + if (pos + 4 > json.length()) throw new RuntimeException("Invalid unicode escape"); + String hex = json.substring(pos, pos + 4); + sb.append((char) Integer.parseInt(hex, 16)); + pos += 4; + } + default -> throw new RuntimeException("Unknown escape: " + esc); + } + } else { + sb.append(c); + } + } + throw new RuntimeException("Unterminated string"); + } + + private Boolean bool(boolean expected) { + String s = expected ? "true" : "false"; + if (json.startsWith(s, pos)) { + pos += s.length(); + return expected; + } + throw new RuntimeException("Expected " + s + " at " + pos); + } + + private Object nil() { + if (json.startsWith("null", pos)) { + pos += 4; + return null; + } + throw new RuntimeException("Expected null at " + pos); + } + + private Number number() { + int start = pos; + if (json.charAt(pos) == '-') pos++; + while (pos < json.length() && Character.isDigit(json.charAt(pos))) pos++; + boolean isFloat = false; + if (pos < json.length() && json.charAt(pos) == '.') { + isFloat = true; + pos++; + while (pos < json.length() && Character.isDigit(json.charAt(pos))) pos++; + } + if (pos < json.length() && (json.charAt(pos) == 'e' || json.charAt(pos) == 'E')) { + isFloat = true; + pos++; + if (pos < json.length() && (json.charAt(pos) == '+' || json.charAt(pos) == '-')) pos++; + while (pos < json.length() && Character.isDigit(json.charAt(pos))) pos++; + } + String s = json.substring(start, pos); + if (isFloat) return Double.parseDouble(s); + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + return new BigInteger(s); + } + } + + private void seek() { + while (pos < json.length() && Character.isWhitespace(json.charAt(pos))) pos++; + } + } +} diff --git a/src/main/java/swiss/qpq/gajumaru/core/tools/Grids.java b/src/main/java/swiss/qpq/gajumaru/core/tools/Grids.java new file mode 100644 index 0000000..d51fa35 --- /dev/null +++ b/src/main/java/swiss/qpq/gajumaru/core/tools/Grids.java @@ -0,0 +1,140 @@ +package swiss.qpq.gajumaru.core.tools; + +import java.math.BigInteger; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * GRIDS URL handling, ported from hz_grids.erl. + */ +public final class Grids { + + private Grids() {} + + public enum Verb { + SPEND, + TRANSFER, + SIGN + } + + public enum Context { + CHAIN, + NODE, + HTTP, + HTTPS + } + + public static final class ParseResult { + public final Verb verb; + public final Context context; + public final String location; + public final String recipient; + public final BigInteger amount; + public final byte[] payload; + public final String url; // For SIGN verb, the reconstructed URL + + private ParseResult(Verb verb, Context context, String location, String recipient, BigInteger amount, byte[] payload, String url) { + this.verb = verb; + this.context = context; + this.location = location; + this.recipient = recipient; + this.amount = amount; + this.payload = payload; + this.url = url; + } + + public static ParseResult spend(Context context, String location, String recipient, BigInteger amount, byte[] payload) { + return new ParseResult(Verb.SPEND, context, location, recipient, amount, payload, null); + } + + public static ParseResult transfer(Context context, String location, String recipient, BigInteger amount, byte[] payload) { + return new ParseResult(Verb.TRANSFER, context, location, recipient, amount, payload, null); + } + + public static ParseResult sign(Context context, String url) { + return new ParseResult(Verb.SIGN, context, null, null, null, null, url); + } + } + + public static ParseResult parse(String gridsUrl) { + try { + URI uri = new URI(gridsUrl); + String scheme = uri.getScheme(); + String host = uri.getHost(); + String path = uri.getPath(); + String query = uri.getQuery(); + + if (!"grids".equals(scheme) && !"grid".equals(scheme)) { + throw new IllegalArgumentException("Invalid scheme: " + scheme); + } + + if (path.startsWith("/1/s/")) { + String recipient = path.substring(5); + Map qargs = parseQuery(query); + BigInteger amount = new BigInteger(qargs.getOrDefault("a", "0")); + byte[] payload = decodePayload(qargs.getOrDefault("p", "")); + return ParseResult.spend(Context.CHAIN, host, recipient, amount, payload); + } else if (path.startsWith("/1/t/")) { + String recipient = path.substring(5); + Map qargs = parseQuery(query); + BigInteger amount = new BigInteger(qargs.getOrDefault("a", "0")); + byte[] payload = decodePayload(qargs.getOrDefault("p", "")); + 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 httpScheme = "grids".equals(scheme) ? "https" : "http"; + String httpUrl = httpScheme + "://" + host + (uri.getPort() != -1 ? ":" + uri.getPort() : "") + subPath; + if (query != null) httpUrl += "?" + query; + return ParseResult.sign("grids".equals(scheme) ? Context.HTTPS : Context.HTTP, httpUrl); + } else { + throw new IllegalArgumentException("Unknown verb in path: " + path); + } + } catch (Exception e) { + throw new RuntimeException("Failed to parse GRIDS URL: " + e.getMessage(), e); + } + } + + public static final String REQ_TYPE_MESSAGE = "message"; + 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 HashMap<>(); + req.put("grids", 1); + req.put("chain", "gajumaru"); + req.put("network_id", networkId); + req.put("type", type); + req.put("public_id", publicId); + req.put("payload", payload); + return req; + } + + private static Map parseQuery(String query) { + Map params = new HashMap<>(); + if (query == null || query.isEmpty()) return params; + String[] pairs = query.split("&"); + for (String pair : pairs) { + int idx = pair.indexOf("="); + if (idx > 0) { + params.put(URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8), + URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8)); + } else { + params.put(URLDecoder.decode(pair, StandardCharsets.UTF_8), ""); + } + } + return params; + } + + private static byte[] decodePayload(String p) { + if (p == null || p.isEmpty()) return new byte[0]; + // The Erlang side uses uri_string:compose_query which percent-encodes. + // URLDecoder.decode should have handled it in parseQuery. + // Assuming payload is just string data or hex? + // hz_grids.erl says: Payload = list_to_binary(proplists:get_value("p", ArgList, "")) + return p.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/test/Testinator.java b/test/Testinator.java index e774487..88b9ef9 100644 --- a/test/Testinator.java +++ b/test/Testinator.java @@ -5,11 +5,14 @@ import java.util.Base64; import java.util.ArrayList; import java.util.List; import java.util.Arrays; +import java.util.Map; +import java.util.HashMap; import java.math.BigInteger; import swiss.qpq.gajumaru.core.encoding.Base58; import swiss.qpq.gajumaru.core.encoding.RLP; import swiss.qpq.gajumaru.core.encoding.ApiEncoder; +import swiss.qpq.gajumaru.core.encoding.ZJ; import swiss.qpq.gajumaru.core.formatting.GajuFormat; import swiss.qpq.gajumaru.core.crypto.Keccak256; import swiss.qpq.gajumaru.core.crypto.Blake2b; @@ -19,6 +22,7 @@ import swiss.qpq.gajumaru.core.data.SpendTx; import swiss.qpq.gajumaru.core.data.SignedTx; import swiss.qpq.gajumaru.core.encoding.Mnemonic; import swiss.qpq.gajumaru.core.tools.CryptoUtils; +import swiss.qpq.gajumaru.core.tools.Grids; public class Testinator { public static void main(String[] args) { @@ -45,6 +49,9 @@ public class Testinator { case "spend_tx" -> { System.out.print(spend_tx(args[1])); } case "signed_tx" -> { System.out.print(signed_tx(args[1])); } case "mnemonic" -> { System.out.print(mnemonic(args[1])); } + case "zj_encode" -> { System.out.print(zj_encode(args[1])); } + case "zj_decode" -> { System.out.print(zj_decode(args[1])); } + case "grids_parse" -> { System.out.print(grids_parse(args[1])); } case "fe_parity" -> { System.out.print(fe_parity(args[1])); } case "reduce_parity" -> { System.out.print(reduce_parity(args[1])); } case "smb_parity" -> { System.out.print(smb_parity(args[1])); } @@ -493,4 +500,56 @@ public class Testinator { Files.write(resPath, results); return resPath.toString(); } + + private static String zj_encode(String workingPath) throws IOException { + Path testPath = Path.of(workingPath, "zj_encode.test"); + Path resPath = Path.of(workingPath, "zj_encode.java.txt"); + List lines = Files.readAllLines(testPath); + List results = new ArrayList<>(); + for (String line : lines) { + if (line.trim().isEmpty()) continue; + // For testing, we decode then re-encode to check consistency + Object val = ZJ.decode(line); + results.add(ZJ.encode(val)); + } + Files.write(resPath, results); + return resPath.toString(); + } + + private static String zj_decode(String workingPath) throws IOException { + Path testPath = Path.of(workingPath, "zj_decode.test"); + Path resPath = Path.of(workingPath, "zj_decode.java.txt"); + List lines = Files.readAllLines(testPath); + List results = new ArrayList<>(); + for (String line : lines) { + if (line.trim().isEmpty()) continue; + Object val = ZJ.decode(line); + // We encode it back to string for comparison or output format + results.add(ZJ.encode(val)); + } + Files.write(resPath, results); + return resPath.toString(); + } + + private static String grids_parse(String workingPath) throws IOException { + Path testPath = Path.of(workingPath, "grids_parse.test"); + Path resPath = Path.of(workingPath, "grids_parse.java.txt"); + List lines = Files.readAllLines(testPath); + List results = new ArrayList<>(); + for (String url : lines) { + if (url.trim().isEmpty()) continue; + Grids.ParseResult res = Grids.parse(url); + Map map = new HashMap<>(); + map.put("verb", res.verb.name()); + map.put("context", res.context.name()); + map.put("location", res.location); + map.put("recipient", res.recipient); + map.put("amount", res.amount != null ? res.amount.toString() : null); + map.put("payload", res.payload != null ? CryptoUtils.binToHex(res.payload) : null); + map.put("url", res.url); + results.add(ZJ.encode(map)); + } + Files.write(resPath, results); + return resPath.toString(); + } }