diff --git a/app/src/main/java/swiss/qpq/gajumobile/MainActivity.kt b/app/src/main/java/swiss/qpq/gajumobile/MainActivity.kt index d7c4939..257c4b8 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/MainActivity.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/MainActivity.kt @@ -7,6 +7,7 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -21,6 +22,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ProcessLifecycleOwner import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import swiss.qpq.gajumobile.data.AppStateManager @@ -39,6 +41,7 @@ import swiss.qpq.gajumobile.ui.screens.DashboardScreen import swiss.qpq.gajumobile.ui.screens.DeleteConfirmationScreen import swiss.qpq.gajumobile.ui.screens.EnvironmentInfoScreen import swiss.qpq.gajumobile.ui.screens.ExplorerScreen +import swiss.qpq.gajumobile.ui.screens.FetchingScreen import swiss.qpq.gajumobile.ui.screens.LockScreen import swiss.qpq.gajumobile.ui.screens.ManageAccountScreen import swiss.qpq.gajumobile.ui.screens.ManageWalletsScreen @@ -50,10 +53,12 @@ import swiss.qpq.gajumobile.ui.screens.SendFormScreen import swiss.qpq.gajumobile.ui.screens.SendReviewScreen import swiss.qpq.gajumobile.ui.screens.SettingsScreen import swiss.qpq.gajumobile.ui.screens.SetupChoiceScreen +import swiss.qpq.gajumobile.ui.screens.SignRequestScreen import swiss.qpq.gajumobile.ui.screens.ViewMnemonicScreen import swiss.qpq.gajumobile.ui.theme.MyApplicationTheme import swiss.qpq.gajumaru.core.formatting.GajuFormat import swiss.qpq.gajumaru.core.tools.Grids +import swiss.qpq.gajumaru.core.tools.Http import swiss.qpq.gajumaru.core.tools.NodeClient import swiss.qpq.gajumaru.core.tools.TransactionService import java.math.BigInteger @@ -118,7 +123,9 @@ enum class Screen { VERIFY_MNEMONIC, ENVIRONMENT_INFO, SEND_FORM, - SEND_REVIEW + SEND_REVIEW, + SIGN_REQUEST, + FETCHING_SIGN_REQUEST } @Composable @@ -132,7 +139,8 @@ fun GajuRouter( val scope = rememberCoroutineScope() val initialScreen = remember { - if (walletRepo.listWallets().isEmpty()) Screen.CREATE_WALLET else Screen.DASHBOARD + val wallets = walletRepo.listWallets().mapNotNull { walletRepo.loadWallet(it) } + if (wallets.isEmpty()) Screen.CREATE_WALLET else Screen.DASHBOARD } val screenStack = remember { mutableStateListOf(initialScreen) } @@ -184,6 +192,10 @@ fun GajuRouter( var pendingGasPrice by rememberSaveable { mutableStateOf("") } var pendingTTL by rememberSaveable { mutableStateOf("") } + var pendingSignRequest by remember { mutableStateOf?>(null) } + var pendingSignUrl by rememberSaveable { mutableStateOf("") } + var activeFetchJob by remember { mutableStateOf(null) } + when (currentScreen) { Screen.CREATE_WALLET -> { CreateWalletScreen( @@ -208,8 +220,10 @@ fun GajuRouter( pendingAccount = account setupRecovered = false navigateTo(Screen.ACCOUNT_NAMING) - } catch (e: Exception) { - onError("Failed to generate account: ${e.message}") + } catch (e: Throwable) { + val msg = e.message ?: e.toString() + onError("Failed to generate account: $msg") + android.util.Log.e("GajuRouter", "Account generation failed", e) } }, onRecover = { @@ -326,6 +340,7 @@ fun GajuRouter( val wallets = remember(appState.walletsVersion) { walletRepo.listWallets().mapNotNull { walletRepo.loadWallet(it) } } + DashboardScreen( wallets = wallets, selectedWalletIndex = activeWalletIndex, @@ -335,6 +350,7 @@ fun GajuRouter( balanceFormat = GajuFormat.Type.valueOf(appState.balanceFormat), balanceUnit = GajuFormat.Unit.valueOf(appState.balanceUnit), onSend = { navigateTo(Screen.QR_SCANNER) }, + onGrids = { navigateTo(Screen.QR_SCANNER) }, onReceive = { navigateTo(Screen.QR_RECEIVE) }, onAddAccount = { walletId -> selectedWalletId = walletId @@ -361,6 +377,13 @@ fun GajuRouter( "transactions" -> Screen.EXPLORER "environment_info" -> Screen.ENVIRONMENT_INFO "manage_wallets" -> Screen.MANAGE_WALLETS + "CREATE_WALLET" -> Screen.CREATE_WALLET + "WIPE_DATA" -> { + walletRepo.listWallets().forEach { walletRepo.deleteWallet(it) } + appStateManager.notifyWalletsChanged() + navigateTo(Screen.CREATE_WALLET) + null + } else -> null } screen?.let { navigateTo(it) } @@ -538,16 +561,23 @@ fun GajuRouter( appStateManager.setBalanceFormat(type.name) }, onNavigate = { dest -> - val screen = when (dest) { - "dashboard" -> Screen.DASHBOARD - "transactions" -> Screen.EXPLORER - "environment_info" -> Screen.ENVIRONMENT_INFO - "manage_wallets" -> Screen.MANAGE_WALLETS - else -> null - } - if (screen != null) { + if (dest == "WIPE_DATA") { + walletRepo.listWallets().forEach { walletRepo.deleteWallet(it) } + appStateManager.notifyWalletsChanged() screenStack.clear() - screenStack.add(screen) + screenStack.add(Screen.CREATE_WALLET) + } else { + val screen = when (dest) { + "dashboard" -> Screen.DASHBOARD + "transactions" -> Screen.EXPLORER + "environment_info" -> Screen.ENVIRONMENT_INFO + "manage_wallets" -> Screen.MANAGE_WALLETS + else -> null + } + if (screen != null) { + screenStack.clear() + screenStack.add(screen) + } } } ) @@ -555,12 +585,51 @@ fun GajuRouter( Screen.ENVIRONMENT_INFO -> { EnvironmentInfoScreen(onBack = { navigateBack() }) } + Screen.FETCHING_SIGN_REQUEST -> { + FetchingScreen( + url = pendingSignUrl, + onCancel = { + activeFetchJob?.cancel() + navigateBack() + } + ) + } Screen.QR_SCANNER -> QRScannerScreen( onScanResult = { url -> try { val result = Grids.parse(url) - pendingScanResult = result - navigateTo(Screen.SEND_FORM) + if (result.verb == Grids.Verb.SIGN) { + pendingSignUrl = result.url + navigateTo(Screen.FETCHING_SIGN_REQUEST) + + activeFetchJob = scope.launch { + try { + android.util.Log.i("GajuRouter", "Fetching GRIDS request from: ${result.url}") + val request = withContext(Dispatchers.IO) { + Http.get(result.url) as? Map + } + if (request != null) { + pendingSignRequest = request + // Remove fetching screen and add request screen + screenStack.removeAt(screenStack.size - 1) + navigateTo(Screen.SIGN_REQUEST) + } else { + onError("Failed to fetch signature request.") + navigateBack() + } + } catch (e: Exception) { + if (activeFetchJob?.isCancelled == false) { + onError("Fetch failed: ${e.message}") + navigateBack() + } + } finally { + activeFetchJob = null + } + } + } else { + pendingScanResult = result + navigateTo(Screen.SEND_FORM) + } } catch (e: Exception) { onError("Invalid GRIDS URL: ${e.message}") } @@ -637,9 +706,9 @@ fun GajuRouter( client.nextNonce(activeAccount.gajuId) to client.topHeight() } - // 2. Derive seckey from airlock + // 2. Derive seed from airlock val masterKey = KeyManager.getMasterKey(context) - val seckey = AccountAirlock.derivePrivateKey(activeAccount.privateKeyEnvelope, masterKey) + val seed = AccountAirlock.derivePrivateKey(activeAccount.privateKeyEnvelope, masterKey) // 3. Build and sign tx val amountPucks = BigInteger(GajuFormat.read(pendingAmountStr)) @@ -653,11 +722,11 @@ fun GajuRouter( height + pendingTTL.toLong(), nonce, pendingPayload, - seckey + seed ) - // 4. Wipe seckey - GajuNative.memzero(seckey) + // 4. Wipe seed + GajuNative.memzero(seed) // 5. Post tx val result = withContext(Dispatchers.IO) { @@ -671,14 +740,96 @@ fun GajuRouter( // Success! Go back to dashboard screenStack.clear() screenStack.add(Screen.DASHBOARD) - } catch (e: Exception) { - onError("Send failed: ${e.message}") + } catch (e: Throwable) { + val msg = e.message ?: e.toString() + onError("Send failed: $msg") + android.util.Log.e("GajuRouter", "Send failed", e) } } }, onBack = { navigateBack() } ) } + Screen.SIGN_REQUEST -> { + val wallets = walletRepo.listWallets().mapNotNull { walletRepo.loadWallet(it) } + val activeWallet = wallets.getOrNull(activeWalletIndex) + val activeAccount = activeWallet?.accounts?.getOrNull(activeAccountIndex) + + val request = pendingSignRequest ?: return + val type = request["type"] as? String ?: "" + val payload = request["payload"] as? String ?: "" + val publicId = request["public_id"] as? String + + // If request specifies an ID, try to find that account + val signAccount = if (publicId != null && publicId != "false") { + wallets.flatMap { it.accounts }.find { it.gajuId == publicId } + } else { + activeAccount + } + + if (signAccount == null) { + onError("No matching account found for signature.") + navigateBack() + return + } + + SignRequestScreen( + type = type, + originUrl = pendingSignUrl, + accountLabel = signAccount.label, + accountId = signAccount.gajuId, + payload = payload, + onSign = { + scope.launch { + try { + val masterKey = KeyManager.getMasterKey(context) + val signedRequest = mutableMapOf() + + // Essential keys present in all GRIDS responses + signedRequest["grids"] = request["grids"] + signedRequest["chain"] = request["chain"] + signedRequest["network_id"] = request["network_id"] + signedRequest["type"] = type + signedRequest["public_id"] = signAccount.gajuId // Must be the actual ID string, never 'false' + + when (type) { + "message" -> { + val sig = AccountAirlock.signMessage(signAccount.privateKeyEnvelope, masterKey, payload) + signedRequest["payload"] = payload + signedRequest["signature"] = android.util.Base64.encodeToString(sig, android.util.Base64.NO_WRAP) + } + "binary" -> { + val binary = android.util.Base64.decode(payload, android.util.Base64.DEFAULT) + val sig = AccountAirlock.signBinary(signAccount.privateKeyEnvelope, masterKey, binary) + signedRequest["payload"] = payload + signedRequest["signature"] = android.util.Base64.encodeToString(sig, android.util.Base64.NO_WRAP) + } + "tx" -> { + val nid = request["network_id"] as? String ?: activeWallet?.networkId ?: "groot.mainnet" + val txBinary = android.util.Base64.decode(payload, android.util.Base64.DEFAULT) + signedRequest["payload"] = AccountAirlock.signTx(signAccount.privateKeyEnvelope, masterKey, txBinary, nid) + signedRequest["signed"] = true + } + } + + android.util.Log.i("GajuRouter", "POSTing GRIDS response to $pendingSignUrl") + withContext(Dispatchers.IO) { + Http.post(pendingSignUrl, signedRequest) + } + + onError("Signature submitted successfully.") + screenStack.clear() + screenStack.add(Screen.DASHBOARD) + } catch (e: Throwable) { + val msg = e.message ?: e.toString() + onError("Signing failed: $msg") + android.util.Log.e("GajuRouter", "Signing failed", e) + } + } + }, + onCancel = { navigateBack() } + ) + } Screen.QR_RECEIVE -> { val wallets = walletRepo.listWallets().mapNotNull { walletRepo.loadWallet(it) } val activeWallet = wallets.getOrNull(activeWalletIndex) diff --git a/app/src/main/java/swiss/qpq/gajumobile/data/WalletRepository.kt b/app/src/main/java/swiss/qpq/gajumobile/data/WalletRepository.kt index 7423c35..225d558 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/data/WalletRepository.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/data/WalletRepository.kt @@ -58,11 +58,6 @@ class WalletRepository(private val context: Context) { } } - fun signMessage(account: Account, message: ByteArray): ByteArray { - val masterKey = KeyManager.getMasterKey(context) - return AccountAirlock.sign(account.privateKeyEnvelope, masterKey, message) - } - suspend fun refreshAccount(walletId: String, accountId: String): Account? { val wallet = loadWallet(walletId) ?: return null val account = wallet.accounts.find { it.id == accountId } ?: return null 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 06110f3..0f6fb5d 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/security/AccountAirlock.java +++ b/app/src/main/java/swiss/qpq/gajumobile/security/AccountAirlock.java @@ -1,8 +1,3 @@ -/* - * Copyright (c) 2026 QPQ AG . All rights reserved. - * Project: Gajumaru Mobile Wallet - */ - package swiss.qpq.gajumobile.security; import java.nio.charset.StandardCharsets; @@ -13,6 +8,7 @@ import javax.crypto.spec.SecretKeySpec; import swiss.qpq.gajumaru.core.crypto.Vault; import swiss.qpq.gajumaru.core.encoding.Mnemonic; import swiss.qpq.gajumaru.core.tools.CryptoUtils; +import swiss.qpq.gajumaru.core.tools.TransactionService; import swiss.qpq.gajumobile.data.models.Account; /** @@ -48,12 +44,6 @@ public final class AccountAirlock { /** * Creates a new Account object from a mnemonic phrase. - * - * @param label The user-defined label for the account. - * @param phrase The mnemonic phrase as a list of UTF-8 word byte arrays. - * @param masterKey The hardware-backed Master Key handle. - * @return The new Account object. - * @throws Exception if creation or encryption fails. */ public static Account createAccount( String label, @@ -69,11 +59,17 @@ public final class AccountAirlock { seed = Mnemonic.decode(phrase); // 2. Generate Public Key using Libsodium - byte[] keypair = GajuNative.INSTANCE.cryptoSignSeedKeypair(seed); + if (GajuNative.INSTANCE.getLoadError() != null) { + throw new RuntimeException("Native library failed to load: " + GajuNative.INSTANCE.getLoadError()); + } + byte[] keypair = GajuNative.cryptoSignSeedKeypair(seed); + if (keypair == null) { + throw new RuntimeException("Native keypair generation failed (returned null)."); + } 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.INSTANCE.memzero(keypair); + GajuNative.memzero(keypair); // 3. Generate random Data Encryption Key (DEK) SecureRandom random = new SecureRandom(); @@ -82,11 +78,9 @@ public final class AccountAirlock { SecretKey dek = new SecretKeySpec(dekBytes, "AES"); // 4. Encrypt Private Key (seed) with DEK - // We use the provider-generated IV here for maximum hardware compatibility Vault.Ciphertext dataCiphertext = Vault.encrypt(dek, seed); // 5. Encrypt DEK with Master Key - // Android Keystore REQUIRED to generate the IV when randomizedEncryption is true Vault.Ciphertext dekCiphertext = Vault.encrypt(masterKey, dekBytes); EncryptedEnvelope envelope = new EncryptedEnvelope( @@ -100,86 +94,90 @@ public final class AccountAirlock { return new Account(id, label, publicKey, envelope, null); } finally { - // 6. Memory Hygiene: Wipe sensitive data from the heap - if (seed != null) { - CryptoUtils.wipe(seed); - } - if (dekBytes != null) { - CryptoUtils.wipe(dekBytes); - } - } - } - - /** - * Utility to convert a UI String into the internal airlock byte[][] format. - */ - public static byte[][] stringToPhrase(String phrase) { - String[] words = phrase.trim().split("\\s+"); - byte[][] result = new byte[words.length][]; - for (int i = 0; i < words.length; i++) { - result[i] = words[i].getBytes(StandardCharsets.UTF_8); - } - return result; - } - - /** - * Utility to wipe a phrase returned by the airlock. - */ - public static void wipePhrase(byte[][] phrase) { - if (phrase != null) { - for (byte[] word : phrase) { - CryptoUtils.wipe(word); - } + if (seed != null) GajuNative.memzero(seed); + if (dekBytes != null) CryptoUtils.wipe(dekBytes); } } /** * Decrypts an account's private key and signs a message. */ - public static byte[] sign( + public static byte[] signMessage( EncryptedEnvelope envelope, SecretKey masterKey, - byte[] message + String message ) throws Exception { - - byte[] plaintextDek = null; - byte[] plaintextPrivateKey = null; - + byte[] seed = derivePrivateKey(envelope, masterKey); try { - plaintextDek = Vault.decrypt( - masterKey, - envelope.getDekIv(), - envelope.getEncryptedDek() - ); - SecretKey dek = new SecretKeySpec(plaintextDek, "AES"); - - plaintextPrivateKey = Vault.decrypt( - dek, - envelope.getDataIv(), - envelope.getEncryptedData() - ); - - // Libsodium SK is 64 bytes: seed || public_key - byte[] keypair = GajuNative.INSTANCE.cryptoSignSeedKeypair(plaintextPrivateKey); + byte[] keypair = GajuNative.cryptoSignSeedKeypair(seed); + if (keypair == null) throw new RuntimeException("Native keypair generation failed."); byte[] secretKey = new byte[64]; System.arraycopy(keypair, 32, secretKey, 0, 64); - byte[] sig = GajuNative.INSTANCE.cryptoSignDetached(message, secretKey); + byte[] sig = TransactionService.signMessage(message, secretKey); - GajuNative.INSTANCE.memzero(keypair); - GajuNative.INSTANCE.memzero(secretKey); - + GajuNative.memzero(keypair); + GajuNative.memzero(secretKey); return sig; - } finally { - if (plaintextDek != null) CryptoUtils.wipe(plaintextDek); - if (plaintextPrivateKey != null) GajuNative.INSTANCE.memzero(plaintextPrivateKey); + GajuNative.memzero(seed); + } + } + + /** + * Decrypts an account's private key and signs binary data. + */ + public static byte[] signBinary( + EncryptedEnvelope envelope, + SecretKey masterKey, + byte[] data + ) throws Exception { + byte[] seed = derivePrivateKey(envelope, masterKey); + try { + byte[] keypair = GajuNative.cryptoSignSeedKeypair(seed); + if (keypair == null) throw new RuntimeException("Native keypair generation failed."); + byte[] secretKey = new byte[64]; + System.arraycopy(keypair, 32, secretKey, 0, 64); + + byte[] sig = TransactionService.signBinary(data, secretKey); + + GajuNative.memzero(keypair); + GajuNative.memzero(secretKey); + return sig; + } finally { + GajuNative.memzero(seed); + } + } + + /** + * Decrypts an account's private key and signs a transaction. + */ + public static String signTx( + EncryptedEnvelope envelope, + SecretKey masterKey, + byte[] txData, + String networkId + ) throws Exception { + byte[] seed = derivePrivateKey(envelope, masterKey); + try { + byte[] keypair = GajuNative.cryptoSignSeedKeypair(seed); + if (keypair == null) throw new RuntimeException("Native keypair generation failed."); + byte[] secretKey = new byte[64]; + System.arraycopy(keypair, 32, secretKey, 0, 64); + + String signedTx = TransactionService.signTx(txData, networkId, secretKey); + + GajuNative.memzero(keypair); + GajuNative.memzero(secretKey); + return signedTx; + } finally { + GajuNative.memzero(seed); } } /** * Decrypts an account's private key (seed). - * WARNING: Result must be wiped with CryptoUtils.wipe() immediately after use. + * WARNING: Result must be wiped with GajuNative.memzero() immediately after use. */ public static byte[] derivePrivateKey( EncryptedEnvelope envelope, @@ -211,29 +209,34 @@ public final class AccountAirlock { EncryptedEnvelope envelope, SecretKey masterKey ) throws Exception { - - byte[] plaintextDek = null; - byte[] plaintextPrivateKey = null; - + byte[] plaintextPrivateKey = derivePrivateKey(envelope, masterKey); try { - plaintextDek = Vault.decrypt( - masterKey, - envelope.getDekIv(), - envelope.getEncryptedDek() - ); - SecretKey dek = new SecretKeySpec(plaintextDek, "AES"); - - plaintextPrivateKey = Vault.decrypt( - dek, - envelope.getDataIv(), - envelope.getEncryptedData() - ); - return Mnemonic.encode(plaintextPrivateKey); - } finally { - if (plaintextDek != null) CryptoUtils.wipe(plaintextDek); - if (plaintextPrivateKey != null) GajuNative.INSTANCE.memzero(plaintextPrivateKey); + if (plaintextPrivateKey != null) GajuNative.memzero(plaintextPrivateKey); + } + } + + /** + * Utility to convert a UI String into the internal airlock byte[][] format. + */ + public static byte[][] stringToPhrase(String phrase) { + String[] words = phrase.trim().split("\\s+"); + byte[][] result = new byte[words.length][]; + for (int i = 0; i < words.length; i++) { + result[i] = words[i].getBytes(StandardCharsets.UTF_8); + } + return result; + } + + /** + * Utility to wipe a phrase returned by the airlock. + */ + public static void wipePhrase(byte[][] phrase) { + if (phrase != null) { + for (byte[] word : phrase) { + CryptoUtils.wipe(word); + } } } } diff --git a/app/src/main/java/swiss/qpq/gajumobile/security/AppLifecycleObserver.kt b/app/src/main/java/swiss/qpq/gajumobile/security/AppLifecycleObserver.kt index 0a7a85e..805caa8 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/security/AppLifecycleObserver.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/security/AppLifecycleObserver.kt @@ -9,6 +9,8 @@ import androidx.lifecycle.LifecycleOwner class AppLifecycleObserver(private val context: Context) : DefaultLifecycleObserver { + private var isRegistered = false + private val screenOffReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == Intent.ACTION_SCREEN_OFF) { @@ -20,12 +22,26 @@ class AppLifecycleObserver(private val context: Context) : DefaultLifecycleObser override fun onStart(owner: LifecycleOwner) { super.onStart(owner) SecurityManager.onAppForegrounded() - context.registerReceiver(screenOffReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF)) + if (!isRegistered) { + context.registerReceiver( + screenOffReceiver, + IntentFilter(Intent.ACTION_SCREEN_OFF), + Context.RECEIVER_NOT_EXPORTED + ) + isRegistered = true + } } override fun onStop(owner: LifecycleOwner) { super.onStop(owner) SecurityManager.onAppBackgrounded() - context.unregisterReceiver(screenOffReceiver) + if (isRegistered) { + try { + context.unregisterReceiver(screenOffReceiver) + } catch (e: Exception) { + android.util.Log.e("AppLifecycle", "Unregister failed", e) + } + isRegistered = false + } } } 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 96c5c89..f024662 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/security/EncryptionService.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/security/EncryptionService.kt @@ -55,10 +55,10 @@ object EncryptionService { // 3. Encrypt the DEK with the Master Key (Hardware-backed) val masterKey = KeyManager.getMasterKey(context) val dekCipher = Cipher.getInstance(AES_GCM) - // Let Keystore generate the IV for the Master Key encryption - dekCipher.init(Cipher.ENCRYPT_MODE, masterKey) + val dekIv = ByteArray(IV_LENGTH).apply { SecureRandom().nextBytes(this) } + // Now using manual IV for hardware key to ensure tag length consistency + dekCipher.init(Cipher.ENCRYPT_MODE, masterKey, GCMParameterSpec(GCM_TAG_LENGTH, dekIv)) val encryptedDek = dekCipher.doFinal(dek.encoded) - val dekIv = dekCipher.iv return EncryptedEnvelope( encryptedData = 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 e9bf498..6c7ea39 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/security/GajuNative.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/security/GajuNative.kt @@ -1,13 +1,23 @@ package swiss.qpq.gajumobile.security object GajuNative { + var loadError: String? = null + private set + init { - System.loadLibrary("gaju-native") + try { + System.loadLibrary("gaju-native") + android.util.Log.i("GajuNative", "Native library loaded successfully.") + } catch (e: Throwable) { + loadError = e.message ?: e.toString() + android.util.Log.e("GajuNative", "Failed to load library: $loadError", e) + } } /** * Zeros out the provided byte array using native memset. */ + @JvmStatic external fun memzero(array: ByteArray) /** @@ -15,7 +25,8 @@ object GajuNative { * @param seed 32-byte seed. * @return 96-byte array containing public key (32 bytes) followed by secret key (64 bytes). */ - external fun cryptoSignSeedKeypair(seed: ByteArray): ByteArray + @JvmStatic + external fun cryptoSignSeedKeypair(seed: ByteArray): ByteArray? /** * Signs a message using Ed25519. @@ -23,7 +34,8 @@ object GajuNative { * @param secretKey 64-byte secret key. * @return 64-byte detached signature. */ - external fun cryptoSignDetached(message: ByteArray, secretKey: ByteArray): ByteArray + @JvmStatic + external fun cryptoSignDetached(message: ByteArray, secretKey: ByteArray): ByteArray? /** * Verifies an Ed25519 detached signature. @@ -32,5 +44,6 @@ object GajuNative { * @param publicKey 32-byte public key. * @return true if valid, false otherwise. */ + @JvmStatic external fun cryptoSignVerifyDetached(signature: ByteArray, message: ByteArray, publicKey: ByteArray): Boolean } 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 1b4eafa..8c3e734 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/security/KeyManager.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/security/KeyManager.kt @@ -15,9 +15,15 @@ object KeyManager { fun getMasterKey(context: Context): SecretKey { val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } - + return if (keyStore.containsAlias(MASTER_KEY_ALIAS)) { - keyStore.getKey(MASTER_KEY_ALIAS, null) as SecretKey + 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) + } } else { generateMasterKey(context) } @@ -36,9 +42,8 @@ object KeyManager { .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(256) - .setRandomizedEncryptionRequired(true) - .setUserAuthenticationRequired(true) - .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL) + .setRandomizedEncryptionRequired(false) // Allow manual IVs for consistency + .setUserAuthenticationRequired(false) // Target StrongBox if available (Android 9+) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/DashboardScreen.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/DashboardScreen.kt index f287430..69d040d 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/DashboardScreen.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/DashboardScreen.kt @@ -58,6 +58,7 @@ fun DashboardScreen( balanceUnit: GajuFormat.Unit = GajuFormat.Unit.GAJU, onSend: () -> Unit, onReceive: () -> Unit, + onGrids: () -> Unit, onAddAccount: (String) -> Unit, onAccountSettings: (String, String) -> Unit, onRefresh: (String, String) -> Unit, @@ -65,7 +66,29 @@ fun DashboardScreen( ) { if (wallets.isEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("No wallets found. Please create one.") + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("No wallets found. Please create one.") + Spacer(modifier = Modifier.height(16.dp)) + ActionCircle( + text = "Create", + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + onClick = { onNavigate("CREATE_WALLET") } + ) + Spacer(modifier = Modifier.height(32.dp)) + Text( + text = "If you created a wallet but don't see it, it may be corrupted.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 48.dp) + ) + androidx.compose.material3.TextButton( + onClick = { onNavigate("WIPE_DATA") } + ) { + Text("Wipe Broken Wallets", color = MaterialTheme.colorScheme.error) + } + } } return } @@ -240,7 +263,15 @@ fun DashboardScreen( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("BALANCE", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 14.sp) + Column { + Text("BALANCE", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 14.sp) + Text( + text = "ON ${currentWallet.networkId.uppercase()}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + fontWeight = FontWeight.Bold + ) + } Row(verticalAlignment = Alignment.CenterVertically) { IconButton( onClick = { @@ -249,7 +280,7 @@ fun DashboardScreen( modifier = Modifier.size(24.dp) ) { Icon( - painter = painterResource(id = R.drawable.ic_check_circle), // Reuse an icon for refresh for now + painter = painterResource(id = R.drawable.ic_refresh), contentDescription = "Refresh Balance", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp), @@ -287,7 +318,7 @@ fun DashboardScreen( Spacer(modifier = Modifier.weight(1f)) - // Send / Receive Buttons + // Send / Receive / GRIDS Buttons Row( modifier = Modifier .fillMaxWidth() @@ -300,6 +331,12 @@ fun DashboardScreen( contentColor = MaterialTheme.colorScheme.onSecondary, onClick = onSend, ) + ActionCircle( + text = "GRIDS", + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary, + onClick = onGrids, + ) ActionCircle( text = "Receive", containerColor = MaterialTheme.colorScheme.primary, diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/FetchingScreen.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/FetchingScreen.kt new file mode 100644 index 0000000..e371310 --- /dev/null +++ b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/FetchingScreen.kt @@ -0,0 +1,81 @@ +package swiss.qpq.gajumobile.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import swiss.qpq.gajumobile.ui.components.GajuButton +import swiss.qpq.gajumobile.ui.components.GajuHeader + +@Composable +fun FetchingScreen( + url: String, + onCancel: () -> Unit +) { + Scaffold( + modifier = Modifier.fillMaxSize(), + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(innerPadding) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + GajuHeader() + + Spacer(modifier = Modifier.height(48.dp)) + + CircularProgressIndicator( + modifier = Modifier.size(64.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 6.dp + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = "RETRIEVING REQUEST", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Connecting to:", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp) + ) + + Spacer(modifier = Modifier.weight(1f)) + + GajuButton( + text = "CANCEL", + onClick = onCancel, + modifier = Modifier.fillMaxWidth(), + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary + ) + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/MnemonicRecoveryScreen.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/MnemonicRecoveryScreen.kt index f779f64..d23a9d2 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/MnemonicRecoveryScreen.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/MnemonicRecoveryScreen.kt @@ -72,7 +72,7 @@ fun MnemonicRecoveryScreen( color = MaterialTheme.colorScheme.surfaceVariant, tonalElevation = 8.dp ) { - Row( + FlowRow( modifier = Modifier.padding(8.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/MnemonicVerifyScreen.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/MnemonicVerifyScreen.kt index dafa28a..615cad0 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/MnemonicVerifyScreen.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/MnemonicVerifyScreen.kt @@ -75,7 +75,7 @@ fun MnemonicVerifyScreen( color = MaterialTheme.colorScheme.surfaceVariant, tonalElevation = 8.dp ) { - Row( + FlowRow( modifier = Modifier.padding(8.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/SettingsScreen.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/SettingsScreen.kt index 77d04b3..6e6b6fc 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/SettingsScreen.kt @@ -14,13 +14,20 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -105,6 +112,68 @@ fun SettingsScreen( } Spacer(modifier = Modifier.weight(1f)) + + var showWipeConfirm by remember { mutableStateOf(false) } + + if (showWipeConfirm) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.errorContainer, + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + "WIPE ALL DATA?", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + "This will permanently delete all wallets, accounts, and keys. This action cannot be undone.", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(vertical = 8.dp) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + OutlinedButton( + onClick = { showWipeConfirm = false }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onErrorContainer) + ) { + Text("CANCEL") + } + androidx.compose.material3.Button( + onClick = { onNavigate("WIPE_DATA") }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { + Text("WIPE EVERYTHING") + } + } + } + } + } else { + OutlinedButton( + onClick = { showWipeConfirm = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_check_circle), // placeholder for trash + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("WIPE ALL DATA") + } + } + + Spacer(modifier = Modifier.height(24.dp)) } } } diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/SignRequestScreen.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/SignRequestScreen.kt new file mode 100644 index 0000000..deeaadc --- /dev/null +++ b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/SignRequestScreen.kt @@ -0,0 +1,140 @@ +package swiss.qpq.gajumobile.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import swiss.qpq.gajumobile.ui.components.GajuButton +import swiss.qpq.gajumobile.ui.components.GajuHeader + +@Composable +fun SignRequestScreen( + type: String, + originUrl: String, + accountLabel: String, + accountId: String, + payload: String, + onSign: () -> Unit, + onCancel: () -> Unit, +) { + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(24.dp) + ) { + GajuButton( + text = "CANCEL", + onClick = onCancel, + modifier = Modifier.weight(1f), + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary, + ) + Spacer(modifier = Modifier.width(16.dp)) + GajuButton( + text = "SIGN", + onClick = onSign, + modifier = Modifier.weight(1f), + ) + } + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(innerPadding) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + GajuHeader() + + // NOTE: CRE 2026-08-25 + // This eventually needs to expand to complete handling of contract call requests. + // That will involve some additions to GRIDS dead drop message forms to include + // contract call requests where the intent should be expressed and the signer + // requestee should be burdened with forming the TX from the contract name/ID, + // function name, and args. This would be significantly more robust to abuse than + // the current "TX signature request" where the TX internals are opaque. + Text( + text = when (type) { + "message" -> "MESSAGE SIGNATURE REQUEST" + "binary" -> "BINARY DATA SIGNATURE REQUEST" + "tx" -> "TRANSACTION SIGNATURE REQUEST" + else -> "SIGNATURE REQUEST" + }, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 16.dp), + textAlign = TextAlign.Center + ) + + Text( + "The server at the URL below is requesting you sign the following.", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(24.dp)) + + SectionLabel("SIGNATURE ACCOUNT") + Text(accountLabel, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + Text(accountId, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace) + + Spacer(modifier = Modifier.height(16.dp)) + + SectionLabel("ORIGINATING URL") + Text(originUrl, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + + Spacer(modifier = Modifier.height(24.dp)) + + SectionLabel(when (type) { + "message" -> "MESSAGE" + "binary" -> "BASE-64 DATA" + "tx" -> "TRANSACTION DATA" + else -> "PAYLOAD" + }) + + Surface( + modifier = Modifier.fillMaxWidth().heightIn(min = 100.dp, max = 300.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp), + ) { + Text( + text = payload, + modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()), + fontSize = 12.sp, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), + modifier = Modifier.padding(bottom = 4.dp) + ) +} diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..621b4dc --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,10 @@ + + + diff --git a/gm-java b/gm-java index 954d876..64c5922 160000 --- a/gm-java +++ b/gm-java @@ -1 +1 @@ -Subproject commit 954d8760ac9b7147461f8e88ee64857d99b8dea2 +Subproject commit 64c592285a6b21f5b3dabc8cee24f3be9cde3bb3