This commit is contained in:
2026-08-23 10:15:59 +09:00
parent 7493b473a7
commit 77dea4f3cf
5 changed files with 509 additions and 25 deletions
@@ -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);
@@ -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",
@@ -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<String, Object> object() {
pos++; // skip '{'
Map<String, Object> 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<Object> array() {
pos++; // skip '['
List<Object> 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++;
}
}
}
@@ -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<String, String> 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<String, String> 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<String, Object> makeRequest(String type, Object payload, String publicId, String networkId) {
Map<String, Object> 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<String, String> parseQuery(String query) {
Map<String, String> 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);
}
}
+59
View File
@@ -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<String> lines = Files.readAllLines(testPath);
List<String> 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<String> lines = Files.readAllLines(testPath);
List<String> 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<String> lines = Files.readAllLines(testPath);
List<String> results = new ArrayList<>();
for (String url : lines) {
if (url.trim().isEmpty()) continue;
Grids.ParseResult res = Grids.parse(url);
Map<String, Object> 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();
}
}