Compare commits

...
8 Commits
Author SHA1 Message Date
zxq9 11516b1cca WIP 2026-08-23 16:13:48 +09:00
zxq9 c1c13213c1 WIP: Fix lingering S < L issue 2026-08-23 11:38:14 +09:00
zxq9 5407431566 WIP: Add note about memory safety 2026-08-23 11:17:12 +09:00
zxq9 ea7a013abe WIP: Tiny note 2026-08-23 11:03:00 +09:00
zxq9 ddc811b63d WIP: fixed Character.isDigit() issue by making my own isDigit() 2026-08-23 10:50:04 +09:00
zxq9 c21f399b69 WIP 2026-08-23 10:41:49 +09:00
zxq9 97bb833d35 WIP: Notes 2026-08-23 10:19:42 +09:00
zxq9 77dea4f3cf WIP 2026-08-23 10:15:59 +09:00
9 changed files with 1075 additions and 37 deletions
@@ -30,7 +30,21 @@ import swiss.qpq.gajumaru.core.tools.CryptoUtils;
// Pure Java implementation of Ed25519 curve arithmetic.
// Uses exquisitely annoying Radix-2^25.5 field arithmetic. Never do this if you can avoid it.
// Verified against the canonical Erlang ec_utils (see test/README.md for how).
//
// SAFETY NOTE:
// Despite the effort at zeroing sensitive memory in this module, Java heap memory does not
// provide reliable secret-memory erasure or even really permit it. If protection against
// copies of secret material in managed memory is a requirement of the deployment environment,
// don't use this implementation; use explicitly managed native memory instead.
//
// This implementation is intended for isolated environments where the Java heap is an
// acceptable trust boundary. It is not intended to provide secure-memory guarantees
// against a hostile or inspecting runtime.
//
// Android is one such platform where a vendor-provided C implementation of Ed25519
// interacting over a JNI boundary with off-heap memory (via java.nio.ByteBuffer)
// and aggressive memory sanitation is necessary.
//
// References:
// I don't even know where to start with references, but the tink-java library and pretty much
// everything (and everyone!) referenced on the lib25519 page deserves a mention.
@@ -63,9 +77,26 @@ public final class Ed25519 {
private static final long[] D2 = {45281625L, 27714825L, 36363642L, 13898781L, 229458L, 15978800L, 54557047L, 27058993L, 29715967L, 9444199L};
private static final long[] I = {34513072L, 25610706L, 9377949L, 3500415L, 12389472L, 33281959L, 41962654L, 31548777L, 326685L, 11406482L};
// Group order L (little-endian)
private static final byte[] L = {
(byte) 0xed, (byte) 0xd3, (byte) 0xf5, (byte) 0x5c, (byte) 0x1a, (byte) 0x63, (byte) 0x12, (byte) 0x58,
(byte) 0xd6, (byte) 0x9c, (byte) 0xf7, (byte) 0xa2, (byte) 0xde, (byte) 0xf9, (byte) 0xde, (byte) 0x14,
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x10
};
// Field prime P = 2^255 - 19 (little-endian, 255 bits used)
private static final byte[] P = {
(byte) 0xed, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff,
(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff,
(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff,
(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0x7f
};
public static final class Ge {
public final long[] X = new long[10], Y = new long[10], Z = new long[10], T = new long[10];
// Internal: Wipes the coordinates.
public void wipe() {
CryptoUtils.wipe(X);
CryptoUtils.wipe(Y);
@@ -82,6 +113,7 @@ public final class Ed25519 {
public final long[][] stack = new long[10][10];
public final Ge geTmp = new Ge();
// Internal: Wipes the scratch space.
public void wipe() {
CryptoUtils.wipe(a);
CryptoUtils.wipe(b);
@@ -156,7 +188,7 @@ public final class Ed25519 {
if (signature.length != 64) return false;
byte[] R_bytes = Arrays.copyOfRange(signature, 0, 32);
byte[] S_bytes = Arrays.copyOfRange(signature, 32, 64);
if ((S_bytes[31] & 0xe0) != 0) return false;
if (!isLessThan(S_bytes, L)) return false;
Scratch sc = new Scratch();
Ge A = decompress(publicKey, sc);
if (A == null) return false;
@@ -188,6 +220,11 @@ public final class Ed25519 {
return ok;
}
// NOTE:
// Internal curve and field arithmetic machinery.
// These are public only for parity testing against reference implementations.
// Internal: Scalar multiplication by base point.
public static Ge scalarMulBase(byte[] scalar, Scratch s) {
Ge res = new Ge();
fe_0(res.X);
@@ -209,16 +246,7 @@ public final class Ed25519 {
return res;
}
public static void ge_double_scalarmul_vartime(Ge r, byte[] s, Ge a, byte[] k, Scratch sc) {
Ge sB = scalarMulBase(s, sc);
Ge kA = scalarMul(a, k, sc);
fe_neg(kA.X, kA.X);
fe_neg(kA.T, kA.T);
ge_add(r, sB, kA, sc);
sB.wipe();
kA.wipe();
}
// Internal: General scalar multiplication.
public static Ge scalarMul(Ge p, byte[] scalar, Scratch s) {
Ge res = new Ge();
fe_0(res.X);
@@ -240,6 +268,7 @@ public final class Ed25519 {
return res;
}
// Internal: Point addition.
public static void ge_add(Ge r, Ge p1, Ge p2, Scratch s) {
long[] yMinusX1 = s.stack[0], yPlusX1 = s.stack[1], yMinusX2 = s.stack[2], yPlusX2 = s.stack[3];
long[] A = s.stack[4], B = s.stack[5], C = s.stack[6], D = s.stack[7];
@@ -264,6 +293,7 @@ public final class Ed25519 {
fe_mul(r.T, E, H, s.t19);
}
// Internal: Point doubling.
public static void ge_double(Ge r, Ge p, Scratch s) {
long[] A = s.stack[0], B = s.stack[1], C = s.stack[2], D = s.stack[3];
long[] E = s.stack[4], F = s.stack[5], G = s.stack[6], H = s.stack[7];
@@ -300,8 +330,15 @@ public final class Ed25519 {
}
}
// Internal: Point decompression.
public static Ge decompress(byte[] b, Scratch s) {
if (b.length != 32) return null;
// Ensure y is canonical (y < P)
byte[] y_check = Arrays.copyOf(b, 32);
y_check[31] &= 0x7F;
if (!isLessThan(y_check, P)) return null;
Ge p = new Ge();
fe_frombytes(p.Y, b);
fe_1(p.Z);
@@ -338,6 +375,7 @@ public final class Ed25519 {
return p;
}
// Internal: Point compression.
public static byte[] compress(Ge p, Scratch s) {
fe_invert(s.a, p.Z, s);
fe_mul(s.b, p.X, s.a, s.t19);
@@ -372,6 +410,17 @@ public final class Ed25519 {
for (int i = 0; i < 10; i++) h[i] = -f[i];
}
private static boolean isLessThan(byte[] a, byte[] b) {
for (int i = 31; i >= 0; i--) {
int ai = a[i] & 0xff;
int bi = b[i] & 0xff;
if (ai < bi) return true;
if (ai > bi) return false;
}
return false;
}
// Internal: Field multiplication.
public static void fe_mul(long[] out, long[] f, long[] g, long[] t) {
t[0] = f[0] * g[0];
t[1] = f[0] * g[1] + f[1] * g[0];
@@ -395,6 +444,7 @@ public final class Ed25519 {
fe_reduce(out, t);
}
/// Internal: Field squaring.
public static void fe_sq(long[] out, long[] f, long[] t) {
t[0] = f[0] * f[0];
t[1] = 2 * f[0] * f[1];
@@ -418,6 +468,7 @@ public final class Ed25519 {
fe_reduce(out, t);
}
// Internal: Field reduction.
public static void fe_reduce(long[] h, long[] t) {
t[0] += t[10] * 19;
t[1] += t[11] * 19;
@@ -441,6 +492,7 @@ public final class Ed25519 {
for (int i = 0; i < 10; i++) h[i] = t[i];
}
// Internal: Load field element from bytes.
public static void fe_frombytes(long[] h, byte[] s) {
for (int i = 0; i < 10; i++) h[i] = 0;
int bitIdx = 0;
@@ -455,6 +507,7 @@ public final class Ed25519 {
}
}
// Internal: Contract field element to bytes.
public static byte[] fe_contract(long[] h) {
long[] val = Arrays.copyOf(h, 10);
for (int p = 0; p < 2; p++) {
@@ -588,6 +641,7 @@ public final class Ed25519 {
return s[0] & 1;
}
// Internal: Scalar reduction.
public static void reduceScalar(byte[] s) {
long s0 = 2097151 & load3(s, 0);
long s1 = 2097151 & (load4(s, 2) >> 5);
@@ -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);
@@ -0,0 +1,87 @@
package swiss.qpq.gajumaru.core.encoding;
import java.util.Arrays;
// Gf256 implements finite field arithmetic over GF(256).
// Ported from gf256.erl.
public final class Gf256 {
private final int[] exp;
private final int[] log;
public Gf256(int primeModulus) {
this.exp = new int[512];
this.log = new int[256];
int x = 1;
for (int i = 0; i < 255; i++) {
exp[i] = x;
exp[i + 255] = x;
log[x] = i;
x <<= 1;
if (x > 255) {
x ^= primeModulus;
}
}
}
public int add(int a, int b) {
return a ^ b;
}
public int multiply(int a, int b) {
if (a == 0 || b == 0) return 0;
return exp[log[a] + log[b]];
}
public int inverse(int x) {
if (x == 0) throw new ArithmeticException("Division by zero");
return exp[255 - log[x]];
}
public int exponent(int i) {
return exp[i % 255];
}
public int[] monomialProduct(int[] poly, int coeff, int degree) {
if (coeff == 0) return new int[]{0};
int[] result = new int[poly.length + degree];
for (int i = 0; i < poly.length; i++) {
result[i] = multiply(poly[i], coeff);
}
return result;
}
public int[] polynomialProduct(int[] p1, int[] p2) {
if (isZero(p1) || isZero(p2)) return new int[]{0};
int[] result = new int[p1.length + p2.length - 1];
for (int i = 0; i < p1.length; i++) {
if (p1[i] == 0) continue;
for (int j = 0; j < p2.length; j++) {
result[i + j] ^= multiply(p1[i], p2[j]);
}
}
return result;
}
public int[] divide(int[] a, int[] b) {
if (isZero(b)) throw new ArithmeticException("Division by zero");
int inv = inverse(b[0]);
int[] r = Arrays.copyOf(a, a.length);
int steps = a.length - b.length + 1;
for (int i = 0; i < steps; i++) {
if (r[i] == 0) continue;
int scale = multiply(r[i], inv);
for (int j = 0; j < b.length; j++) {
r[i + j] ^= multiply(b[j], scale);
}
}
// Remainder is the last b.length - 1 elements
return Arrays.copyOfRange(r, a.length - b.length + 1, a.length);
}
private boolean isZero(int[] poly) {
return poly.length == 0 || (poly.length == 1 && poly[0] == 0);
}
}
@@ -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,347 @@
package swiss.qpq.gajumaru.core.encoding;
import java.util.Arrays;
// QR encoder for Byte mode.
// Ported from qr.erl.
public final class QR {
private QR() {}
public enum ECC {
M(0);
final int bits;
ECC(int bits) { this.bits = bits; }
}
private static final int PRIME_MODULUS = 285;
private static final int FORMAT_INFO_POLY = 1335;
private static final int FORMAT_INFO_MASK = 21522;
private static final int VERSION_INFO_POLY = 7973;
private static final int[][] ALIGNMENT_COORDINATES = {
{}, // V0
{}, // V1
{6, 18}, {6, 22}, {6, 26}, {6, 30}, {6, 34}, {6, 22, 38}, {6, 24, 42}, {6, 26, 46},
{6, 28, 50}, {6, 30, 54}, {6, 32, 58}, {6, 34, 62}, {6, 26, 46, 66}, {6, 26, 48, 70},
{6, 26, 50, 74}, {6, 30, 54, 78}, {6, 30, 56, 82}, {6, 30, 58, 86}, {6, 34, 62, 90},
{6, 28, 50, 72, 94}, {6, 26, 50, 74, 98}, {6, 30, 54, 78, 102}, {6, 28, 54, 80, 106},
{6, 32, 58, 84, 110}, {6, 30, 58, 86, 114}, {6, 34, 62, 90, 118}, {6, 26, 50, 74, 98, 122},
{6, 30, 54, 78, 102, 126}, {6, 26, 52, 78, 104, 130}, {6, 30, 56, 82, 108, 134},
{6, 34, 60, 86, 112, 138}, {6, 30, 58, 86, 114, 142}, {6, 34, 62, 90, 118, 146},
{6, 30, 54, 78, 102, 126, 150}, {6, 24, 50, 76, 102, 128, 154}, {6, 28, 54, 80, 106, 132, 158},
{6, 32, 58, 84, 110, 136, 162}, {6, 26, 54, 82, 110, 138, 166}, {6, 30, 58, 86, 114, 142, 170}
};
private record VersionInfo(ECC ecc, int version, int byteCapacity, int[][] blocks, int remainder) {}
private static final VersionInfo[] VERSION_TABLE = {
new VersionInfo(ECC.M, 1, 14, new int[][]{{1, 26, 16}}, 0),
new VersionInfo(ECC.M, 2, 26, new int[][]{{1, 44, 28}}, 7),
new VersionInfo(ECC.M, 3, 42, new int[][]{{1, 70, 44}}, 7),
new VersionInfo(ECC.M, 4, 62, new int[][]{{2, 50, 32}}, 7),
new VersionInfo(ECC.M, 5, 84, new int[][]{{2, 67, 43}}, 7),
new VersionInfo(ECC.M, 6, 106, new int[][]{{4, 43, 27}}, 7),
new VersionInfo(ECC.M, 7, 122, new int[][]{{4, 49, 31}}, 0),
new VersionInfo(ECC.M, 8, 152, new int[][]{{2, 60, 38}, {2, 61, 39}}, 0),
new VersionInfo(ECC.M, 9, 180, new int[][]{{3, 58, 36}, {2, 59, 37}}, 0),
new VersionInfo(ECC.M, 10, 213, new int[][]{{4, 69, 43}, {1, 70, 44}}, 0),
new VersionInfo(ECC.M, 11, 251, new int[][]{{1, 80, 50}, {4, 81, 51}}, 0),
new VersionInfo(ECC.M, 12, 287, new int[][]{{6, 58, 36}, {2, 59, 37}}, 0),
new VersionInfo(ECC.M, 13, 331, new int[][]{{8, 59, 37}, {1, 60, 38}}, 0),
new VersionInfo(ECC.M, 14, 362, new int[][]{{4, 64, 40}, {5, 65, 41}}, 3),
new VersionInfo(ECC.M, 15, 412, new int[][]{{5, 65, 41}, {5, 66, 42}}, 3),
new VersionInfo(ECC.M, 40, 3391, new int[][]{{1, 3391, 2953}}, 0)
};
public static boolean[][] encode(String text) {
return encode(text.getBytes(java.nio.charset.StandardCharsets.UTF_8), ECC.M);
}
public static boolean[][] encode(byte[] data, ECC ecc) {
VersionInfo vi = chooseVersion(data.length, ecc);
if (vi == null) throw new IllegalArgumentException("Data too large for supported versions");
BitBuffer buffer = new BitBuffer();
buffer.add(4, 4); // Byte mode
int cciBits = vi.version <= 9 ? 8 : 16;
buffer.add(data.length, cciBits);
for (byte b : data) buffer.add(b & 0xFF, 8);
buffer.add(0, 4); // Terminator
int totalDataBytesCount = 0;
for (int[] b : vi.blocks) totalDataBytesCount += b[0] * b[2];
if (buffer.bitCount() % 8 != 0) buffer.add(0, 8 - (buffer.bitCount() % 8));
boolean toggle = true;
while (buffer.bitCount() / 8 < totalDataBytesCount) {
buffer.add(toggle ? 0xEC : 0x11, 8);
toggle = !toggle;
}
byte[] codewords = generateCodewords(vi, buffer.toByteArray());
int dim = 17 + vi.version * 4;
boolean[][] matrix = new boolean[dim][dim];
boolean[][] reserved = new boolean[dim][dim];
placeFixedPatterns(vi, matrix, reserved);
placeData(vi, matrix, reserved, codewords, vi.remainder);
applyMask(matrix, reserved, 0);
placeFormatInfo(vi, matrix, 0);
if (vi.version >= 7) placeVersionInfo(vi, matrix);
return matrix;
}
private static VersionInfo chooseVersion(int length, ECC ecc) {
for (VersionInfo vi : VERSION_TABLE) {
if (vi.ecc == ecc && vi.byteCapacity >= length) return vi;
}
return null;
}
private static byte[] generateCodewords(VersionInfo vi, byte[] data) {
Gf256 field = new Gf256(PRIME_MODULUS);
int totalBlocks = 0;
for (int[] b : vi.blocks) totalBlocks += b[0];
byte[][] dataBlocks = new byte[totalBlocks][];
byte[][] eccBlocks = new byte[totalBlocks][];
int blockIdx = 0;
int dataOffset = 0;
for (int[] b : vi.blocks) {
int count = b[0];
int dataBytes = b[2];
int totalBytes = b[1];
int eccBytes = totalBytes - dataBytes;
int[] gen = generator(field, eccBytes);
for (int i = 0; i < count; i++) {
byte[] block = Arrays.copyOfRange(data, dataOffset, dataOffset + dataBytes);
dataBlocks[blockIdx] = block;
eccBlocks[blockIdx] = encodeRS(field, block, gen, eccBytes);
dataOffset += dataBytes;
blockIdx++;
}
}
int totalCodewords = 0;
for (int[] b : vi.blocks) totalCodewords += b[0] * b[1];
byte[] result = new byte[totalCodewords];
int resOffset = 0;
int maxData = 0;
for (byte[] b : dataBlocks) maxData = Math.max(maxData, b.length);
for (int i = 0; i < maxData; i++) {
for (byte[] b : dataBlocks) {
if (i < b.length) result[resOffset++] = b[i];
}
}
int eccLen = eccBlocks[0].length;
for (int i = 0; i < eccLen; i++) {
for (byte[] b : eccBlocks) {
result[resOffset++] = b[i];
}
}
return result;
}
private static int[] generator(Gf256 f, int degree) {
int[] g = {1};
for (int i = 0; i < degree; i++) {
g = f.polynomialProduct(g, new int[]{1, f.exponent(i)});
}
return g;
}
private static byte[] encodeRS(Gf256 f, byte[] data, int[] gen, int eccBytes) {
int[] poly = new int[data.length + eccBytes];
for (int i = 0; i < data.length; i++) poly[i] = data[i] & 0xFF;
int[] rem = f.divide(poly, gen);
byte[] res = new byte[eccBytes];
int offset = eccBytes - rem.length;
for (int i = 0; i < rem.length; i++) res[offset + i] = (byte) rem[i];
return res;
}
private static void placeFixedPatterns(VersionInfo vi, boolean[][] matrix, boolean[][] reserved) {
int dim = matrix.length;
placeFinder(matrix, reserved, 0, 0);
placeFinder(matrix, reserved, dim - 7, 0);
placeFinder(matrix, reserved, 0, dim - 7);
// Separators around finders
for (int i = 0; i < 8; i++) {
reserved[7][i] = reserved[i][7] = true;
reserved[7][dim - 1 - i] = reserved[i][dim - 8] = true;
reserved[dim - 8][i] = reserved[dim - 1 - i][7] = true;
}
// Timing patterns
for (int i = 8; i < dim - 8; i++) {
matrix[6][i] = matrix[i][6] = (i % 2 == 0);
reserved[6][i] = reserved[i][6] = true;
}
// Alignment patterns
int[] coords = ALIGNMENT_COORDINATES[vi.version];
for (int y : coords) {
for (int x : coords) {
if (isFinderArea(x, y, dim)) continue;
placeAlignment(matrix, reserved, x, y);
}
}
// Dark module
matrix[4 * vi.version + 9][8] = true;
reserved[4 * vi.version + 9][8] = true;
// Reserved areas for format and version info
for (int i = 0; i < 9; i++) reserved[i][8] = reserved[8][i] = true;
for (int i = 0; i < 8; i++) reserved[dim - 1 - i][8] = reserved[8][dim - 1 - i] = true;
if (vi.version >= 7) {
for (int i = 0; i < 6; i++) {
for (int j = 0; j < 3; j++) {
reserved[dim - 11 + j][i] = reserved[i][dim - 11 + j] = true;
}
}
}
}
private static boolean isFinderArea(int x, int y, int dim) {
return (x < 9 && y < 9) || (x > dim - 10 && y < 9) || (x < 9 && y > dim - 10);
}
private static void placeFinder(boolean[][] m, boolean[][] r, int x, int y) {
for (int j = 0; j < 7; j++) {
for (int i = 0; i < 7; i++) {
int px = x + i, py = y + j;
r[py][px] = true;
boolean black = (i == 0 || i == 6 || j == 0 || j == 6 || (i >= 2 && i <= 4 && j >= 2 && j <= 4));
m[py][px] = black;
}
}
}
private static void placeAlignment(boolean[][] m, boolean[][] r, int x, int y) {
for (int j = -2; j <= 2; j++) {
for (int i = -2; i <= 2; i++) {
r[y + j][x + i] = true;
m[y + j][x + i] = (Math.abs(i) == 2 || Math.abs(j) == 2 || (i == 0 && j == 0));
}
}
}
private static void placeData(VersionInfo vi, boolean[][] m, boolean[][] r, byte[] data, int remainderBits) {
int dim = m.length;
int bitIdx = 0;
int totalBits = data.length * 8 + remainderBits;
int x = dim - 1, y = dim - 1, dir = -1;
while (x >= 0) {
if (x == 6) x--;
for (int i = 0; i < 2; i++) {
int cx = x - i;
if (!r[y][cx]) {
if (bitIdx < totalBits) {
int byteIdx = bitIdx / 8;
int shift = 7 - (bitIdx % 8);
boolean bit = byteIdx < data.length && ((data[byteIdx] >> shift) & 1) != 0;
m[y][cx] = bit;
bitIdx++;
}
}
}
y += dir;
if (y < 0 || y >= dim) {
y -= dir; x -= 2; dir = -dir;
}
}
}
private static void applyMask(boolean[][] m, boolean[][] r, int pattern) {
for (int y = 0; y < m.length; y++) {
for (int x = 0; x < m.length; x++) {
if (!r[y][x] && isMasked(x, y, pattern)) m[y][x] = !m[y][x];
}
}
}
private static boolean isMasked(int x, int y, int pattern) {
return switch (pattern) {
case 0 -> (x + y) % 2 == 0;
case 1 -> y % 2 == 0;
case 2 -> x % 3 == 0;
case 3 -> (x + y) % 3 == 0;
case 4 -> (y / 2 + x / 3) % 2 == 0;
case 5 -> ((x * y) % 2) + ((x * y) % 3) == 0;
case 6 -> (((x * y) % 2) + ((x * y) % 3)) % 2 == 0;
case 7 -> (((x + y) % 2) + ((x * y) % 3)) % 2 == 0;
default -> false;
};
}
private static final int[][] FORMAT_INFO_COORDS_TL = {
{8, 0}, {8, 1}, {8, 2}, {8, 3}, {8, 4}, {8, 5}, {8, 7}, {8, 8}, {7, 8}, {5, 8}, {4, 8}, {3, 8}, {2, 8}, {1, 8}, {0, 8}
};
private static void placeFormatInfo(VersionInfo vi, boolean[][] m, int mask) {
int info = (vi.ecc.bits << 3) | mask;
int bch = bch(info, FORMAT_INFO_POLY, 10);
int full = ((info << 10) | bch) ^ FORMAT_INFO_MASK;
int dim = m.length;
for (int i = 0; i < 15; i++) {
boolean bit = ((full >> i) & 1) != 0;
// Top-left strip
m[FORMAT_INFO_COORDS_TL[i][0]][FORMAT_INFO_COORDS_TL[i][1]] = bit;
// Bottom-left / Top-right strips
if (i < 8) m[8][dim - 1 - i] = bit;
else m[dim - 15 + i][8] = bit;
}
}
private static void placeVersionInfo(VersionInfo vi, boolean[][] m) {
int bch = bch(vi.version, VERSION_INFO_POLY, 12);
int full = (vi.version << 12) | bch;
int dim = m.length;
for (int i = 0; i < 18; i++) {
boolean bit = ((full >> i) & 1) != 0;
m[dim - 11 + i % 3][i / 3] = bit;
m[i / 3][dim - 11 + i % 3] = bit;
}
}
private static int bch(int data, int poly, int degree) {
int d = data << degree;
int msb = 31 - Integer.numberOfLeadingZeros(poly);
for (int i = 31 - Integer.numberOfLeadingZeros(d); i >= degree; i--) {
if (((d >> i) & 1) != 0) d ^= (poly << (i - msb));
}
return d;
}
private static class BitBuffer {
private byte[] data = new byte[32];
private int bitIdx = 0;
public void add(int value, int bits) {
ensureCapacity((bitIdx + bits + 7) / 8);
for (int i = bits - 1; i >= 0; i--) {
if (((value >> i) & 1) != 0) data[bitIdx / 8] |= (1 << (7 - (bitIdx % 8)));
bitIdx++;
}
}
public int bitCount() { return bitIdx; }
public byte[] toByteArray() { return Arrays.copyOf(data, (bitIdx + 7) / 8); }
private void ensureCapacity(int bytes) {
if (bytes > data.length) data = Arrays.copyOf(data, Math.max(data.length * 2, bytes));
}
}
}
@@ -0,0 +1,324 @@
package swiss.qpq.gajumaru.core.encoding;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
// ZJ: Tiny JSON parser, ported from zj.erl.
//
// NOTE:
// Does not *quite* live up to ZJ in every respect, but is sufficient for GRIDS.
// Uses standard Java exceptions for errors.
public final class ZJ {
public static class ParseException extends RuntimeException {
public final int position;
public ParseException(String message, int position) {
super(message + " at position " + position);
this.position = position;
}
}
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 {
throw new IllegalArgumentException("Unsupported JSON value type: " + value.getClass().getName());
}
}
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;
Object result = value();
seek();
if (pos != json.length()) {
throw new ParseException("Unexpected trailing data", pos);
}
return result;
}
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 ParseException("Unexpected character: " + c, pos);
}
};
}
private Map<String, Object> object() {
pos++; // skip '{'
Map<String, Object> map = new LinkedHashMap<>();
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 ParseException("Expected ':'", 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 ParseException("Expected ',' or '}'", 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 ParseException("Expected ',' or ']'", pos);
}
pos++;
}
return list;
}
private String string() {
if (json.charAt(pos) != '"') throw new ParseException("Expected '\"'", pos);
pos++;
StringBuilder sb = new StringBuilder();
while (pos < json.length()) {
char c = json.charAt(pos++);
if (c == '"') return sb.toString();
if (c < 0x20) throw new ParseException("Unescaped control character", pos - 1);
if (c == '\\') {
if (pos >= json.length()) throw new ParseException("Unterminated escape", 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 ParseException("Invalid unicode escape", pos);
String hex = json.substring(pos, pos + 4);
try {
sb.append((char) Integer.parseInt(hex, 16));
} catch (NumberFormatException e) {
throw new ParseException("Invalid hex in unicode escape", pos);
}
pos += 4;
}
default -> throw new ParseException("Unknown escape: " + esc, pos - 1);
}
} else {
sb.append(c);
}
}
throw new ParseException("Unterminated string", pos);
}
private Boolean bool(boolean expected) {
String s = expected ? "true" : "false";
if (json.startsWith(s, pos)) {
pos += s.length();
return expected;
}
throw new ParseException("Expected " + s, pos);
}
private Object nil() {
if (json.startsWith("null", pos)) {
pos += 4;
return null;
}
throw new ParseException("Expected null", pos);
}
private Number number() {
int start = pos;
if (pos < json.length() && json.charAt(pos) == '-') pos++;
if (pos >= json.length()) throw new ParseException("Unexpected end of number", pos);
char c = json.charAt(pos);
if (c == '0') {
pos++;
} else if (c >= '1' && c <= '9') {
pos++;
while (pos < json.length() && isDigit(json.charAt(pos))) pos++;
} else {
throw new ParseException("Expected digit", pos);
}
boolean isDecimal = false;
if (pos < json.length() && json.charAt(pos) == '.') {
isDecimal = true;
pos++;
if (pos >= json.length() || !isDigit(json.charAt(pos))) {
throw new ParseException("Expected digit after '.'", pos);
}
while (pos < json.length() && isDigit(json.charAt(pos))) pos++;
}
if (pos < json.length() && (json.charAt(pos) == 'e' || json.charAt(pos) == 'E')) {
isDecimal = true;
pos++;
if (pos < json.length() && (json.charAt(pos) == '+' || json.charAt(pos) == '-')) pos++;
if (pos >= json.length() || !isDigit(json.charAt(pos))) {
throw new ParseException("Expected digit in exponent", pos);
}
while (pos < json.length() && isDigit(json.charAt(pos))) pos++;
}
String s = json.substring(start, pos);
if (isDecimal) return new BigDecimal(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++;
}
// This is a nitpick but it turns out that Character.isDigit() accepts a bunch of possible
// unicode digits as well as ASCII '0' through '9'. That's normally good, but not for
// parsing JSON! (Shoutout to P.H. -- "only ever permit ASCII" wins this one time.)
private static boolean isDigit(char c) {
return c >= '0' && c <= '9';
}
}
}
@@ -39,6 +39,14 @@ public final class GajuFormat {
public enum Unit { GAJU, PUCK }
public record FormatSpec(Type type, Unit unit, char separator, int span) {}
public static FormatSpec standardSpec(Type type, Unit unit) {
return switch (type) {
case US -> new FormatSpec(type, unit, ',', 3);
case JP -> new FormatSpec(type, unit, ' ', 4);
case METRIC, LEGACY -> new FormatSpec(type, unit, ' ', 3);
};
}
private GajuFormat() {}
private static final String GAJU_MARK = "";
@@ -0,0 +1,137 @@
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.LinkedHashMap;
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 baseUrl = httpScheme + "://" + host + (uri.getPort() != -1 ? ":" + uri.getPort() : "") + subPath;
String httpUrl = query != null ? baseUrl + "?" + query : baseUrl;
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 LinkedHashMap<>();
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 LinkedHashMap<>();
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.
return p.getBytes(StandardCharsets.UTF_8);
}
}
+74
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) {
@@ -40,11 +44,15 @@ public class Testinator {
case "blake2b" -> { System.out.print(blake2b(args[1])); }
case "ed25519" -> { System.out.print(ed25519(args[1])); }
case "ed25519_verify" -> { System.out.print(ed25519_verify(args[1])); }
case "ed25519_malleability" -> { System.out.print(ed25519_malleability()); }
case "api_encode" -> { System.out.print(api_encode(args[1])); }
case "id_serialization" -> { System.out.print(id_serialization(args[1])); }
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])); }
@@ -384,6 +392,20 @@ public class Testinator {
return CryptoUtils.binToHex(pub) + "|" + CryptoUtils.binToHex(sig) + "|" + verify;
}
private static String ed25519_malleability() {
// L in little-endian
byte[] L_bytes = {
(byte) 0xed, (byte) 0xd3, (byte) 0xf5, (byte) 0x5c, (byte) 0x1a, (byte) 0x63, (byte) 0x12, (byte) 0x58,
(byte) 0xd6, (byte) 0x9c, (byte) 0xf7, (byte) 0xa2, (byte) 0xde, (byte) 0xf9, (byte) 0xde, (byte) 0x14,
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x10
};
byte[] sig = new byte[64];
System.arraycopy(L_bytes, 0, sig, 32, 32); // S = L
boolean result = Ed25519.verify(new byte[32], new byte[0], sig);
return Boolean.toString(result);
}
private static String api_encode(String workingPath) throws IOException {
Path testPath = Path.of(workingPath, "api_encode.test");
Path resPath = Path.of(workingPath, "api_encode.java.txt");
@@ -493,4 +515,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();
}
}