From d3e641c6e9543937cfef3abb1716f8ef15ab3c3c Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Thu, 12 Feb 2026 20:31:15 +0100 Subject: [PATCH] Update Android passkey replace flow (#1685) --- .../AliasVaultCredentialProviderService.kt | 37 ++++++++++--------- .../PasskeyRegistrationActivity.kt | 32 ++++++++++++++-- .../repositories/PasskeyRepository.kt | 27 ++++++++++++-- 3 files changed, 71 insertions(+), 25 deletions(-) diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/AliasVaultCredentialProviderService.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/AliasVaultCredentialProviderService.kt index aadd527db..8cc3aeeda 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/AliasVaultCredentialProviderService.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/credentialprovider/AliasVaultCredentialProviderService.kt @@ -223,8 +223,8 @@ class AliasVaultCredentialProviderService : CredentialProviderService() { // Extract RP info val rpObj = requestObj.optJSONObject("rp") - val rpId = rpObj?.optString("id") ?: "" - val rpName = rpObj?.optString("name") ?: rpId + val rpId = rpObj?.optString("id")?.takeIf { it.isNotEmpty() } ?: "" + val rpName = rpObj?.optString("name")?.takeIf { it.isNotEmpty() } ?: rpId // Extract user info val userObj = requestObj.optJSONObject("user") @@ -233,23 +233,26 @@ class AliasVaultCredentialProviderService : CredentialProviderService() { val createEntries = mutableListOf() - if (rpId.isNotEmpty()) { - // Create entry for saving passkey to AliasVault - // Using rpName or userDisplayName as the account name - val accountName = if (userDisplayName.isNotEmpty()) { - "$userDisplayName@$rpName" - } else { - rpName - } - - val entry = CreateEntry( - accountName = accountName, - pendingIntent = createNewPendingIntent(rpId), - ) - - createEntries.add(entry) + /* + * Always create an entry for AliasVault, even if rpId is empty. + * Per WebAuthn spec, rpId defaults to the origin's effective domain when not provided. + * The actual rpId will be derived from the verified origin during registration. + * Reference: PasskeyRegistrationActivity.kt derives rpId from origin if empty. + */ + val displayRpName = rpName.ifEmpty { "Passkey" } + val accountName = if (userDisplayName.isNotEmpty()) { + "$userDisplayName@$displayRpName" + } else { + displayRpName } + val entry = CreateEntry( + accountName = accountName, + pendingIntent = createNewPendingIntent(rpId.ifEmpty { "passkey-create" }), + ) + + createEntries.add(entry) + return BeginCreateCredentialResponse(createEntries) } catch (e: Exception) { Log.e(TAG, "Error handling passkey create query", e) 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 f6d85c812..3ed750ea5 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 @@ -22,6 +22,7 @@ import net.aliasvault.app.vaultstore.VaultStore import net.aliasvault.app.vaultstore.keystoreprovider.AndroidKeystoreProvider import net.aliasvault.app.vaultstore.storageprovider.AndroidStorageProvider import org.json.JSONObject +import java.net.URL /** * PasskeyRegistrationActivity @@ -80,17 +81,24 @@ class PasskeyRegistrationActivity : FragmentActivity() { // Extract RP info val rpObj = requestObj.optJSONObject("rp") - viewModel.rpId = rpObj?.optString("id") ?: "" viewModel.rpName = rpObj?.optString("name")?.takeIf { it.isNotEmpty() } + /* + * Derive rpId: use explicit rp.id if provided, otherwise fall back to origin hostname. + * This matches WebAuthn spec behavior where rpId defaults to the origin's effective domain. + * Reference: browser extension PasskeyAuthenticator.ts and PasskeyCreate.tsx + */ + val explicitRpId = rpObj?.optString("id")?.takeIf { it.isNotEmpty() } + viewModel.rpId = explicitRpId ?: "" + // Extract user info val userObj = requestObj.optJSONObject("user") viewModel.userName = userObj?.optString("name")?.takeIf { it.isNotEmpty() } viewModel.userDisplayName = userObj?.optString("displayName")?.takeIf { it.isNotEmpty() } val userIdB64 = userObj?.optString("id") - if (viewModel.rpId.isEmpty() || viewModel.requestJson.isEmpty()) { - Log.e(TAG, "Missing required parameters") + if (viewModel.requestJson.isEmpty()) { + Log.e(TAG, "Missing required parameters: requestJson is empty") finish() return } @@ -171,6 +179,23 @@ class PasskeyRegistrationActivity : FragmentActivity() { viewModel.isPrivilegedCaller = originResult.isPrivileged Log.d(TAG, "Origin verified: ${originResult.origin} (privileged: ${originResult.isPrivileged})") + /* + * If rpId was not provided in the request, derive it from the verified origin. + * This matches WebAuthn spec behavior where rpId defaults to origin's effective domain. + * Reference: browser extension PasskeyAuthenticator.ts and PasskeyCreate.tsx + */ + if (viewModel.rpId.isEmpty() && originResult.isPrivileged) { + try { + val originUrl = URL(originResult.origin) + viewModel.rpId = originUrl.host + Log.d(TAG, "Derived rpId from origin: ${viewModel.rpId}") + } catch (e: Exception) { + Log.e(TAG, "Failed to derive rpId from origin", e) + showError("Invalid origin URL") + return@launch + } + } + // Initialize unlock coordinator unlockCoordinator = UnlockCoordinator( activity = this@PasskeyRegistrationActivity, @@ -216,7 +241,6 @@ class PasskeyRegistrationActivity : FragmentActivity() { // Get existing passkeys for the rpId (can be replaced) viewModel.existingPasskeys = vaultStore.getPasskeysWithCredentialInfo( rpId = viewModel.rpId, - userName = viewModel.userName, userId = viewModel.userId, ) diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/PasskeyRepository.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/PasskeyRepository.kt index ed0ce3a1e..f6c9a41b2 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/PasskeyRepository.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/repositories/PasskeyRepository.kt @@ -285,7 +285,6 @@ class PasskeyRepository(database: VaultDatabase) : BaseRepository(database) { * @param displayName The updated display name. * @param logo The updated logo bytes (optional). */ - @Suppress("UNUSED_PARAMETER") // Logo update not yet implemented fun replace( oldPasskeyId: UUID, newPasskey: Passkey, @@ -304,11 +303,31 @@ class PasskeyRepository(database: VaultDatabase) : BaseRepository(database) { // Update the item's name executeUpdate( - "UPDATE Items SET Name = ?, UpdatedAt = ? WHERE Id = ?", - arrayOf(displayName, timestamp, itemId.toString().uppercase()), + "UPDATE Items SET UpdatedAt = ? WHERE Id = ?", + arrayOf(timestamp, itemId.toString().uppercase()), ) - // TODO: Update logo if provided + if (logo != null) { + val source = newPasskey.rpId.lowercase().replace("www.", "") + val itemResults = executeQuery( + "SELECT LogoId FROM Items WHERE Id = ?", + arrayOf(itemId.toString().uppercase()), + ) + val existingLogoId = itemResults.firstOrNull()?.get("LogoId") as? String + + if (existingLogoId != null) { + executeUpdate( + "UPDATE Logos SET FileData = ?, UpdatedAt = ? WHERE Id = ?", + arrayOf(logo, timestamp, existingLogoId), + ) + } else { + val newLogoId = getOrCreateLogo(source, logo, timestamp) + executeUpdate( + "UPDATE Items SET LogoId = ?, UpdatedAt = ? WHERE Id = ?", + arrayOf(newLogoId, timestamp, itemId.toString().uppercase()), + ) + } + } // Soft delete the old passkey softDelete("Passkeys", oldPasskeyId.toString().uppercase())