WIP: omg so much little stuff

This commit is contained in:
2026-08-25 20:53:15 +09:00
parent bbac50a8fe
commit 77a9f5f380
15 changed files with 661 additions and 141 deletions
@@ -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<Map<String, Any?>?>(null) }
var pendingSignUrl by rememberSaveable { mutableStateOf("") }
var activeFetchJob by remember { mutableStateOf<Job?>(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<String, Any?>
}
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<String, Any?>()
// 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)
@@ -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
@@ -1,8 +1,3 @@
/*
* Copyright (c) 2026 QPQ AG <info@qpq.swiss>. 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);
}
}
}
}
@@ -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
}
}
}
@@ -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,
@@ -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
}
@@ -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) {
@@ -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,
@@ -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))
}
}
}
@@ -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)
) {
@@ -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)
) {
@@ -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))
}
}
}
@@ -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)
)
}
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.07,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>
+1 -1
Submodule gm-java updated: 954d8760ac...64c592285a