diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyRegistrationActivity.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyRegistrationActivity.kt index 24d8d84a0..e248e0710 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyRegistrationActivity.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/PasskeyRegistrationActivity.kt @@ -4,7 +4,9 @@ import android.app.Activity import android.content.Intent import android.os.Bundle import android.util.Log +import android.view.LayoutInflater import android.view.View +import android.widget.LinearLayout import android.widget.TextView import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.CreatePublicKeyCredentialResponse @@ -19,11 +21,14 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.aliasvault.app.R import net.aliasvault.app.components.LoadingIndicator +import net.aliasvault.app.vaultstore.PasskeyWithCredentialInfo import net.aliasvault.app.vaultstore.VaultStore import net.aliasvault.app.vaultstore.createCredentialWithPasskey +import net.aliasvault.app.vaultstore.getPasskeysWithCredentialInfo import net.aliasvault.app.vaultstore.models.Passkey import net.aliasvault.app.vaultstore.passkey.PasskeyAuthenticator import net.aliasvault.app.vaultstore.passkey.PasskeyHelper +import net.aliasvault.app.vaultstore.replacePasskey import net.aliasvault.app.webapi.WebApiService import org.json.JSONArray import org.json.JSONObject @@ -36,6 +41,10 @@ import java.util.UUID * Handles passkey registration (credential creation) with a full UI. * Shows a form where the user can edit the display name, then creates and saves the passkey. * Displays loading states and error messages similar to iOS PasskeyRegistrationView. + * + * Supports two modes: + * 1. Selection mode: When existing passkeys are found, shows options to create new or replace existing + * 2. Form mode: Direct passkey creation form (either new or replacing a selected passkey) */ class PasskeyRegistrationActivity : Activity() { @@ -46,18 +55,22 @@ class PasskeyRegistrationActivity : Activity() { private lateinit var vaultStore: VaultStore private lateinit var webApiService: WebApiService - // UI elements - private lateinit var headerSubtitle: TextView - private lateinit var displayNameInput: TextInputEditText - private lateinit var websiteText: TextView - private lateinit var usernameContainer: View - private lateinit var usernameText: TextView - private lateinit var errorText: TextView - private lateinit var saveButton: MaterialButton - private lateinit var cancelButton: MaterialButton - private lateinit var scrollView: View - private lateinit var loadingOverlay: View - private lateinit var loadingIndicator: LoadingIndicator + // UI elements - Form mode + private var headerSubtitle: TextView? = null + private var displayNameInput: TextInputEditText? = null + private var websiteText: TextView? = null + private var usernameContainer: View? = null + private var usernameText: TextView? = null + private var errorText: TextView? = null + private var saveButton: MaterialButton? = null + private var cancelButton: MaterialButton? = null + private var scrollView: View? = null + private var loadingOverlay: View? = null + private var loadingIndicator: LoadingIndicator? = null + + // UI elements - Selection mode + private var createNewButton: MaterialButton? = null + private var existingPasskeysContainer: LinearLayout? = null // Request data private var providerRequest: ProviderCreateCredentialRequest? = null @@ -69,15 +82,16 @@ class PasskeyRegistrationActivity : Activity() { private var userDisplayName: String? = null private var userId: ByteArray? = null + // State + private var existingPasskeys: List = emptyList() + private var selectedPasskeyToReplace: PasskeyWithCredentialInfo? = null + private var isReplaceMode: Boolean = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_passkey_registration) Log.d(TAG, "PasskeyRegistrationActivity onCreate called") - // Initialize UI elements - initializeViews() - try { // Initialize VaultStore and WebApiService vaultStore = VaultStore.getExistingInstance() @@ -90,7 +104,7 @@ class PasskeyRegistrationActivity : Activity() { providerRequest = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) if (providerRequest == null) { Log.e(TAG, "No provider request found in intent") - showError(getString(R.string.passkey_creation_failed)) + finish() return } @@ -100,7 +114,7 @@ class PasskeyRegistrationActivity : Activity() { val createRequest = providerRequest!!.callingRequest if (createRequest !is CreatePublicKeyCredentialRequest) { Log.e(TAG, "Request is not a CreatePublicKeyCredentialRequest") - showError(getString(R.string.passkey_creation_failed)) + finish() return } @@ -130,7 +144,7 @@ class PasskeyRegistrationActivity : Activity() { if (rpId.isEmpty() || requestJson.isEmpty()) { Log.e(TAG, "Missing required parameters") - showError(getString(R.string.passkey_creation_failed)) + finish() return } @@ -146,24 +160,97 @@ class PasskeyRegistrationActivity : Activity() { null } - // Populate UI - populateUI() - - // Set up button listeners - saveButton.setOnClickListener { - onSaveClicked() + // Check for existing passkeys + val db = vaultStore.database + if (db != null) { + existingPasskeys = vaultStore.getPasskeysWithCredentialInfo( + rpId = rpId, + userName = userName, + userId = userId, + db = db, + ) + Log.d(TAG, "Found ${existingPasskeys.size} existing passkeys for rpId=$rpId") } - cancelButton.setOnClickListener { - onCancelClicked() + // Decide which layout to show + if (existingPasskeys.isEmpty()) { + // No existing passkeys - show form directly + showFormView(isReplace = false, passkeyToReplace = null) + } else { + // Existing passkeys found - show selection view + showSelectionView() } } catch (e: Exception) { Log.e(TAG, "Error in onCreate", e) - showError(getString(R.string.passkey_creation_failed)) + finish() } } - private fun initializeViews() { + /** + * Show selection view when there are existing passkeys + */ + private fun showSelectionView() { + setContentView(R.layout.activity_passkey_selection) + + // Initialize selection view elements + val headerSubtitle: TextView = findViewById(R.id.headerSubtitle) + createNewButton = findViewById(R.id.createNewButton) + existingPasskeysContainer = findViewById(R.id.existingPasskeysContainer) + cancelButton = findViewById(R.id.cancelButton) + scrollView = findViewById(R.id.scrollView) + loadingOverlay = findViewById(R.id.loadingOverlay) + loadingIndicator = findViewById(R.id.loadingIndicator) + + // Set subtitle + headerSubtitle.text = "Create a new passkey for $rpId" + + // Set up create new button + createNewButton?.setOnClickListener { + showFormView(isReplace = false, passkeyToReplace = null) + } + + // Populate existing passkeys list + val inflater = LayoutInflater.from(this) + existingPasskeys.forEach { passkeyInfo -> + val itemView = inflater.inflate(R.layout.item_existing_passkey, existingPasskeysContainer, false) + + val displayNameView = itemView.findViewById(R.id.passkeyDisplayName) + val subtitleView = itemView.findViewById(R.id.passkeySubtitle) + + displayNameView.text = passkeyInfo.passkey.displayName + val subtitle = buildString { + passkeyInfo.username?.let { append(it) } + if (passkeyInfo.username != null && passkeyInfo.serviceName != null) { + append(" • ") + } + passkeyInfo.serviceName?.let { append(it) } + } + subtitleView.text = subtitle.ifEmpty { rpId } + + itemView.setOnClickListener { + showFormView(isReplace = true, passkeyToReplace = passkeyInfo) + } + + existingPasskeysContainer?.addView(itemView) + } + + // Set up cancel button + cancelButton?.setOnClickListener { + setResult(RESULT_CANCELED) + finish() + } + } + + /** + * Show form view for creating or replacing a passkey + */ + private fun showFormView(isReplace: Boolean, passkeyToReplace: PasskeyWithCredentialInfo?) { + setContentView(R.layout.activity_passkey_registration) + + this.isReplaceMode = isReplace + this.selectedPasskeyToReplace = passkeyToReplace + + // Initialize form view elements headerSubtitle = findViewById(R.id.headerSubtitle) displayNameInput = findViewById(R.id.displayNameInput) websiteText = findViewById(R.id.websiteText) @@ -175,75 +262,92 @@ class PasskeyRegistrationActivity : Activity() { scrollView = findViewById(R.id.scrollView) loadingOverlay = findViewById(R.id.loadingOverlay) loadingIndicator = findViewById(R.id.loadingIndicator) - } - private fun populateUI() { - // Set subtitle - headerSubtitle.text = "Create a new passkey for $rpId" - - // Set display name (default to rpId) - displayNameInput.setText(rpId) + // Update UI based on mode + if (isReplace && passkeyToReplace != null) { + headerSubtitle?.text = "Replace passkey for $rpId" + displayNameInput?.setText(passkeyToReplace.passkey.displayName) + saveButton?.text = getString(R.string.passkey_replace_button) + } else { + headerSubtitle?.text = "Create a new passkey for $rpId" + displayNameInput?.setText(rpId) + saveButton?.text = getString(R.string.passkey_create_button) + } // Set website - websiteText.text = rpId + websiteText?.text = rpId // Set username if available if (!userName.isNullOrEmpty()) { - usernameContainer.visibility = View.VISIBLE - usernameText.text = userName + usernameContainer?.visibility = View.VISIBLE + usernameText?.text = userName } else { - usernameContainer.visibility = View.GONE + usernameContainer?.visibility = View.GONE + } + + // Set up button listeners + saveButton?.setOnClickListener { + onSaveClicked() + } + + cancelButton?.setOnClickListener { + if (existingPasskeys.isNotEmpty()) { + // Go back to selection view + showSelectionView() + } else { + setResult(RESULT_CANCELED) + finish() + } } } private fun onSaveClicked() { // Validate display name - val displayName = displayNameInput.text.toString().trim() + val displayName = displayNameInput?.text.toString().trim() if (displayName.isEmpty()) { - errorText.text = getString(R.string.passkey_error_empty_name) - errorText.visibility = View.VISIBLE + errorText?.text = getString(R.string.passkey_error_empty_name) + errorText?.visibility = View.VISIBLE return } // Hide error and start creation - errorText.visibility = View.GONE + errorText?.visibility = View.GONE // Start passkey creation in coroutine CoroutineScope(Dispatchers.Main).launch { - createPasskey(displayName) + if (isReplaceMode && selectedPasskeyToReplace != null) { + replacePasskeyFlow(displayName, selectedPasskeyToReplace!!) + } else { + createPasskeyFlow(displayName) + } } } - private fun onCancelClicked() { - setResult(RESULT_CANCELED) - finish() - } - private fun showLoading(message: String) { - loadingIndicator.setMessage(message) - loadingIndicator.startAnimation() - loadingOverlay.visibility = View.VISIBLE - scrollView.alpha = 0.3f - scrollView.isEnabled = false + loadingIndicator?.setMessage(message) + loadingIndicator?.startAnimation() + loadingOverlay?.visibility = View.VISIBLE + scrollView?.alpha = 0.3f + scrollView?.isEnabled = false } private fun hideLoading() { - loadingIndicator.stopAnimation() - loadingOverlay.visibility = View.GONE - scrollView.alpha = 1.0f - scrollView.isEnabled = true + loadingIndicator?.stopAnimation() + loadingOverlay?.visibility = View.GONE + scrollView?.alpha = 1.0f + scrollView?.isEnabled = true } private fun showError(message: String) { hideLoading() - errorText.text = message - errorText.visibility = View.VISIBLE + errorText?.text = message + errorText?.visibility = View.VISIBLE } /** - * Create the passkey + * Create a new passkey flow */ - private suspend fun createPasskey(displayName: String) = withContext(Dispatchers.IO) { + private suspend fun createPasskeyFlow(displayName: String) = withContext(Dispatchers.IO) { try { withContext(Dispatchers.Main) { showLoading(getString(R.string.passkey_creating)) @@ -433,6 +537,206 @@ class PasskeyRegistrationActivity : Activity() { } } + /** + * Replace an existing passkey flow + */ + private suspend fun replacePasskeyFlow(displayName: String, passkeyToReplace: PasskeyWithCredentialInfo) = withContext(Dispatchers.IO) { + try { + withContext(Dispatchers.Main) { + showLoading(getString(R.string.passkey_replacing)) + } + + Log.d(TAG, "Replacing passkey ${passkeyToReplace.passkey.id} for RP: $rpId") + + // Extract favicon (optional) + var logo: ByteArray? = null + try { + logo = webApiService.extractFavicon("https://$rpId") + } catch (e: Exception) { + Log.w(TAG, "Favicon extraction failed", e) + // Continue without logo + } + + // Generate new passkey credentials + val newPasskeyId = UUID.randomUUID() + val credentialId = PasskeyHelper.guidToBytes(newPasskeyId.toString()) + + // Use clientDataHash from the request + val requestClientDataHash = this@PasskeyRegistrationActivity.clientDataHash + if (requestClientDataHash == null) { + throw Exception("Client data hash not available") + } + + // Parse request to get challenge + val requestObj = JSONObject(requestJson) + val challenge = requestObj.optString("challenge", "") + + // Use origin from the request + val requestOrigin = this@PasskeyRegistrationActivity.origin + if (requestOrigin == null) { + throw Exception("Origin not available") + } + + // Extract PRF inputs if present + val prfInputs = extractPrfInputs(requestObj) + val enablePrf = prfInputs != null + + // Create the new passkey using PasskeyAuthenticator + val passkeyResult = PasskeyAuthenticator.createPasskey( + credentialId = credentialId, + clientDataHash = requestClientDataHash, + rpId = rpId, + userId = userId, + userName = userName, + userDisplayName = userDisplayName, + uvPerformed = true, + enablePrf = enablePrf, + prfInputs = prfInputs, + ) + + // Create new Passkey model object + val now = Date() + val newPasskey = Passkey( + id = newPasskeyId, + parentCredentialId = passkeyToReplace.passkey.parentCredentialId, + rpId = rpId, + userHandle = userId, + userName = userName, + publicKey = passkeyResult.publicKey, + privateKey = passkeyResult.privateKey, + prfKey = passkeyResult.prfSecret, + displayName = displayName, + createdAt = now, + updatedAt = now, + isDeleted = false, + ) + + // Replace in database + withContext(Dispatchers.Main) { + showLoading(getString(R.string.passkey_saving)) + } + + val db = vaultStore.database ?: throw Exception("Vault not unlocked") + db.beginTransaction() + try { + vaultStore.replacePasskey( + oldPasskeyId = passkeyToReplace.passkey.id, + newPasskey = newPasskey, + displayName = displayName, + logo = logo, + db = db, + ) + + // Commit transaction and persist to encrypted vault file + vaultStore.commitTransaction() + + Log.d(TAG, "Passkey replaced successfully") + } catch (e: Exception) { + db.endTransaction() + throw e + } + + // Upload vault changes to server + withContext(Dispatchers.Main) { + showLoading(getString(R.string.passkey_syncing)) + } + + try { + vaultStore.mutateVault(webApiService) + } catch (e: Exception) { + Log.w(TAG, "Vault mutation failed, but passkey was replaced locally", e) + withContext(Dispatchers.Main) { + showError("Saved locally, but sync failed: ${e.message}") + delay(2000) + } + } + + // Build response (same as create flow) + val credentialIdB64 = base64urlEncode(credentialId) + val attestationObjectB64 = base64urlEncode(passkeyResult.attestationObject) + + val clientDataJson = + """{"type":"webauthn.create","challenge":"$challenge","origin":"$requestOrigin","crossOrigin":false}""" + val clientDataJsonB64 = base64urlEncode(clientDataJson.toByteArray(Charsets.UTF_8)) + + val responseJson = JSONObject().apply { + put("id", credentialIdB64) + put("rawId", credentialIdB64) + put("type", "public-key") + put("authenticatorAttachment", "platform") + put( + "response", + JSONObject().apply { + put("clientDataJSON", clientDataJsonB64) + put("attestationObject", attestationObjectB64) + put("authenticatorData", base64urlEncode(passkeyResult.authenticatorData)) + put( + "transports", + org.json.JSONArray().apply { + put("internal") + }, + ) + put("publicKey", base64urlEncode(passkeyResult.publicKeyDER)) + put("publicKeyAlgorithm", -7) + }, + ) + + // Add PRF extension results if present + val prfResults = if (enablePrf) passkeyResult.prfResults else null + if (prfResults != null) { + put( + "clientExtensionResults", + JSONObject().apply { + put( + "prf", + JSONObject().apply { + put("enabled", true) + put( + "results", + JSONObject().apply { + put("first", base64urlEncode(prfResults.first)) + prfResults.second?.let { + put("second", base64urlEncode(it)) + } + }, + ) + }, + ) + }, + ) + } else { + put("clientExtensionResults", JSONObject()) + } + } + + Log.d(TAG, "Response JSON: ${responseJson.toString(2)}") + + val response = CreatePublicKeyCredentialResponse(responseJson.toString()) + + withContext(Dispatchers.Main) { + hideLoading() + val resultIntent = Intent() + try { + Log.d(TAG, "Setting credential response...") + PendingIntentHandler.setCreateCredentialResponse(resultIntent, response) + Log.d(TAG, "Credential response set successfully") + + setResult(RESULT_OK, resultIntent) + Log.d(TAG, "Result set to RESULT_OK, finishing activity") + finish() + } catch (e: Exception) { + Log.e(TAG, "Error setting credential response", e) + showError(getString(R.string.passkey_creation_failed) + ": ${e.message}") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error replacing passkey", e) + withContext(Dispatchers.Main) { + showError(getString(R.string.passkey_creation_failed) + ": ${e.message}") + } + } + } + /** * Extract PRF extension inputs from request */ diff --git a/apps/mobile-app/android/app/src/main/res/drawable/passkey_item_background.xml b/apps/mobile-app/android/app/src/main/res/drawable/passkey_item_background.xml new file mode 100644 index 000000000..d685a0ee1 --- /dev/null +++ b/apps/mobile-app/android/app/src/main/res/drawable/passkey_item_background.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/apps/mobile-app/android/app/src/main/res/layout/activity_passkey_selection.xml b/apps/mobile-app/android/app/src/main/res/layout/activity_passkey_selection.xml new file mode 100644 index 000000000..50f8355c4 --- /dev/null +++ b/apps/mobile-app/android/app/src/main/res/layout/activity_passkey_selection.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile-app/android/app/src/main/res/layout/item_existing_passkey.xml b/apps/mobile-app/android/app/src/main/res/layout/item_existing_passkey.xml new file mode 100644 index 000000000..a3b97de11 --- /dev/null +++ b/apps/mobile-app/android/app/src/main/res/layout/item_existing_passkey.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/mobile-app/android/app/src/main/res/values-night/colors.xml b/apps/mobile-app/android/app/src/main/res/values-night/colors.xml index bda9e050c..820791460 100644 --- a/apps/mobile-app/android/app/src/main/res/values-night/colors.xml +++ b/apps/mobile-app/android/app/src/main/res/values-night/colors.xml @@ -6,6 +6,7 @@ #202020 #444444 #f49541 + #33f49541 #6b7280 #eabf69 #9BA1A6 diff --git a/apps/mobile-app/android/app/src/main/res/values/colors.xml b/apps/mobile-app/android/app/src/main/res/values/colors.xml index 1257607f0..e66a4c272 100644 --- a/apps/mobile-app/android/app/src/main/res/values/colors.xml +++ b/apps/mobile-app/android/app/src/main/res/values/colors.xml @@ -24,6 +24,7 @@ #ffffff #d1d5db #f49541 + #33f49541 #6b7280 #eabf69 #687076 diff --git a/apps/mobile-app/android/app/src/main/res/values/strings.xml b/apps/mobile-app/android/app/src/main/res/values/strings.xml index af34ce05f..31d5e6763 100644 --- a/apps/mobile-app/android/app/src/main/res/values/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values/strings.xml @@ -35,4 +35,10 @@ Retry Info icon Create a passkey to securely sign in without passwords + Create New Passkey + Select passkey to replace + Replace Passkey + Replace Passkey + Replacing this passkey will delete the old one and create a new one + Replacing passkey… \ No newline at end of file