diff --git a/app/src/main/java/swiss/qpq/gajumobile/data/models/GridsModels.kt b/app/src/main/java/swiss/qpq/gajumobile/data/models/GridsModels.kt index 302baa8..ea4c272 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/data/models/GridsModels.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/data/models/GridsModels.kt @@ -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.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 ) } diff --git a/app/src/main/java/swiss/qpq/gajumobile/security/AccountAirlock.java b/app/src/main/java/swiss/qpq/gajumobile/security/AccountAirlock.java index 6eae6d7..5c22aba 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/security/AccountAirlock.java +++ b/app/src/main/java/swiss/qpq/gajumobile/security/AccountAirlock.java @@ -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); } } diff --git a/app/src/main/java/swiss/qpq/gajumobile/security/EncryptionService.kt b/app/src/main/java/swiss/qpq/gajumobile/security/EncryptionService.kt index ea7ea3c..d947028 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/security/EncryptionService.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/security/EncryptionService.kt @@ -101,10 +101,31 @@ object EncryptionService { fun encryptRaw(key: SecretKey, data: ByteArray): Pair { 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) diff --git a/app/src/main/java/swiss/qpq/gajumobile/security/GajuNative.kt b/app/src/main/java/swiss/qpq/gajumobile/security/GajuNative.kt index 0cfcdba..5467215 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/security/GajuNative.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/security/GajuNative.kt @@ -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() } diff --git a/app/src/main/java/swiss/qpq/gajumobile/security/KeyManager.kt b/app/src/main/java/swiss/qpq/gajumobile/security/KeyManager.kt index 8c3e734..801cf26 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/security/KeyManager.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/security/KeyManager.kt @@ -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+) diff --git a/app/src/main/java/swiss/qpq/gajumobile/security/NativeSigningProvider.kt b/app/src/main/java/swiss/qpq/gajumobile/security/NativeSigningProvider.kt new file mode 100644 index 0000000..92f80b8 --- /dev/null +++ b/app/src/main/java/swiss/qpq/gajumobile/security/NativeSigningProvider.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 QPQ AG . 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) + } +} diff --git a/app/src/test/java/swiss/qpq/gajumobile/security/AccountAirlockTest.kt b/app/src/test/java/swiss/qpq/gajumobile/security/AccountAirlockTest.kt index f2f28e0..1fa7fda 100644 --- a/app/src/test/java/swiss/qpq/gajumobile/security/AccountAirlockTest.kt +++ b/app/src/test/java/swiss/qpq/gajumobile/security/AccountAirlockTest.kt @@ -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) } } diff --git a/gm-java b/gm-java index 64c5922..7cd299d 160000 --- a/gm-java +++ b/gm-java @@ -1 +1 @@ -Subproject commit 64c592285a6b21f5b3dabc8cee24f3be9cde3bb3 +Subproject commit 7cd299d250963ccefad8325a25bd29eb4c86c63e