WIP: Signing works
This commit is contained in:
@@ -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
Reference in New Issue
Block a user