WIP: Signing works

This commit is contained in:
2026-08-26 18:19:37 +09:00
parent 223e95ace6
commit 37dd0c3858
8 changed files with 96 additions and 32 deletions
@@ -32,7 +32,7 @@ data class GridsSignRequest(
val payload: String,
val publicId: String?,
val networkId: String?,
val grids: String?,
val grids: Int,
val chain: String?
)
@@ -40,12 +40,17 @@ data class GridsSignRequest(
* Safely maps a raw Map (typically from ZJ.decode) to a typed GridsSignRequest.
*/
fun Map<String, Any?>.toGridsSignRequest(): GridsSignRequest {
val gridsVal = when (val g = this["grids"]) {
is Number -> g.toInt()
is String -> g.toIntOrNull() ?: 1
else -> 1
}
return GridsSignRequest(
type = GridsSignType.fromString(this["type"] as? String),
payload = this["payload"] as? String ?: "",
publicId = this["public_id"] as? String,
networkId = this["network_id"] as? String,
grids = this["grids"] as? String,
grids = gridsVal,
chain = this["chain"] as? String
)
}
@@ -17,7 +17,17 @@ import swiss.qpq.gajumobile.data.models.Account;
*/
public final class AccountAirlock {
private AccountAirlock() {}
private static void checkNative() {
if (GajuNative.INSTANCE.getLoadError() != null) {
throw new RuntimeException("Native library (gaju-native) not available: " + GajuNative.INSTANCE.getLoadError());
}
}
private static void safeMemzero(byte[] array) {
if (array != null && GajuNative.INSTANCE.getLoadError() == null) {
GajuNative.memzero(array);
}
}
/**
* Returns the 4096-word Gajumaru dictionary.
@@ -57,9 +67,7 @@ public final class AccountAirlock {
seed = Mnemonic.decode(phrase);
// 2. Generate Public Key using Libsodium
if (GajuNative.INSTANCE.getLoadError() != null) {
throw new RuntimeException("Native library failed to load: " + GajuNative.INSTANCE.getLoadError());
}
checkNative();
byte[] keypair = GajuNative.cryptoSignSeedKeypair(seed);
if (keypair == null) {
throw new RuntimeException("Native keypair generation failed (returned null).");
@@ -67,7 +75,7 @@ public final class AccountAirlock {
byte[] publicKey = new byte[32];
System.arraycopy(keypair, 0, publicKey, 0, 32);
// keypair contains sk too, but we only need pk here for the Account model
GajuNative.memzero(keypair);
safeMemzero(keypair);
// 3. Generate random Data Encryption Key (DEK)
// 4. Encrypt Private Key (seed) with DEK
@@ -78,7 +86,7 @@ public final class AccountAirlock {
return new Account(id, label, publicKey, envelope, null);
} finally {
if (seed != null) GajuNative.memzero(seed);
if (seed != null) safeMemzero(seed);
}
}
@@ -92,6 +100,7 @@ public final class AccountAirlock {
) throws Exception {
byte[] seed = derivePrivateKey(envelope, masterKey);
try {
checkNative();
byte[] keypair = GajuNative.cryptoSignSeedKeypair(seed);
if (keypair == null) throw new RuntimeException("Native keypair generation failed.");
byte[] secretKey = new byte[64];
@@ -99,11 +108,11 @@ public final class AccountAirlock {
byte[] sig = TransactionService.signMessage(message, secretKey);
GajuNative.memzero(keypair);
GajuNative.memzero(secretKey);
safeMemzero(keypair);
safeMemzero(secretKey);
return sig;
} finally {
GajuNative.memzero(seed);
safeMemzero(seed);
}
}
@@ -117,6 +126,7 @@ public final class AccountAirlock {
) throws Exception {
byte[] seed = derivePrivateKey(envelope, masterKey);
try {
checkNative();
byte[] keypair = GajuNative.cryptoSignSeedKeypair(seed);
if (keypair == null) throw new RuntimeException("Native keypair generation failed.");
byte[] secretKey = new byte[64];
@@ -124,11 +134,11 @@ public final class AccountAirlock {
byte[] sig = TransactionService.signBinary(data, secretKey);
GajuNative.memzero(keypair);
GajuNative.memzero(secretKey);
safeMemzero(keypair);
safeMemzero(secretKey);
return sig;
} finally {
GajuNative.memzero(seed);
safeMemzero(seed);
}
}
@@ -143,6 +153,7 @@ public final class AccountAirlock {
) throws Exception {
byte[] seed = derivePrivateKey(envelope, masterKey);
try {
checkNative();
byte[] keypair = GajuNative.cryptoSignSeedKeypair(seed);
if (keypair == null) throw new RuntimeException("Native keypair generation failed.");
byte[] secretKey = new byte[64];
@@ -150,11 +161,11 @@ public final class AccountAirlock {
String signedTx = TransactionService.signTx(txData, networkId, secretKey);
GajuNative.memzero(keypair);
GajuNative.memzero(secretKey);
safeMemzero(keypair);
safeMemzero(secretKey);
return signedTx;
} finally {
GajuNative.memzero(seed);
safeMemzero(seed);
}
}
@@ -180,7 +191,7 @@ public final class AccountAirlock {
try {
return Mnemonic.encode(plaintextPrivateKey);
} finally {
if (plaintextPrivateKey != null) GajuNative.memzero(plaintextPrivateKey);
if (plaintextPrivateKey != null) safeMemzero(plaintextPrivateKey);
}
}
@@ -101,10 +101,31 @@ object EncryptionService {
fun encryptRaw(key: SecretKey, data: ByteArray): Pair<ByteArray, ByteArray> {
try {
val cipher = Cipher.getInstance(AES_GCM)
val iv = ByteArray(IV_LENGTH).apply { SecureRandom().nextBytes(this) }
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(GCM_TAG_LENGTH, iv))
// Try initializing without a specific IV to allow the provider (e.g. AndroidKeyStore)
// to generate a high-entropy randomized IV.
var iv: ByteArray?
try {
// We still want to specify the tag length if possible.
// However, many providers will default to 128 bits.
cipher.init(Cipher.ENCRYPT_MODE, key)
iv = cipher.iv
} catch (e: Exception) {
// Fallback for software providers that require explicit IV or GCM specs during init
val manualIv = ByteArray(IV_LENGTH).apply { SecureRandom().nextBytes(this) }
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(GCM_TAG_LENGTH, manualIv))
iv = manualIv
}
if (iv == null || iv.isEmpty()) {
// Some providers might not generate it until doFinal or at all if misconfigured
val manualIv = ByteArray(IV_LENGTH).apply { SecureRandom().nextBytes(this) }
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(GCM_TAG_LENGTH, manualIv))
iv = manualIv
}
val ciphertext = cipher.doFinal(data)
return ciphertext to iv
return ciphertext to iv!!
} catch (e: Exception) {
throw Exception("encryptRaw failed: ${e.message}", e)
}
@@ -113,6 +134,9 @@ object EncryptionService {
@JvmStatic
fun decryptRaw(key: SecretKey, iv: ByteArray, encryptedData: ByteArray): ByteArray {
try {
if (iv.size != IV_LENGTH && iv.size != 16) {
android.util.Log.w("EncryptionService", "Unexpected IV length: ${iv.size}. Expected 12 or 16.")
}
val cipher = Cipher.getInstance(AES_GCM)
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_LENGTH, iv))
return cipher.doFinal(encryptedData)
@@ -7,6 +7,8 @@ object GajuNative {
init {
try {
System.loadLibrary("gaju-native")
// Register as the default signing provider for TransactionService
swiss.qpq.gajumaru.core.tools.TransactionService.setProvider(NativeSigningProvider())
} catch (e: Throwable) {
loadError = e.message ?: e.toString()
}
@@ -17,13 +17,7 @@ object KeyManager {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
return if (keyStore.containsAlias(MASTER_KEY_ALIAS)) {
try {
keyStore.getKey(MASTER_KEY_ALIAS, null) as SecretKey
} catch (e: Exception) {
android.util.Log.w("KeyManager", "Existing key inaccessible, regenerating", e)
keyStore.deleteEntry(MASTER_KEY_ALIAS)
generateMasterKey(context)
}
keyStore.getKey(MASTER_KEY_ALIAS, null) as SecretKey
} else {
generateMasterKey(context)
}
@@ -42,7 +36,7 @@ object KeyManager {
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setRandomizedEncryptionRequired(false) // Allow manual IVs for consistency
.setRandomizedEncryptionRequired(true)
.setUserAuthenticationRequired(false)
// Target StrongBox if available (Android 9+)
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 QPQ AG <info@qpq.swiss>. All rights reserved.
*/
package swiss.qpq.gajumobile.security
import swiss.qpq.gajumaru.core.tools.SigningProvider
/**
* NativeSigningProvider implements SigningProvider by delegating to libsodium
* via GajuNative.
*/
class NativeSigningProvider : SigningProvider {
override fun cryptoSignSeedKeypair(seed: ByteArray): ByteArray? {
return GajuNative.cryptoSignSeedKeypair(seed)
}
override fun cryptoSignDetached(message: ByteArray, secretKey: ByteArray): ByteArray? {
return GajuNative.cryptoSignDetached(message, secretKey)
}
}
@@ -3,14 +3,19 @@ package swiss.qpq.gajumobile.security
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assume
import org.junit.Test
import swiss.qpq.gajumaru.core.crypto.Ed25519
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import java.security.SecureRandom
import swiss.qpq.gajumaru.core.tools.TransactionService
class AccountAirlockTest {
private fun assumeNativeLoaded() {
Assume.assumeTrue("Native library failed to load: ${GajuNative.loadError}", GajuNative.loadError == null)
}
@Test
fun testShortMnemonicRoundtrip() {
// Create a "short" seed (first 4 bytes are zero)
@@ -46,6 +51,7 @@ class AccountAirlockTest {
@Test
fun testCreateAccountFlow() {
assumeNativeLoaded()
val keyGen = KeyGenerator.getInstance("AES")
keyGen.init(256)
val masterKey: SecretKey = keyGen.generateKey()
@@ -63,7 +69,7 @@ class AccountAirlockTest {
val signature = AccountAirlock.signBinary(account.privateKeyEnvelope, masterKey, message)
assertNotNull(signature)
assertTrue(Ed25519.verify(account.publicKey, message, signature))
assertTrue(TransactionService.verifyBinary(message, signature, account.publicKey))
AccountAirlock.wipePhrase(phrase)
}
}
+1 -1
Submodule gm-java updated: 64c592285a...7cd299d250