diff --git a/fakewallet/build.gradle b/fakewallet/build.gradle index 5a3b01d..359efd1 100644 --- a/fakewallet/build.gradle +++ b/fakewallet/build.gradle @@ -53,6 +53,7 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'com.google.android.material:material:1.6.1' + implementation 'org.bouncycastle:bcprov-jdk15on:1.70' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' diff --git a/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/MainActivity.kt b/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/MainActivity.kt index ab4d920..76dba3f 100644 --- a/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/MainActivity.kt +++ b/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/MainActivity.kt @@ -6,6 +6,7 @@ package com.solanamobile.fakewallet.ui import android.content.Intent import android.os.Bundle +import android.os.PersistableBundle import android.util.Log import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -28,12 +29,15 @@ class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private var shownMessageIndex: Int? = null + private var pendingEvent: ViewModelEvent? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) + pendingEvent = savedInstanceState?.getParcelable(KEY_PENDING_EVENT) + val seedListadapter = SeedListAdapter( lifecycleScope = lifecycleScope, implementationLimits = viewModel.uiState.map { uiState -> @@ -125,20 +129,22 @@ class MainActivity : AppCompatActivity() { lifecycle.coroutineScope.launch { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.viewModelEvents.collect { event -> + check(pendingEvent == null) { "Received a request while another is pending" } when (event) { is ViewModelEvent.AuthorizeNewSeed -> { val i = Wallet.authorizeSeed(WalletContractV1.PURPOSE_SIGN_SOLANA_TRANSACTION) @Suppress("deprecation") startActivityForResult(i, REQUEST_AUTHORIZE_SEED_ACCESS) + pendingEvent = event } is ViewModelEvent.DeauthorizeSeed -> { try { Wallet.deauthorizeSeed(this@MainActivity, event.authToken) Log.d(TAG, "Seed ${event.authToken} deauthorized") - viewModel.onDeauthorizeSeedSuccess() + viewModel.onDeauthorizeSeedSuccess(event) } catch (e: Exception) { Log.e(TAG, "Failed to deauthorize seed", e) - viewModel.onDeauthorizeSeedFailure(-1) + viewModel.onDeauthorizeSeedFailure(event, -1) } } is ViewModelEvent.UpdateAccountName -> { @@ -146,10 +152,10 @@ class MainActivity : AppCompatActivity() { Wallet.updateAccountName(this@MainActivity, event.authToken, event.accountId, event.name) Log.d(TAG, "Account name updated (to '${event.name})'") - viewModel.onUpdateAccountNameSuccess() + viewModel.onUpdateAccountNameSuccess(event) } catch (e: Exception) { Log.e(TAG, "Failed to update account name", e) - viewModel.onUpdateAccountNameFailure(-1) + viewModel.onUpdateAccountNameFailure(event, -1) } } is ViewModelEvent.SignTransactions -> { @@ -157,18 +163,21 @@ class MainActivity : AppCompatActivity() { event.authToken, event.transactions) @Suppress("deprecation") startActivityForResult(i, REQUEST_SIGN_TRANSACTIONS) + pendingEvent = event } is ViewModelEvent.SignMessages -> { val i = Wallet.signMessages( event.authToken, event.messages) @Suppress("deprecation") startActivityForResult(i, REQUEST_SIGN_MESSAGES) + pendingEvent = event } is ViewModelEvent.RequestPublicKeys -> { val i = Wallet.requestPublicKeys( event.authToken, event.derivationPaths) @Suppress("deprecation") startActivityForResult(i, REQUEST_GET_PUBLIC_KEYS) + pendingEvent = event } } } @@ -180,55 +189,68 @@ class MainActivity : AppCompatActivity() { @Suppress("deprecation") super.onActivityResult(requestCode, resultCode, data) + val event = pendingEvent!! + pendingEvent = null + when (requestCode) { REQUEST_AUTHORIZE_SEED_ACCESS -> { + check(event is ViewModelEvent.AuthorizeNewSeed) try { val authToken = Wallet.onAuthorizeSeedResult(resultCode, data) Log.d(TAG, "Seed authorized, AuthToken=$authToken") - viewModel.onAuthorizeNewSeedSuccess(authToken) + viewModel.onAuthorizeNewSeedSuccess(event, authToken) } catch (e: Wallet.ActionFailedException) { Log.e(TAG, "Seed authorization failed", e) - viewModel.onAuthorizeNewSeedFailure(resultCode) + viewModel.onAuthorizeNewSeedFailure(event, resultCode) } } REQUEST_SIGN_TRANSACTIONS -> { + check(event is ViewModelEvent.SignTransactions) try { val result = Wallet.onSignTransactionsResult(resultCode, data) Log.d(TAG, "Transaction signed: signatures=$result") - viewModel.onSignTransactionsSuccess(result) + viewModel.onSignTransactionsSuccess(event, result) } catch (e: Wallet.ActionFailedException) { Log.e(TAG, "Transaction signing failed", e) - viewModel.onSignTransactionsFailure(resultCode) + viewModel.onSignTransactionsFailure(event, resultCode) } } REQUEST_SIGN_MESSAGES -> { + check(event is ViewModelEvent.SignMessages) try { val result = Wallet.onSignMessagesResult(resultCode, data) Log.d(TAG, "Message signed: signatures=$result") - viewModel.onSignMessagesSuccess(result) + viewModel.onSignMessagesSuccess(event, result) } catch (e: Wallet.ActionFailedException) { Log.e(TAG, "Message signing failed", e) - viewModel.onSignMessagesFailure(resultCode) + viewModel.onSignMessagesFailure(event, resultCode) } } REQUEST_GET_PUBLIC_KEYS -> { + check(event is ViewModelEvent.RequestPublicKeys) try { val result = Wallet.onRequestPublicKeysResult(resultCode, data) Log.d(TAG, "Public key retrieved: publicKey=$result") - viewModel.onRequestPublicKeysSuccess(result) + viewModel.onRequestPublicKeysSuccess(event, result) } catch (e: Wallet.ActionFailedException) { Log.e(TAG, "Transaction signing failed", e) - viewModel.onRequestPublicKeysFailure(resultCode) + viewModel.onRequestPublicKeysFailure(event, resultCode) } } } } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putParcelable(KEY_PENDING_EVENT, pendingEvent) + } + companion object { private val TAG = MainActivity::class.simpleName private const val REQUEST_AUTHORIZE_SEED_ACCESS = 0 private const val REQUEST_SIGN_TRANSACTIONS = 1 private const val REQUEST_SIGN_MESSAGES = 2 private const val REQUEST_GET_PUBLIC_KEYS = 3 + private const val KEY_PENDING_EVENT = "pendingEvent" } } \ No newline at end of file diff --git a/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/MainViewModel.kt b/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/MainViewModel.kt index a7fba35..895dec0 100644 --- a/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/MainViewModel.kt +++ b/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/MainViewModel.kt @@ -8,9 +8,12 @@ import android.app.Application import android.database.ContentObserver import android.net.Uri import android.os.Handler +import android.os.Parcel +import android.os.Parcelable import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.solanamobile.fakewallet.usecase.VerifyEd25519SignatureUseCase import com.solanamobile.seedvault.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* @@ -147,7 +150,7 @@ class MainViewModel( } } - fun onAuthorizeNewSeedSuccess(authToken: Long) { + fun onAuthorizeNewSeedSuccess(event: ViewModelEvent.AuthorizeNewSeed, authToken: Long) { // Mark two accounts as user wallets. This simulates a real wallet app exploring each // account and marking them as containing user funds. viewModelScope.launch { @@ -185,7 +188,7 @@ class MainViewModel( } } - fun onAuthorizeNewSeedFailure(resultCode: Int) { + fun onAuthorizeNewSeedFailure(event: ViewModelEvent.AuthorizeNewSeed, resultCode: Int) { showErrorMessage(resultCode) } @@ -197,10 +200,10 @@ class MainViewModel( } } - fun onDeauthorizeSeedSuccess() { + fun onDeauthorizeSeedSuccess(event: ViewModelEvent.DeauthorizeSeed) { } - fun onDeauthorizeSeedFailure(resultCode: Int) { + fun onDeauthorizeSeedFailure(event: ViewModelEvent.DeauthorizeSeed, resultCode: Int) { showErrorMessage(resultCode) } @@ -216,15 +219,15 @@ class MainViewModel( } } - fun onUpdateAccountNameSuccess() { + fun onUpdateAccountNameSuccess(event: ViewModelEvent.UpdateAccountName) { } - fun onUpdateAccountNameFailure(resultCode: Int) { + fun onUpdateAccountNameFailure(event: ViewModelEvent.UpdateAccountName, resultCode: Int) { showErrorMessage(resultCode) } fun signFakeTransaction(@WalletContractV1.AuthToken authToken: Long, account: Account) { - val fakeTransaction = byteArrayOf(0.toByte()) + val fakeTransaction = createFakeTransaction(0) viewModelScope.launch { val transaction = SigningRequest(fakeTransaction, listOf(account.derivationPath)) _viewModelEvents.emit( @@ -247,7 +250,7 @@ class MainViewModel( Bip44DerivationPath.newBuilder() .setAccount(BipLevel(i * maxRequestedSignatures + j, true)).build().toUri() } - SigningRequest(byteArrayOf(i.toByte()), derivationPaths) + SigningRequest(createFakeTransaction(i), derivationPaths) } viewModelScope.launch { @@ -257,16 +260,28 @@ class MainViewModel( } } - fun onSignTransactionsSuccess(signatures: List) { - showMessage("Transactions signed successfully") + private fun createFakeTransaction(i: Int): ByteArray { + return ByteArray(TRANSACTION_SIZE) { i.toByte() } } - fun onSignTransactionsFailure(resultCode: Int) { + fun onSignTransactionsSuccess( + event: ViewModelEvent.SignTransactions, + signatures: List + ) { + verifySignatures( + event.authToken, + event.transactions, + signatures, + "Transactions signed successfully" + ) + } + + fun onSignTransactionsFailure(event: ViewModelEvent.SignTransactions, resultCode: Int) { showErrorMessage(resultCode) } fun signFakeMessage(@WalletContractV1.AuthToken authToken: Long, account: Account) { - val fakeMessage = byteArrayOf(1.toByte()) + val fakeMessage = createFakeMessage(0) viewModelScope.launch { val message = SigningRequest(fakeMessage, listOf(account.derivationPath)) _viewModelEvents.emit( @@ -289,7 +304,7 @@ class MainViewModel( Bip44DerivationPath.newBuilder() .setAccount(BipLevel(i * maxRequestedSignatures + j, true)).build().toUri() } - SigningRequest(byteArrayOf(i.toByte()), derivationPaths) + SigningRequest(createFakeMessage(i), derivationPaths) } viewModelScope.launch { @@ -299,11 +314,23 @@ class MainViewModel( } } - fun onSignMessagesSuccess(signatures: List) { - showMessage("Messages signed successfully") + private fun createFakeMessage(i: Int): ByteArray { + return ByteArray(MESSAGE_SIZE) { i.toByte() } + } + + fun onSignMessagesSuccess( + event: ViewModelEvent.SignMessages, + signatures: List + ) { + verifySignatures( + event.authToken, + event.messages, + signatures, + "Messages signed successfully" + ) } - fun onSignMessagesFailure(resultCode: Int) { + fun onSignMessagesFailure(event: ViewModelEvent.SignMessages, resultCode: Int) { showErrorMessage(resultCode) } @@ -325,11 +352,11 @@ class MainViewModel( } } - fun onRequestPublicKeysSuccess(publicKeys: List) { + fun onRequestPublicKeysSuccess(event: ViewModelEvent.RequestPublicKeys, publicKeys: List) { showMessage("Public key(s) retrieved") } - fun onRequestPublicKeysFailure(resultCode: Int) { + fun onRequestPublicKeysFailure(event: ViewModelEvent.RequestPublicKeys, resultCode: Int) { showErrorMessage(resultCode) } @@ -367,6 +394,44 @@ class MainViewModel( } } + private fun verifySignatures( + @WalletContractV1.AuthToken authToken: Long, + signingRequests: List, + signingResponses: List, + successMessage: String + ) { + check(signingRequests.size == signingResponses.size) { "Mismatch between number of requested and provided signatures" } + viewModelScope.launch { + val signaturesVerified = signingRequests.zip(signingResponses) { request, response -> + val publicKeys = response.resolvedDerivationPaths.map { resolvedDerivationPath -> + val c = Wallet.getAccounts( + getApplication(), + authToken, + arrayOf(WalletContractV1.ACCOUNTS_PUBLIC_KEY_RAW), + WalletContractV1.ACCOUNTS_BIP32_DERIVATION_PATH, + resolvedDerivationPath.toString() + ) + if (c?.moveToNext() != true) { + showMessage("Error: one or more public keys not found") + return@launch + } + c.getBlob(0) + } + + response.signatures.zip(publicKeys) { payloadSignature, publicKey -> + VerifyEd25519SignatureUseCase(publicKey, request.payload, payloadSignature) + }.all { it } + }.all { it } + + if (!signaturesVerified) { + showMessage("ERROR: One or more signatures not valid") + return@launch + } + + showMessage(successMessage) + } + } + private fun showErrorMessage(resultCode: Int) { showMessage("Action failed, error=$resultCode") } @@ -391,6 +456,8 @@ class MainViewModel( private val TAG = MainViewModel::class.simpleName private const val FIRST_REQUESTED_PUBLIC_KEY_INDEX = 1000 private const val IMPLEMENTATION_LIMITS_MAX_BIP32_PATH_DEPTH = "MaxBip32PathDepth" + private const val TRANSACTION_SIZE = 512 + private const val MESSAGE_SIZE = 512 } } @@ -421,31 +488,110 @@ data class UiState( val messages: List = listOf() ) -sealed interface ViewModelEvent { - object AuthorizeNewSeed : ViewModelEvent +sealed interface ViewModelEvent : Parcelable { + object AuthorizeNewSeed : ViewModelEvent { + override fun writeToParcel(parcel: Parcel, flags: Int) = Unit + override fun describeContents(): Int = 0 + + @JvmField + val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = AuthorizeNewSeed + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } data class DeauthorizeSeed( @WalletContractV1.AuthToken val authToken: Long - ) : ViewModelEvent + ) : ViewModelEvent { + constructor(p: Parcel) : this(p.readLong()) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeLong(authToken) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = DeauthorizeSeed(parcel) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } data class UpdateAccountName( @WalletContractV1.AuthToken val authToken: Long, @WalletContractV1.AccountId val accountId: Long, val name: String?, - ) : ViewModelEvent + ) : ViewModelEvent { + constructor(p: Parcel) : this(p.readLong(), p.readLong(), p.readString()) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeLong(authToken) + parcel.writeLong(accountId) + parcel.writeString(name) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = UpdateAccountName(parcel) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } data class SignTransactions( @WalletContractV1.AuthToken val authToken: Long, val transactions: ArrayList, - ) : ViewModelEvent + ) : ViewModelEvent { + constructor(p: Parcel) : this(p.readLong(), p.createTypedArrayList(SigningRequest.CREATOR)!!) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeLong(authToken) + parcel.writeParcelableList(transactions, 0) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = SignTransactions(parcel) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } data class SignMessages( @WalletContractV1.AuthToken val authToken: Long, val messages: ArrayList, - ) : ViewModelEvent + ) : ViewModelEvent { + constructor(p: Parcel) : this(p.readLong(), p.createTypedArrayList(SigningRequest.CREATOR)!!) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeLong(authToken) + parcel.writeParcelableList(messages, 0) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = SignMessages(parcel) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } data class RequestPublicKeys( @WalletContractV1.AuthToken val authToken: Long, val derivationPaths: ArrayList, - ) : ViewModelEvent + ) : ViewModelEvent { + constructor(p: Parcel) : this(p.readLong(), p.createTypedArrayList(Uri.CREATOR)!!) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeLong(authToken) + parcel.writeParcelableList(derivationPaths, 0) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = RequestPublicKeys(parcel) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } } diff --git a/fakewallet/src/main/java/com/solanamobile/fakewallet/usecase/VerifyEd25519SignatureUseCase.kt b/fakewallet/src/main/java/com/solanamobile/fakewallet/usecase/VerifyEd25519SignatureUseCase.kt new file mode 100644 index 0000000..2f9a0a1 --- /dev/null +++ b/fakewallet/src/main/java/com/solanamobile/fakewallet/usecase/VerifyEd25519SignatureUseCase.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Solana Mobile Inc. + */ + +package com.solanamobile.fakewallet.usecase + +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer + +object VerifyEd25519SignatureUseCase { + operator fun invoke(publicKey: ByteArray, payload: ByteArray, signature: ByteArray): Boolean { + require(publicKey.size == ACCOUNT_PUBLIC_KEY_LEN) { "Invalid public key length for a Solana transaction" } + require(payload.isNotEmpty()) { "Payload cannot be empty" } + require(signature.size == SIGNATURE_LEN) { "Invalid signature length for a Solana transaction" } + val publicKeyParams = Ed25519PublicKeyParameters(publicKey, 0) + val signer = Ed25519Signer() + signer.init(false, publicKeyParams) + signer.update(payload, 0, payload.size) + return signer.verifySignature(signature) + } + + private const val ACCOUNT_PUBLIC_KEY_LEN = 32 + private const val SIGNATURE_LEN = 64 +} \ No newline at end of file