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