diff --git a/.gitignore b/.gitignore index aa724b7..9913d0f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .gradle /local.properties /.idea/caches +/.idea/deploymentTargetSelector.xml /.idea/libraries /.idea/modules.xml /.idea/workspace.xml diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index 8cbe05f..0efcb9c 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -87,6 +87,10 @@ dependencies { implementation("androidx.credentials:credentials:1.2.2") implementation("androidx.credentials:credentials-play-services-auth:1.2.2") + // Deterministic Passkeys + implementation(files("libs/dP256.jar")) + implementation("cash.z.ecc.android:kotlin-bip39:1.0.8") + // Kotlin Coroutine val coroutineVersion by extra { "1.7.1" } implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutineVersion") diff --git a/demo/libs/dP256.jar b/demo/libs/dP256.jar new file mode 100644 index 0000000..d9ba34a Binary files /dev/null and b/demo/libs/dP256.jar differ diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index 2918679..3f8a456 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -135,6 +135,7 @@ + diff --git a/demo/src/main/java/foundation/algorand/demo/AnswerActivity.kt b/demo/src/main/java/foundation/algorand/demo/AnswerActivity.kt index 7ee48c0..7c2d75a 100644 --- a/demo/src/main/java/foundation/algorand/demo/AnswerActivity.kt +++ b/demo/src/main/java/foundation/algorand/demo/AnswerActivity.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.lifecycleScope import com.algorand.algosdk.account.Account import com.algorand.algosdk.transaction.Transaction import com.algorand.algosdk.util.Encoder +import com.fasterxml.uuid.Generators import com.google.android.gms.fido.Fido import com.google.android.gms.fido.fido2.Fido2ApiClient import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse @@ -39,15 +40,29 @@ import foundation.algorand.auth.fido2.AssertionApi import foundation.algorand.auth.fido2.AttestationApi import foundation.algorand.auth.fido2.toPublicKeyCredentialCreationOptions import foundation.algorand.auth.fido2.toPublicKeyCredentialRequestOptions +import foundation.algorand.crypto.EncoderType import foundation.algorand.crypto.avm.KeyPairs import foundation.algorand.demo.credential.CredentialRepository import foundation.algorand.demo.credential.db.Credential import foundation.algorand.demo.credential.db.CredentialDatabase import foundation.algorand.demo.databinding.ActivityAnswerBinding +import foundation.algorand.demo.derivedSecret.DerivedSecretRepository import foundation.algorand.demo.provider.AVMProvider import foundation.algorand.demo.settings.AccountDialogFragment +import foundation.algorand.demo.settings.ManualAddPassKeysDialogFragment import foundation.algorand.demo.settings.NotificationsDialogFragment +import foundation.algorand.demo.settings.PassKeysMnemonicDialogFragment import foundation.algorand.demo.settings.SettingsDialogFragment +import foundation.algorand.provider.Message +import foundation.algorand.provider.avm.models.RequestMessage +import foundation.algorand.provider.avm.models.ResponseMessage +import foundation.algorand.provider.avm.models.SignTransactionsParams +import foundation.algorand.provider.avm.models.SignTransactionsResult +import java.security.Security +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import okhttp3.OkHttpClient @@ -57,18 +72,6 @@ import org.json.JSONObject import org.webrtc.DataChannel import org.webrtc.PeerConnection import ru.gildor.coroutines.okhttp.await -import java.security.Security -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine -import com.fasterxml.uuid.Generators -import foundation.algorand.crypto.EncoderType -import foundation.algorand.provider.Message -import foundation.algorand.provider.avm.models.RequestMessage -import foundation.algorand.provider.avm.models.ResponseMessage -import foundation.algorand.provider.avm.models.SignTransactionsParams -import foundation.algorand.provider.avm.models.SignTransactionsResult -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi class AnswerActivity : AppCompatActivity() { companion object { @@ -77,27 +80,71 @@ class AnswerActivity : AppCompatActivity() { } fun createIceServer(uri: String, username: String, password: String): PeerConnection.IceServer { return PeerConnection.IceServer.builder(uri) - .setUsername(username) - .setPassword(password) - .createIceServer() + .setUsername(username) + .setPassword(password) + .createIceServer() } // Liquid Auth Service - private val iceServers = listOf( - PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer(), - PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer(), - PeerConnection.IceServer.builder("stun:stun2.l.google.com:19302").createIceServer(), - createIceServer("turn:global.turn.nodely.network:80?transport=tcp", BuildConfig.NODELY_TURN_USERNAME, BuildConfig.NODELY_TURN_CREDENTIAL), - createIceServer("turns:global.turn.nodely.network:443?transport=tcp", BuildConfig.NODELY_TURN_USERNAME, BuildConfig.NODELY_TURN_CREDENTIAL), - createIceServer("turn:eu.turn.nodely.io:80?transport=tcp", BuildConfig.NODELY_TURN_USERNAME, BuildConfig.NODELY_TURN_CREDENTIAL), - createIceServer("turns:eu.turn.nodely.io:443?transport=tcp", BuildConfig.NODELY_TURN_USERNAME, BuildConfig.NODELY_TURN_CREDENTIAL), - createIceServer("turn:us.turn.nodely.io:80?transport=tcp", BuildConfig.NODELY_TURN_USERNAME, BuildConfig.NODELY_TURN_CREDENTIAL), - createIceServer("turns:us.turn.nodely.io:443?transport=tcp", BuildConfig.NODELY_TURN_USERNAME, BuildConfig.NODELY_TURN_CREDENTIAL), - createIceServer("turn:global.relay.metered.ca:80", BuildConfig.TURN_USERNAME, BuildConfig.TURN_CREDENTIAL), - createIceServer("turn:global.relay.metered.ca:80?transport=tcp", BuildConfig.TURN_USERNAME, BuildConfig.TURN_CREDENTIAL), - createIceServer("turn:global.relay.metered.ca:443", BuildConfig.TURN_USERNAME, BuildConfig.TURN_CREDENTIAL), - createIceServer("turns:global.relay.metered.ca:443?transport=tcp", BuildConfig.TURN_USERNAME, BuildConfig.TURN_CREDENTIAL) - ) + private val iceServers = + listOf( + PeerConnection.IceServer.builder("stun:stun.l.google.com:19302") + .createIceServer(), + PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302") + .createIceServer(), + PeerConnection.IceServer.builder("stun:stun2.l.google.com:19302") + .createIceServer(), + createIceServer( + "turn:global.turn.nodely.network:80?transport=tcp", + BuildConfig.NODELY_TURN_USERNAME, + BuildConfig.NODELY_TURN_CREDENTIAL + ), + createIceServer( + "turns:global.turn.nodely.network:443?transport=tcp", + BuildConfig.NODELY_TURN_USERNAME, + BuildConfig.NODELY_TURN_CREDENTIAL + ), + createIceServer( + "turn:eu.turn.nodely.io:80?transport=tcp", + BuildConfig.NODELY_TURN_USERNAME, + BuildConfig.NODELY_TURN_CREDENTIAL + ), + createIceServer( + "turns:eu.turn.nodely.io:443?transport=tcp", + BuildConfig.NODELY_TURN_USERNAME, + BuildConfig.NODELY_TURN_CREDENTIAL + ), + createIceServer( + "turn:us.turn.nodely.io:80?transport=tcp", + BuildConfig.NODELY_TURN_USERNAME, + BuildConfig.NODELY_TURN_CREDENTIAL + ), + createIceServer( + "turns:us.turn.nodely.io:443?transport=tcp", + BuildConfig.NODELY_TURN_USERNAME, + BuildConfig.NODELY_TURN_CREDENTIAL + ), + createIceServer( + "turn:global.relay.metered.ca:80", + BuildConfig.TURN_USERNAME, + BuildConfig.TURN_CREDENTIAL + ), + createIceServer( + "turn:global.relay.metered.ca:80?transport=tcp", + BuildConfig.TURN_USERNAME, + BuildConfig.TURN_CREDENTIAL + ), + createIceServer( + "turn:global.relay.metered.ca:443", + BuildConfig.TURN_USERNAME, + BuildConfig.TURN_CREDENTIAL + ), + createIceServer( + "turns:global.relay.metered.ca:443?transport=tcp", + BuildConfig.TURN_USERNAME, + BuildConfig.TURN_CREDENTIAL + ) + ) private var mBounded = false private var signalService: SignalService? = null @@ -105,9 +152,10 @@ class AnswerActivity : AppCompatActivity() { // Data Models private lateinit var db: CredentialDatabase - private val credentialRepository = CredentialRepository() // Handle Credential Operations - private val viewModel: AnswerViewModel by viewModels() // Handle View State - private val wallet: WalletViewModel by viewModels() // Handle Wallet Operations + private val credentialRepository = CredentialRepository() // Handle Credential Operations + private val derivedSecretRepository = DerivedSecretRepository() + private val viewModel: AnswerViewModel by viewModels() // Handle View State + private val wallet: WalletViewModel by viewModels() // Handle Wallet Operations private val notifications: NotificationViewModel by viewModels() // Handle Notifications // Fragments/Bindings @@ -115,20 +163,18 @@ class AnswerActivity : AppCompatActivity() { private lateinit var binding: ActivityAnswerBinding // Third Party APIs - private var httpClient = OkHttpClient.Builder() - .cookieJar(Cookies()) - .build() + private var httpClient = OkHttpClient.Builder().cookieJar(Cookies()).build() private lateinit var scanner: GmsBarcodeScanner private lateinit var promptInfo: BiometricPrompt.PromptInfo - // FIDO/Auth interfaces private var fido2Client: Fido2ApiClient? = null private var signalClient: SignalClient? = null private val attestationApi = AttestationApi(httpClient) private val assertionApi = AssertionApi(httpClient) - private val userAgent = "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} " + - "(Android ${Build.VERSION.RELEASE}; ${Build.MODEL}; ${Build.BRAND})" + private val userAgent = + "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} " + + "(Android ${Build.VERSION.RELEASE}; ${Build.MODEL}; ${Build.BRAND})" private var signature: ByteArray? = null // Datachannel Provider/Handler @@ -139,17 +185,18 @@ class AnswerActivity : AppCompatActivity() { private val provider = AVMProvider(providerId) // Register/Attestation Intent Launcher - private val attestationIntentLauncher = registerForActivityResult( - ActivityResultContracts.StartIntentSenderForResult(), - ::handleAuthenticatorAttestationResult - ) + private val attestationIntentLauncher = + registerForActivityResult( + ActivityResultContracts.StartIntentSenderForResult(), + ::handleAuthenticatorAttestationResult + ) // Authenticate/Assertion Intent Channel - private val assertionIntentLauncher = registerForActivityResult( - ActivityResultContracts.StartIntentSenderForResult(), - ::handleAuthenticatorAssertionResult - ) - + private val assertionIntentLauncher = + registerForActivityResult( + ActivityResultContracts.StartIntentSenderForResult(), + ::handleAuthenticatorAssertionResult + ) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -168,10 +215,15 @@ class AnswerActivity : AppCompatActivity() { // Create Fragments accountDialogFragment = - AccountDialogFragment(wallet.account.value!!, wallet.rekey.value!!, wallet.selected.value!!) + AccountDialogFragment( + wallet.account.value!!, + wallet.rekey.value!!, + wallet.selected.value!! + ) // Ensure the device has notifications enabled - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (!notificationManager.areNotificationsEnabled()) { val notificationsDialogFragment = NotificationsDialogFragment(packageName) if (!notificationsDialogFragment.isVisible) { @@ -179,7 +231,8 @@ class AnswerActivity : AppCompatActivity() { } } // Ensure the device is secure to access FIDO/Passkeys - val keyguardManager = this@AnswerActivity.getSystemService(KEYGUARD_SERVICE) as KeyguardManager + val keyguardManager = + this@AnswerActivity.getSystemService(KEYGUARD_SERVICE) as KeyguardManager if (!keyguardManager.isDeviceSecure) { val settingsDialogFragment = SettingsDialogFragment() if (!settingsDialogFragment.isVisible) { @@ -187,27 +240,39 @@ class AnswerActivity : AppCompatActivity() { } } + val passKeysMnemonicFragment = PassKeysMnemonicDialogFragment() + if (!passKeysMnemonicFragment.isVisible && + derivedSecretRepository.getDerivedParentSecret(this@AnswerActivity) == null + ) { + passKeysMnemonicFragment.show(supportFragmentManager, "MNEMONIC_INPUT") + } + // Load the existing credentials lifecycleScope.launch { db = CredentialDatabase.getInstance(this@AnswerActivity) val credentials = db.credentialDao().getAll() credentials.collect() { credentialList -> Log.d(TAG, "db: $credentialList") - val credArray = credentialList.map { - val user = it.userHandle - val origin = it.origin - "$user@$origin" - } as MutableList + val credArray = + credentialList.map { + val user = it.userHandle + val origin = it.origin + "$user@$origin" + } as + MutableList if (credArray.isEmpty()) { - credArray.add("No Credentials Found, scan a QR code to register a new credential.") + credArray.add( + "No Credentials Found, scan a QR code to register a new credential." + ) } val listView = findViewById(R.id.listView) - val adapter: ArrayAdapter<*> = ArrayAdapter( - this@AnswerActivity, - android.R.layout.simple_list_item_1, - android.R.id.text1, - credArray - ) + val adapter: ArrayAdapter<*> = + ArrayAdapter( + this@AnswerActivity, + android.R.layout.simple_list_item_1, + android.R.id.text1, + credArray + ) listView.adapter = adapter } } @@ -215,24 +280,23 @@ class AnswerActivity : AppCompatActivity() { binding = ActivityAnswerBinding.inflate(layoutInflater) binding.lifecycleOwner = this binding.viewModel = viewModel - binding.connectButton.setOnClickListener { - connect() + binding.connectButton.setOnClickListener { connect() } + binding.showManualAddPassKeyDialogButton.setOnClickListener { + showManualAddPassKeysDialog() } setContentView(binding.root) } override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) - initWebRTCService { - hydrateIntents() - } + initWebRTCService { hydrateIntents() } } - /** - * Reload the application state from an Intent - */ + /** Reload the application state from an Intent */ private fun hydrateIntents() { - val isConnected = signalService?.dataChannel is DataChannel && signalService?.dataChannel?.state() === DataChannel.State.OPEN + val isConnected = + signalService?.dataChannel is DataChannel && + signalService?.dataChannel?.state() === DataChannel.State.OPEN val isIntent = intent != null val isDeepLink = intent?.data != null && intent.data is Uri val isDataChannelMessage = intent?.getStringExtra("msg") != null @@ -251,7 +315,6 @@ class AnswerActivity : AppCompatActivity() { signalService!!.updateLastKnownReferer(appId) } } - } } // Find the Referrer in the Activity @@ -264,16 +327,17 @@ class AnswerActivity : AppCompatActivity() { val msg = AuthMessage.fromUri(intentUri) viewModel.setMessage(msg) signalService?.start( - msg.origin, - httpClient, - notifications.createNotificationBuilder(this@AnswerActivity), - NotificationViewModel.SERVICE_NOTIFICATION_ID, - AnswerActivity::class.java + msg.origin, + httpClient, + notifications.createNotificationBuilder(this@AnswerActivity), + NotificationViewModel.SERVICE_NOTIFICATION_ID, + AnswerActivity::class.java ) // Launch the authentication process lifecycleScope.launch { - val savedCredential = credentialRepository.getCredentialByOrigin(this@AnswerActivity, msg.origin) + val savedCredential = + credentialRepository.getCredentialByOrigin(this@AnswerActivity, msg.origin) if (savedCredential === null) { register(msg) } else { @@ -283,7 +347,7 @@ class AnswerActivity : AppCompatActivity() { } // Handle a datachannel message - if(isDataChannelMessage) { + if (isDataChannelMessage) { val msg = intent.getStringExtra("msg") if (msg !== null) { handleMessages(msg) @@ -301,88 +365,92 @@ class AnswerActivity : AppCompatActivity() { if (mBounded) { return } - notifications.createChannels(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + notifications.createChannels( + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + ) // Handle the Service Connection - mConnection = object : ServiceConnection { - override fun onServiceDisconnected(name: ComponentName) { - mBounded = false - signalService = null - } + mConnection = + object : ServiceConnection { + override fun onServiceDisconnected(name: ComponentName) { + mBounded = false + signalService = null + } - override fun onServiceConnected(name: ComponentName, service: IBinder) { - mBounded = true - val mLocalBinder = service as SignalService.LocalBinder - signalService = mLocalBinder.getServerInstance() - onServiceConnection() - } - } + override fun onServiceConnected(name: ComponentName, service: IBinder) { + mBounded = true + val mLocalBinder = service as SignalService.LocalBinder + signalService = mLocalBinder.getServerInstance() + onServiceConnection() + } + } val startIntent = Intent(this, SignalService::class.java) startService(startIntent) bindService(startIntent, mConnection as ServiceConnection, Context.BIND_AUTO_CREATE) } /** - * Load seed phrases from SharedPreferences - * This is not recommended in production applications, it is just for demonstration purposes. + * Load seed phrases from SharedPreferences This is not recommended in production applications, + * it is just for demonstration purposes. */ private fun hydrateSharedPreferences() { val sharedPref = getSharedPreferences(SHARED_PREFERENCE_SEED_FILE, Context.MODE_PRIVATE) // Load the stored seed phrases - sharedPref.getString("MAIN_ACCOUNT", null)?.let { - wallet.setAccount(Account(it)) - } ?: run { - val account = Account() - sharedPref.edit().putString("MAIN_ACCOUNT", account.toMnemonic()).apply() - wallet.setAccount(account) - } - sharedPref.getString("REKEY_ACCOUNT", null)?.let { - wallet.setRekey(Account(it)) - } ?: run { - val account = Account() - sharedPref.edit().putString("REKEY_ACCOUNT", account.toMnemonic()).apply() - wallet.setRekey(account) - } + sharedPref.getString("MAIN_ACCOUNT", null)?.let { wallet.setAccount(Account(it)) } + ?: run { + val account = Account() + sharedPref.edit().putString("MAIN_ACCOUNT", account.toMnemonic()).apply() + wallet.setAccount(account) + } + sharedPref.getString("REKEY_ACCOUNT", null)?.let { wallet.setRekey(Account(it)) } + ?: run { + val account = Account() + sharedPref.edit().putString("REKEY_ACCOUNT", account.toMnemonic()).apply() + wallet.setRekey(account) + } sharedPref.getString("SELECTED_ACCOUNT", null)?.let { if (wallet.rekey.value!!.address.toString() == it) { wallet.setSelected(wallet.rekey.value!!) } else { wallet.setSelected(wallet.account.value!!) } - } ?: run { - sharedPref.edit().putString("SELECTED_ACCOUNT", wallet.account.value!!.address.toString()).apply() - wallet.setSelected(wallet.account.value!!) } + ?: run { + sharedPref + .edit() + .putString( + "SELECTED_ACCOUNT", + wallet.account.value!!.address.toString() + ) + .apply() + wallet.setSelected(wallet.account.value!!) + } } - /** - * Show the Account Settings Fragment - */ + /** Show the Account Settings Fragment */ private fun toggleAccountDialogFragment() { val fragmentManager = supportFragmentManager val transaction = fragmentManager.beginTransaction() transaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) - transaction - .add(android.R.id.content, accountDialogFragment) - .addToBackStack(null) - .commit() + transaction.add(android.R.id.content, accountDialogFragment).addToBackStack(null).commit() } - /** - * Switch the Activity type - */ + /** Switch the Activity type */ private fun handleSwitchActivity() { val switchIntent = Intent(this, OfferActivity::class.java) switchIntent.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) startActivity(switchIntent) } - /** - * Algorand Specific Rekey - */ + /** Algorand Specific Rekey */ private fun handleRekey() { val result = wallet.algod.AccountInformation(wallet.account.value!!.address).execute() if (!result.isSuccessful) { - Toast.makeText(this@AnswerActivity, "Error getting account information", Toast.LENGTH_LONG).show() + Toast.makeText( + this@AnswerActivity, + "Error getting account information", + Toast.LENGTH_LONG + ) + .show() return } val accountInfo = result.body() @@ -393,39 +461,47 @@ class AnswerActivity : AppCompatActivity() { // Rekey Main Account to the Rekey Account if (wallet.account.value!!.address === wallet.selected.value!!.address) { wallet.rekey(wallet.account.value!!, wallet.rekey.value!!) - Toast.makeText(this@AnswerActivity, "Rekeyed to ${wallet.rekey.value!!.address}", Toast.LENGTH_LONG).show() + Toast.makeText( + this@AnswerActivity, + "Rekeyed to ${wallet.rekey.value!!.address}", + Toast.LENGTH_LONG + ) + .show() // Rekey back to the Main Account from the Rekey Account } else { wallet.rekey(wallet.account.value!!, wallet.account.value!!, wallet.rekey.value!!) Toast.makeText(this@AnswerActivity, "Removed Rekey", Toast.LENGTH_LONG).show() } - getSharedPreferences(SHARED_PREFERENCE_SEED_FILE, Context.MODE_PRIVATE).edit().putString("SELECTED_ACCOUNT", wallet.selected.value!!.address.toString()).apply() + getSharedPreferences(SHARED_PREFERENCE_SEED_FILE, Context.MODE_PRIVATE) + .edit() + .putString("SELECTED_ACCOUNT", wallet.selected.value!!.address.toString()) + .apply() } - /** - * Navigate to the Algorand Dispenser - */ + /** Navigate to the Algorand Dispenser */ private fun handleOpenDispenser() { val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("Address", wallet.account.value!!.address.toString())) - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://bank.testnet.algorand.network")) + clipboard.setPrimaryClip( + ClipData.newPlainText("Address", wallet.account.value!!.address.toString()) + ) + val browserIntent = + Intent(Intent.ACTION_VIEW, Uri.parse("https://bank.testnet.algorand.network")) startActivity(browserIntent) } - /** - * Navigate to the Account Explorer - */ + /** Navigate to the Account Explorer */ private fun handleAccountExplorer() { - val browserIntent = Intent( - Intent.ACTION_VIEW, - Uri.parse("https://testnet.explorer.perawallet.app/address/${wallet.account.value!!.address}") - ) + val browserIntent = + Intent( + Intent.ACTION_VIEW, + Uri.parse( + "https://testnet.explorer.perawallet.app/address/${wallet.account.value!!.address}" + ) + ) startActivity(browserIntent) } - /** - * Handle Menu Options - */ + /** Handle Menu Options */ override fun onOptionsItemSelected(item: MenuItem): Boolean { // Handle item selection. return when (item.itemId) { @@ -433,64 +509,60 @@ class AnswerActivity : AppCompatActivity() { handleSwitchActivity() true } - R.id.rekeyButton -> { handleRekey() true } - R.id.accountButton -> { toggleAccountDialogFragment() true } - R.id.accountExplorerButton -> { handleAccountExplorer() true } - R.id.dispenserButton -> { handleOpenDispenser() true } - else -> super.onOptionsItemSelected(item) } } - /** - * Transaction Biometric Prompt - */ - private suspend fun biometrics(message: SignTransactionsParams): BiometricPrompt.AuthenticationResult? { + /** Transaction Biometric Prompt */ + private suspend fun biometrics( + message: SignTransactionsParams + ): BiometricPrompt.AuthenticationResult? { return suspendCoroutine { continuation -> - var biometricPrompt = BiometricPrompt(this@AnswerActivity, ContextCompat.getMainExecutor(this@AnswerActivity), - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded( - result: BiometricPrompt.AuthenticationResult - ) { - super.onAuthenticationSucceeded(result) - continuation.resume(result) - } + var biometricPrompt = + BiometricPrompt( + this@AnswerActivity, + ContextCompat.getMainExecutor(this@AnswerActivity), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult + ) { + super.onAuthenticationSucceeded(result) + continuation.resume(result) + } - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - continuation.resume(null) - } - }) - promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle("Transaction(s) ${message.txns.size}") - .setSubtitle( - "Provider: ${message.providerId}" - ) - .setNegativeButtonText("Cancel") - .build() + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + continuation.resume(null) + } + } + ) + promptInfo = + BiometricPrompt.PromptInfo.Builder() + .setTitle("Transaction(s) ${message.txns.size}") + .setSubtitle("Provider: ${message.providerId}") + .setNegativeButtonText("Cancel") + .build() biometricPrompt.authenticate(promptInfo) } } - /** - * Decode Unsigned Transaction - */ + /** Decode Unsigned Transaction */ @OptIn(ExperimentalEncodingApi::class) private fun decodeUnsignedTransaction(unsignedTxn: String): Transaction? { return Encoder.decodeFromMsgPack(Base64.decode(unsignedTxn), Transaction::class.java) @@ -507,17 +579,23 @@ class AnswerActivity : AppCompatActivity() { try { val message = Message(Base64.UrlSafe.decode(msgStr), EncoderType.CBOR) val request = provider.encoder.decode(message.data, message.encoding) - if (request.reference == "arc0027:sign_transactions:request"){ + if (request.reference == "arc0027:sign_transactions:request") { lifecycleScope.launch { - val params = provider.encoder.decode( - provider.encoder.encode(request.params, EncoderType.NONE), EncoderType.NONE - ) + val params = + provider.encoder.decode( + provider.encoder.encode(request.params, EncoderType.NONE), + EncoderType.NONE + ) biometrics(params) provider.setKeyPair(keyPair) val resultMessage = provider.handleMessage(message) as ResponseMessage when (resultMessage.result) { is SignTransactionsResult -> { - signalService!!.send(Base64.UrlSafe.encode(resultMessage.toByteArray(EncoderType.CBOR))) + signalService!!.send( + Base64.UrlSafe.encode( + resultMessage.toByteArray(EncoderType.CBOR) + ) + ) } else -> { TODO("Not Implemented") @@ -525,7 +603,6 @@ class AnswerActivity : AppCompatActivity() { } } } - } catch (e: Throwable) { Log.e(TAG, "Error: $e") runOnUiThread { @@ -537,63 +614,74 @@ class AnswerActivity : AppCompatActivity() { /** * Connect/Proof of Knowledge API * - * Connects the Wallet/Android Application to a dApp/website using a Barcode. - * The barcode must use the liquid uri scheme and contain a request id. + * Connects the Wallet/Android Application to a dApp/website using a Barcode. The barcode must + * use the liquid uri scheme and contain a request id. * * liquid:///?requestId= * - * In Android 14, the application can handle the FIDO:/ URI scheme directly. - * This is useful when a user is registering the phone as an Authenticator for the first time. + * In Android 14, the application can handle the FIDO:/ URI scheme directly. This is useful when + * a user is registering the phone as an Authenticator for the first time. */ private fun connect() { - GmsBarcodeScanning.getClient(this@AnswerActivity).startScan() - .addOnSuccessListener { barcode -> - // Handle any scanned FIDO URI directly - if (barcode.displayValue!!.startsWith("FIDO:/")) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(barcode.displayValue))) - } else { - Toast.makeText(this@AnswerActivity, "Android 14 Required", Toast.LENGTH_LONG).show() - } - // Handle Liquid Auth URI - } else { - // Decode Barcode Message - val msg = AuthMessage.fromBarcode(barcode) - viewModel.setMessage(msg) - signalService!!.updateDeepLinkFlag(false) - signalService?.start( - msg.origin, - httpClient, - notifications.createNotificationBuilder(this@AnswerActivity), - NotificationViewModel.SERVICE_NOTIFICATION_ID, - AnswerActivity::class.java, - ) - // Connect to Service - lifecycleScope.launch { - val savedCredential = - credentialRepository.getCredentialByOrigin(this@AnswerActivity, msg.origin) - signalClient = SignalClient(msg.origin, this@AnswerActivity, httpClient) - if (savedCredential === null) { - register(msg) + GmsBarcodeScanning.getClient(this@AnswerActivity) + .startScan() + .addOnSuccessListener { barcode -> + // Handle any scanned FIDO URI directly + if (barcode.displayValue!!.startsWith("FIDO:/")) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(barcode.displayValue)) + ) } else { - authenticate(msg, savedCredential) + Toast.makeText( + this@AnswerActivity, + "Android 14 Required", + Toast.LENGTH_LONG + ) + .show() + } + // Handle Liquid Auth URI + } else { + // Decode Barcode Message + val msg = AuthMessage.fromBarcode(barcode) + viewModel.setMessage(msg) + signalService!!.updateDeepLinkFlag(false) + signalService?.start( + msg.origin, + httpClient, + notifications.createNotificationBuilder(this@AnswerActivity), + NotificationViewModel.SERVICE_NOTIFICATION_ID, + AnswerActivity::class.java, + ) + // Connect to Service + lifecycleScope.launch { + val savedCredential = + credentialRepository.getCredentialByOrigin( + this@AnswerActivity, + msg.origin + ) + signalClient = SignalClient(msg.origin, this@AnswerActivity, httpClient) + if (savedCredential === null) { + register(msg) + } else { + authenticate(msg, savedCredential) + } } } } - } - .addOnCanceledListener { - Toast.makeText(this@AnswerActivity, "Canceled", Toast.LENGTH_LONG).show() - } - .addOnFailureListener { e -> - Toast.makeText(this@AnswerActivity, e.message, Toast.LENGTH_LONG).show() - } + .addOnCanceledListener { + Toast.makeText(this@AnswerActivity, "Canceled", Toast.LENGTH_LONG).show() + } + .addOnFailureListener { e -> + Toast.makeText(this@AnswerActivity, e.message, Toast.LENGTH_LONG).show() + } } /** * Registration of a new Credential (Step 1 of 2) * - * Receives PublicKeyCredentialCreationOptions from the FIDO2 Server and launches - * the authenticator Intent using the handleAuthenticatorAttestationResult Handler + * Receives PublicKeyCredentialCreationOptions from the FIDO2 Server and launches the + * authenticator Intent using the handleAuthenticatorAttestationResult Handler */ private suspend fun register(msg: AuthMessage, options: JSONObject = JSONObject()) { val account = wallet.account.value!! @@ -611,29 +699,26 @@ class AnswerActivity : AppCompatActivity() { // FIDO2 Server API Response for PublicKeyCredentialCreationOptions val response = attestationApi.postAttestationOptions(msg.origin, userAgent, options).await() val session = Cookie.fromResponse(response) - session?.let { - setSession(Cookie.getID(it)) - } + session?.let { setSession(Cookie.getID(it)) } // Convert ResponseBody to FIDO2 PublicKeyCredentialCreationOptions val pubKeyCredentialCreationOptions = response.body!!.toPublicKeyCredentialCreationOptions() // Sign the challenge with the algorand account, this is used in the liquid FIDO2 extension - signature = KeyPairs.rawSignBytes( - pubKeyCredentialCreationOptions.challenge, - KeyPairs.getKeyPair(selected.toMnemonic()).private - ) + signature = + KeyPairs.rawSignBytes( + pubKeyCredentialCreationOptions.challenge, + KeyPairs.getKeyPair(selected.toMnemonic()).private + ) // Kick off FIDO2 Client Intent - val pendingIntent = fido2Client!!.getRegisterPendingIntent(pubKeyCredentialCreationOptions).await() - attestationIntentLauncher.launch( - IntentSenderRequest.Builder(pendingIntent) - .build() - ) + val pendingIntent = + fido2Client!!.getRegisterPendingIntent(pubKeyCredentialCreationOptions).await() + attestationIntentLauncher.launch(IntentSenderRequest.Builder(pendingIntent).build()) } /** * Registration of a New Credential (Step 2 of 2) * - * Handles the ActivityResult from a FIDO2 Intent and submits - * the Authenticator's PublicKeyCredential to the FIDO2 Server + * Handles the ActivityResult from a FIDO2 Intent and submits the Authenticator's + * PublicKeyCredential to the FIDO2 Server */ @OptIn(ExperimentalEncodingApi::class) private fun handleAuthenticatorAttestationResult(activityResult: ActivityResult) { @@ -641,25 +726,32 @@ class AnswerActivity : AppCompatActivity() { when { activityResult.resultCode != Activity.RESULT_OK -> - Toast.makeText(this@AnswerActivity, "Canceled", Toast.LENGTH_LONG).show() - - bytes == null -> - Toast.makeText(this@AnswerActivity, "Error", Toast.LENGTH_LONG) - .show() - + Toast.makeText(this@AnswerActivity, "Canceled", Toast.LENGTH_LONG).show() + bytes == null -> Toast.makeText(this@AnswerActivity, "Error", Toast.LENGTH_LONG).show() else -> { // Handle PublicKeyCredential Response from Authenticator val credential = PublicKeyCredential.deserializeFromBytes(bytes) val response = credential.response if (response is AuthenticatorErrorResponse) { if (response.errorCode === ErrorCode.UNKNOWN_ERR) { - Toast.makeText(this@AnswerActivity, "Something Went Wrong", Toast.LENGTH_LONG).show() + Toast.makeText( + this@AnswerActivity, + "Something Went Wrong", + Toast.LENGTH_LONG + ) + .show() } else { - Toast.makeText(this@AnswerActivity, response.errorMessage, Toast.LENGTH_LONG).show() + Toast.makeText( + this@AnswerActivity, + response.errorMessage, + Toast.LENGTH_LONG + ) + .show() } } else { if (signature === null) { - Toast.makeText(this@AnswerActivity, "Signature is null", Toast.LENGTH_LONG).show() + Toast.makeText(this@AnswerActivity, "Signature is null", Toast.LENGTH_LONG) + .show() return } val msg = viewModel.message.value!! @@ -674,44 +766,59 @@ class AnswerActivity : AppCompatActivity() { lifecycleScope.launch { // POST Authenticator Results to FIDO2 API - attestationApi.postAttestationResult( - msg.origin, - userAgent, - credential, - liquidExtJSON - ).await() - viewModel.saveCredential(this@AnswerActivity, wallet.account.value!!, credential) + attestationApi + .postAttestationResult( + msg.origin, + userAgent, + credential, + liquidExtJSON + ) + .await() + viewModel.saveCredential( + this@AnswerActivity, + wallet.account.value!!, + credential + ) Log.d(TAG, "Credential Saved") if (mBounded) { Log.d(TAG, "Service Bonded") signalService?.peer(msg.requestId, "answer", iceServers) runOnUiThread { - if(signalService!!.isDeepLink) this@AnswerActivity.onBackPressed() + if (signalService!!.isDeepLink) this@AnswerActivity.onBackPressed() } - signalService?.handleMessages(this@AnswerActivity, { peerMsg -> - Log.d(TAG, "handleMessages($peerMsg)") - handleMessages(peerMsg) - },{ - Log.d(TAG, "onStateChange($it)") - if (it === "OPEN") { - Log.d(TAG, "Sending Credential") - signalService?.send( - viewModel.getCredentialMessage( - wallet.account.value!!, - credential - ).toString() - ) - } - }, - notifications.createNotificationBuilder(this@AnswerActivity), - NotificationViewModel.SERVICE_NOTIFICATION_ID, - AnswerActivity::class.java + signalService?.handleMessages( + this@AnswerActivity, + { peerMsg -> + Log.d(TAG, "handleMessages($peerMsg)") + handleMessages(peerMsg) + }, + { + Log.d(TAG, "onStateChange($it)") + if (it === "OPEN") { + Log.d(TAG, "Sending Credential") + signalService?.send( + viewModel + .getCredentialMessage( + wallet.account.value!!, + credential + ) + .toString() + ) + } + }, + notifications.createNotificationBuilder(this@AnswerActivity), + NotificationViewModel.SERVICE_NOTIFICATION_ID, + AnswerActivity::class.java ) } else { - Toast.makeText(this@AnswerActivity, "Couldn't find service", Toast.LENGTH_LONG).show() + Toast.makeText( + this@AnswerActivity, + "Couldn't find service", + Toast.LENGTH_LONG + ) + .show() } } - } } } @@ -720,57 +827,62 @@ class AnswerActivity : AppCompatActivity() { /** * Authentication using a PublicKeyCredential (Step 1 of 2) * - * Receives PublicKeyCredentialRequestOptions from the FIDO2 Server and launches - * the authenticator Intent using the handleAuthenticatorAssertionResult Handler + * Receives PublicKeyCredentialRequestOptions from the FIDO2 Server and launches the + * authenticator Intent using the handleAuthenticatorAssertionResult Handler */ private suspend fun authenticate(msg: AuthMessage, credential: Credential) { - val response = assertionApi.postAssertionOptions( - msg.origin, - userAgent, - credential.credentialId - ).await() + val response = + assertionApi + .postAssertionOptions(msg.origin, userAgent, credential.credentialId) + .await() val session = Cookie.fromResponse(response) - session?.let { - setSession(Cookie.getID(it)) - } - val publicKeyCredentialRequestOptions = response.body!!.toPublicKeyCredentialRequestOptions() - val pendingIntent = fido2Client!!.getSignPendingIntent(publicKeyCredentialRequestOptions).await() + session?.let { setSession(Cookie.getID(it)) } + val publicKeyCredentialRequestOptions = + response.body!!.toPublicKeyCredentialRequestOptions() + val pendingIntent = + fido2Client!!.getSignPendingIntent(publicKeyCredentialRequestOptions).await() assertionIntentLauncher.launch(IntentSenderRequest.Builder(pendingIntent).build()) } /** * Authentication using a PublicKeyCredential (Step 2 of 2) * - * Handles the ActivityResult from a FIDO2 Intent and submits - * the Authenticator's PublicKeyCredential to the FIDO2 Server + * Handles the ActivityResult from a FIDO2 Intent and submits the Authenticator's + * PublicKeyCredential to the FIDO2 Server */ private fun handleAuthenticatorAssertionResult(activityResult: ActivityResult) { val bytes = activityResult.data?.getByteArrayExtra(Fido.FIDO2_KEY_CREDENTIAL_EXTRA) when { activityResult.resultCode != Activity.RESULT_OK -> - Toast.makeText(this@AnswerActivity, "Canceled", Toast.LENGTH_LONG).show() - + Toast.makeText(this@AnswerActivity, "Canceled", Toast.LENGTH_LONG).show() bytes == null -> - Toast.makeText(this@AnswerActivity, "Authenticate Error", Toast.LENGTH_LONG).show() - + Toast.makeText(this@AnswerActivity, "Authenticate Error", Toast.LENGTH_LONG) + .show() else -> { // Handle PublicKeyCredential Response from Authenticator val credential = PublicKeyCredential.deserializeFromBytes(bytes) val pubKeyCredentialResponse = credential.response if (pubKeyCredentialResponse is AuthenticatorErrorResponse) { - Toast.makeText(this@AnswerActivity, pubKeyCredentialResponse.errorMessage, Toast.LENGTH_LONG) - .show() + Toast.makeText( + this@AnswerActivity, + pubKeyCredentialResponse.errorMessage, + Toast.LENGTH_LONG + ) + .show() } else { lifecycleScope.launch { val liquidExtJSON = JSONObject() liquidExtJSON.put("requestId", viewModel.message.value!!.requestId) // POST Authenticator Results to FIDO2 API - val response = assertionApi.postAssertionResult( - viewModel.message.value!!.origin, - userAgent, - credential, - liquidExtJSON - ).await() + val response = + assertionApi + .postAssertionResult( + viewModel.message.value!!.origin, + userAgent, + credential, + liquidExtJSON + ) + .await() // Update Render/State val data = response.body!!.string() @@ -791,38 +903,46 @@ class AnswerActivity : AppCompatActivity() { if (mBounded) { signalService?.peer(msg.requestId, "answer", iceServers) runOnUiThread { - if(signalService!!.isDeepLink) this@AnswerActivity.onBackPressed() + if (signalService!!.isDeepLink) this@AnswerActivity.onBackPressed() } - signalService?.handleMessages(this@AnswerActivity, { peerMsg -> - Log.d(TAG, "handleMessages($peerMsg)") - handleMessages(peerMsg) - },{ - Log.d(TAG, "onStateChange($it)") - if (it === "OPEN") { - Log.d(TAG, "Sending Credential") - signalService?.send( - viewModel.getCredentialMessage( - wallet.account.value!!, - credential - ).toString() - ) - } - }, - notifications.createNotificationBuilder(this@AnswerActivity), - NotificationViewModel.SERVICE_NOTIFICATION_ID, - AnswerActivity::class.java + signalService?.handleMessages( + this@AnswerActivity, + { peerMsg -> + Log.d(TAG, "handleMessages($peerMsg)") + handleMessages(peerMsg) + }, + { + Log.d(TAG, "onStateChange($it)") + if (it === "OPEN") { + Log.d(TAG, "Sending Credential") + signalService?.send( + viewModel + .getCredentialMessage( + wallet.account.value!!, + credential + ) + .toString() + ) + } + }, + notifications.createNotificationBuilder(this@AnswerActivity), + NotificationViewModel.SERVICE_NOTIFICATION_ID, + AnswerActivity::class.java ) } else { - Toast.makeText(this@AnswerActivity, "Couldn't find service", Toast.LENGTH_LONG).show() + Toast.makeText( + this@AnswerActivity, + "Couldn't find service", + Toast.LENGTH_LONG + ) + .show() } } } } } } - /** - * Update Render for demonstration purposes only - */ + /** Update Render for demonstration purposes only */ private fun setSession(s: String?) { if (s === null) { viewModel.setSession("Logged Out") @@ -831,4 +951,10 @@ class AnswerActivity : AppCompatActivity() { viewModel.setSession(s) } } + + /** Show the Manual Add PassKeys Dialog */ + private fun showManualAddPassKeysDialog() { + val dialogFragment = ManualAddPassKeysDialogFragment() + dialogFragment.show(supportFragmentManager, ManualAddPassKeysDialogFragment.TAG) + } } diff --git a/demo/src/main/java/foundation/algorand/demo/credential/CredentialRepository.kt b/demo/src/main/java/foundation/algorand/demo/credential/CredentialRepository.kt index 18cafc3..f0c9c7d 100644 --- a/demo/src/main/java/foundation/algorand/demo/credential/CredentialRepository.kt +++ b/demo/src/main/java/foundation/algorand/demo/credential/CredentialRepository.kt @@ -8,29 +8,38 @@ import androidx.annotation.RequiresApi import androidx.credentials.provider.CallingAppInfo import foundation.algorand.demo.credential.db.Credential import foundation.algorand.demo.credential.db.CredentialDatabase +import foundation.algorand.demo.derivedSecret.DerivedSecretRepository +import foundation.algorand.deterministicP256.DeterministicP256 import java.security.* import java.security.spec.* import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi +// import java.security.interfaces.ECPrivateKey interface CredentialRepository { val keyStore: KeyStore var db: CredentialDatabase suspend fun saveCredential(context: Context, credential: Credential) fun getDatabase(context: Context): CredentialDatabase - fun generateCredentialId(): ByteArray - fun getKeyPair(context: Context): KeyPair - fun getKeyPair(context: Context, credentialId: ByteArray): KeyPair + fun generateCredentialId(keyPair: KeyPair): ByteArray + fun getKeyPair(context: Context, credentialId: ByteArray): KeyPair? + fun createDeterministicKeyPair(context: Context, origin: String, userHandle: String): KeyPair fun appInfoToOrigin(info: CallingAppInfo): String fun getCredential(context: Context, credentialId: ByteArray): Credential? fun getCredentialByOrigin(context: Context, origin: String): Credential? + fun sign(keyPair: KeyPair, payload: ByteArray): ByteArray } + fun CredentialRepository(): CredentialRepository = Repository() -class Repository(): CredentialRepository { + +class Repository() : CredentialRepository { override var keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore") - private var generator: KeyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC) + private var generator: KeyPairGenerator = + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC) + private var dP256: DeterministicP256 = DeterministicP256() override lateinit var db: CredentialDatabase + private val derivedSecretRepository = DerivedSecretRepository() init { keyStore.load(null) } @@ -42,17 +51,27 @@ class Repository(): CredentialRepository { getDatabase(context) db.credentialDao().insertAll(credential) } + override fun getDatabase(context: Context): CredentialDatabase { Log.d(TAG, "getDatabase($context)") - if(!::db.isInitialized) { + if (!::db.isInitialized) { db = CredentialDatabase.getInstance(context) } return db } - override fun generateCredentialId(): ByteArray { + + // CredentialId is deterministically generated from the public key + // Taking SHA-256 hash of the public key, giving us a 32-byte credentialId + override fun generateCredentialId(keyPair: KeyPair): ByteArray { Log.d(TAG, "generateCredentialId()") - val credentialId = ByteArray(32) - SecureRandom().nextBytes(credentialId) + + // Get the public key bytes + val publicKeyBytes = keyPair.public.encoded + + // Compute SHA-256 hash of the public key + val messageDigest = MessageDigest.getInstance("SHA-256") + val credentialId = messageDigest.digest(publicKeyBytes) + return credentialId } @@ -81,18 +100,28 @@ class Repository(): CredentialRepository { } return null } - override fun getKeyPair(context: Context): KeyPair{ - return getKeyPair(context, generateCredentialId()) - } - override fun getKeyPair(context:Context, credentialId: ByteArray): KeyPair { + + override fun getKeyPair(context: Context, credentialId: ByteArray): KeyPair? { Log.d(TAG, "getKeyPair($context, $credentialId)") - val savedKeyPair = getKeyPairFromDatabase(context, credentialId) - if (savedKeyPair != null) { - return savedKeyPair - } - generator.initialize(ECGenParameterSpec("secp256r1")) - return generator.generateKeyPair() + return getKeyPairFromDatabase(context, credentialId) + } + + override fun createDeterministicKeyPair( + context: Context, + origin: String, + userHandle: String + ): KeyPair { + // Note that we take the LOWERCASE of the userHandle, to prevent confusion + Log.d(TAG, "createDeterministicKeyPair($context, , $origin, ${userHandle.lowercase()})") + + val derivedParentSecret = derivedSecretRepository.getDerivedParentSecret(context)?.derivedSecret!!.toByteArray() + return dP256.genDomainSpecificKeypair(derivedParentSecret, origin, userHandle.lowercase()) + } + + override fun sign(keyPair: KeyPair, payload: ByteArray): ByteArray { + return dP256.signWithDomainSpecificKeyPair(keyPair, payload) } + @OptIn(ExperimentalEncodingApi::class) @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) override fun appInfoToOrigin(info: CallingAppInfo): String { diff --git a/demo/src/main/java/foundation/algorand/demo/credential/db/CredentialDao.kt b/demo/src/main/java/foundation/algorand/demo/credential/db/CredentialDao.kt index 5c2ded4..cb32efd 100644 --- a/demo/src/main/java/foundation/algorand/demo/credential/db/CredentialDao.kt +++ b/demo/src/main/java/foundation/algorand/demo/credential/db/CredentialDao.kt @@ -8,10 +8,8 @@ import kotlinx.coroutines.flow.Flow @Dao interface CredentialDao { - @Query("SELECT * FROM credential") - fun getAll(): Flow> - @Query("SELECT * FROM credential") - fun getAllRegular(): List + @Query("SELECT * FROM credential") fun getAll(): Flow> + @Query("SELECT * FROM credential") fun getAllRegular(): List @Query("SELECT * FROM credential WHERE credentialId IN (:credentialIds)") fun loadAllByIds(credentialIds: List): List @Query("SELECT * FROM credential WHERE credentialId LIKE :credentialId LIMIT 1") @@ -21,9 +19,11 @@ interface CredentialDao { @Query("SELECT * FROM credential WHERE userHandle LIKE :userHandle LIMIT 1") fun findByUser(userHandle: String): Credential - @Insert - suspend fun insertAll(vararg credentials: Credential) + @Insert suspend fun insertAll(vararg credentials: Credential) - @Delete - fun delete(credential: Credential) + @Delete fun delete(credential: Credential) + + // FIXME: This should in the future use a specific + // data class for derived master/parent secret + @Insert fun insertAllNoSuspend(vararg credentials: Credential) } diff --git a/demo/src/main/java/foundation/algorand/demo/derivedSecret/db/DerivedSecret.kt b/demo/src/main/java/foundation/algorand/demo/derivedSecret/db/DerivedSecret.kt new file mode 100644 index 0000000..b16ef60 --- /dev/null +++ b/demo/src/main/java/foundation/algorand/demo/derivedSecret/db/DerivedSecret.kt @@ -0,0 +1,30 @@ +package foundation.algorand.demo.derivedSecret.db + +import androidx.room.* +import androidx.room.TypeConverter + +enum class SecretType { + PASSKEY, + SPENDKEY +} + +class Converters { + @TypeConverter + fun fromSecretType(value: SecretType): String { + return value.name + } + + @TypeConverter + fun toSecretType(value: String): SecretType { + return SecretType.valueOf(value) + } +} + +@Entity +data class DerivedSecret( + @PrimaryKey val id: String, + // FIXME: Not secure storage of keys, this is just for demonstration + @ColumnInfo(name = "derivedSecret") val derivedSecret: String, + @ColumnInfo(name = "mnemonic") val mnemonic: String, + @ColumnInfo(name = "type") val type: SecretType +) diff --git a/demo/src/main/java/foundation/algorand/demo/derivedSecret/db/DerivedSecretDao.kt b/demo/src/main/java/foundation/algorand/demo/derivedSecret/db/DerivedSecretDao.kt new file mode 100644 index 0000000..3804432 --- /dev/null +++ b/demo/src/main/java/foundation/algorand/demo/derivedSecret/db/DerivedSecretDao.kt @@ -0,0 +1,24 @@ +package foundation.algorand.demo.derivedSecret.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +@Dao +interface DerivedSecretDao { + @Query("SELECT * FROM derivedSecret") fun getAll(): Flow> + @Query("SELECT * FROM derivedSecret") fun getAllRegular(): List + @Query("SELECT * FROM derivedSecret WHERE id IN (:ids)") + fun loadAllByIds(ids: List): List + @Query("SELECT * FROM derivedSecret WHERE id LIKE :id LIMIT 1") + fun findById(id: String): DerivedSecret? + @Query("SELECT * FROM derivedSecret WHERE type LIKE :secretType LIMIT 1") + fun findBySecretType(secretType: SecretType): DerivedSecret? + + @Insert suspend fun insertAll(vararg derivedSecret: DerivedSecret) + + @Delete fun delete(derivedSecret: DerivedSecret) + + @Insert fun insertAllNoSuspend(vararg derivedSecrets: DerivedSecret) +} diff --git a/demo/src/main/java/foundation/algorand/demo/derivedSecret/db/DerivedSecretDatabase.kt b/demo/src/main/java/foundation/algorand/demo/derivedSecret/db/DerivedSecretDatabase.kt new file mode 100644 index 0000000..48a13ef --- /dev/null +++ b/demo/src/main/java/foundation/algorand/demo/derivedSecret/db/DerivedSecretDatabase.kt @@ -0,0 +1,34 @@ +package foundation.algorand.demo.derivedSecret.db + +import android.content.Context +import android.util.Log +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters + +@Database(entities = [DerivedSecret::class], version = 1) +@TypeConverters(Converters::class) +abstract class DerivedSecretDatabase : RoomDatabase() { + abstract fun derivedSecretDao(): DerivedSecretDao + + companion object { + const val TAG = "DerivedSecretDatabase" + private var INSTANCE: DerivedSecretDatabase? = null + fun getInstance(context: Context): DerivedSecretDatabase { + Log.d(TAG, "getInstance($context)") + if (INSTANCE == null) { + INSTANCE = + Room.databaseBuilder( + context, + DerivedSecretDatabase::class.java, + "derivedSecret" + ) + .allowMainThreadQueries() + .build() + } + + return INSTANCE!! + } + } +} diff --git a/demo/src/main/java/foundation/algorand/demo/derivedSecret/derivedSecretRepository.kt b/demo/src/main/java/foundation/algorand/demo/derivedSecret/derivedSecretRepository.kt new file mode 100644 index 0000000..e882a23 --- /dev/null +++ b/demo/src/main/java/foundation/algorand/demo/derivedSecret/derivedSecretRepository.kt @@ -0,0 +1,75 @@ +package foundation.algorand.demo.derivedSecret + +import android.content.Context +import android.os.Build +import android.security.keystore.KeyProperties +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.credentials.provider.CallingAppInfo +import foundation.algorand.demo.derivedSecret.db.DerivedSecret +import foundation.algorand.demo.derivedSecret.db.DerivedSecretDatabase +import foundation.algorand.deterministicP256.DeterministicP256 +import java.security.* +import java.security.spec.* +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import foundation.algorand.demo.derivedSecret.db.SecretType + +// import java.security.interfaces.ECPrivateKey + +interface DerivedSecretRepository { + val keyStore: KeyStore + var db: DerivedSecretDatabase + fun saveDerivedParentSecret(context: Context, mnemonic: CharArray) + fun getDatabase(context: Context): DerivedSecretDatabase + fun getDerivedParentSecret(context: Context): DerivedSecret? +} + +fun DerivedSecretRepository(): DerivedSecretRepository = Repository() + +class Repository() : DerivedSecretRepository { + override var keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore") + private var generator: KeyPairGenerator = + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC) + private var dP256: DeterministicP256 = DeterministicP256() + override lateinit var db: DerivedSecretDatabase + init { + keyStore.load(null) + } + companion object { + const val TAG = "DerivedSecretRepository" + } + + override fun saveDerivedParentSecret(context: Context, mnemonic: CharArray) { + Log.d(TAG, "saveDerivedParentSecret([mnemonic kept hidden])") + getDatabase(context) + + getDerivedParentSecret(context)?.let { db.derivedSecretDao().delete(it) } + + db.derivedSecretDao() + .insertAllNoSuspend( + DerivedSecret( + id = "derivedParentSecret", + derivedSecret = + dP256.genDerivedMainKeyWithBIP39(mnemonic.concatToString()) + .contentToString(), + type = SecretType.PASSKEY, + mnemonic = mnemonic.concatToString() // We could choose to not store the mnemonic + ) + ) + } + + override fun getDatabase(context: Context): DerivedSecretDatabase { + Log.d(TAG, "getDatabase($context)") + if (!::db.isInitialized) { + db = DerivedSecretDatabase.getInstance(context) + } + return db + } + + override fun getDerivedParentSecret(context: Context): DerivedSecret? { + getDatabase(context) + return db.derivedSecretDao().findById("derivedParentSecret") + } + +} diff --git a/demo/src/main/java/foundation/algorand/demo/headless/CreatePasskeyViewModel.kt b/demo/src/main/java/foundation/algorand/demo/headless/CreatePasskeyViewModel.kt index 310e3ba..20244bf 100644 --- a/demo/src/main/java/foundation/algorand/demo/headless/CreatePasskeyViewModel.kt +++ b/demo/src/main/java/foundation/algorand/demo/headless/CreatePasskeyViewModel.kt @@ -114,16 +114,17 @@ class CreatePasskeyViewModel(): ViewModel() { request.callingRequest as CreatePublicKeyCredentialRequest val requestOptions = PublicKeyCredentialCreationOptions(publicKeyRequest.requestJson) - // Generate a credentialId - val credentialId = credentialRepository.generateCredentialId() - // Generate a credential key pair - val keyPair = credentialRepository.getKeyPair(context, credentialId) - val requestJson = JSONObject(publicKeyRequest.requestJson) val userJson = requestJson.getJSONObject("user") val name = userJson.get("name").toString() val userId = userJson.get("id").toString() + // Generate a key pair + val keyPair = credentialRepository.createDeterministicKeyPair(context, request.callingAppInfo.origin!!, name) + + // Deterministically generate a credentialId + val credentialId = credentialRepository.generateCredentialId(keyPair) + // Save passkey in your database as per your own implementation viewModelScope.launch { credentialRepository.saveCredential( diff --git a/demo/src/main/java/foundation/algorand/demo/headless/GetPasskeyViewModel.kt b/demo/src/main/java/foundation/algorand/demo/headless/GetPasskeyViewModel.kt index 57d260b..86ccc6e 100644 --- a/demo/src/main/java/foundation/algorand/demo/headless/GetPasskeyViewModel.kt +++ b/demo/src/main/java/foundation/algorand/demo/headless/GetPasskeyViewModel.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import foundation.algorand.demo.credential.CredentialRepository import org.json.JSONObject +import java.security.KeyPair import java.security.Signature import java.security.interfaces.ECPrivateKey import kotlin.io.encoding.Base64 @@ -98,13 +99,20 @@ class GetPasskeyViewModel: ViewModel() { packageName = packageName, clientDataHash = clientDataHash!! ) + val keyPair = credentialRepository.getKeyPair(context, credId) + ?: throw Error("No keypair corresponding to that credential id!") + // FIXME:add proper error handling! + + // NOTE: + // We are assuming that a passkey has already been created and stored under the credId + // Ideally we could simply recreate the passkey at this stage, but it is not necessarily + // the case that the authenticating website will pass along the userId. The WebAuthn + // demo website do pass along the userId for the registration phase, but then only the + // credential id in subseqent attempts to authenticate. //TODO: Fix signature issues - val sig = Signature.getInstance("SHA256withECDSA") - sig.initSign(keyPair.private as ECPrivateKey ) - sig.update(response.dataToSign()) - response.signature = sig.sign() + response.signature = credentialRepository.sign(keyPair, response.dataToSign()) val options = request.credentialOptions[0] as GetPublicKeyCredentialOption val json = options.requestJson val requestJson = JSONObject(json) diff --git a/demo/src/main/java/foundation/algorand/demo/settings/AccountDialogFragment.kt b/demo/src/main/java/foundation/algorand/demo/settings/AccountDialogFragment.kt index 8baa749..c563941 100644 --- a/demo/src/main/java/foundation/algorand/demo/settings/AccountDialogFragment.kt +++ b/demo/src/main/java/foundation/algorand/demo/settings/AccountDialogFragment.kt @@ -12,11 +12,12 @@ import foundation.algorand.demo.databinding.FragmentAccountDialogBinding import kotlinx.coroutines.delay import kotlinx.coroutines.launch - -/** - * A simple [DialogFragment] subclass. - */ -class AccountDialogFragment(private val account: Account, private val rekey: Account, private val selected: Account) : DialogFragment() { +/** A simple [DialogFragment] subclass. */ +class AccountDialogFragment( + private val account: Account, + private val rekey: Account, + private val selected: Account +) : DialogFragment() { companion object { const val TAG = "AccountDialogFragment" } @@ -24,26 +25,34 @@ class AccountDialogFragment(private val account: Account, private val rekey: Acc private val wallet: WalletViewModel by activityViewModels() private lateinit var binding: FragmentAccountDialogBinding override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? ): View { binding = FragmentAccountDialogBinding.inflate(inflater, container, false) binding.account = account binding.rekey = rekey binding.selected = selected binding.balance = "loading" - wallet.selected.observe(viewLifecycleOwner) { - binding.selected = it - } + wallet.selected.observe(viewLifecycleOwner) { binding.selected = it } return binding.root } override fun onResume() { lifecycleScope.launch { - binding.balance = wallet.algod.AccountInformation(account.address).execute().body().amount.toString() + binding.balance = + wallet.algod.AccountInformation(account.address) + .execute() + .body() + .amount + .toString() if (binding.balance == "0") { delay(5000) - binding.balance = wallet.algod.AccountInformation(account.address).execute().body().amount.toString() + binding.balance = + wallet.algod.AccountInformation(account.address) + .execute() + .body() + .amount + .toString() } } super.onResume() diff --git a/demo/src/main/java/foundation/algorand/demo/settings/ManualAddPassKeysDialogFragment.kt b/demo/src/main/java/foundation/algorand/demo/settings/ManualAddPassKeysDialogFragment.kt new file mode 100644 index 0000000..b72e818 --- /dev/null +++ b/demo/src/main/java/foundation/algorand/demo/settings/ManualAddPassKeysDialogFragment.kt @@ -0,0 +1,99 @@ +package foundation.algorand.demo.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import androidx.fragment.app.DialogFragment +import foundation.algorand.demo.R +import foundation.algorand.demo.credential.CredentialRepository +import foundation.algorand.demo.credential.db.Credential +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ManualAddPassKeysDialogFragment : DialogFragment() { + + private val credentialRepository = CredentialRepository() + + companion object { + const val TAG = "ManualAddPassKeysDialogFragment" + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + // Inflate the layout for this fragment + return inflater.inflate(R.layout.fragment_manual_add_pass_keys_dialog, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val originInput = view.findViewById(R.id.originInput) + val userHandleInput = view.findViewById(R.id.userHandleInput) + val recreateCredentialButton = view.findViewById