Update Android passkey replace flow (#1685)

This commit is contained in:
Leendert de Borst
2026-02-12 20:31:15 +01:00
committed by Leendert de Borst
parent 10be2d3450
commit d3e641c6e9
3 changed files with 71 additions and 25 deletions

View File

@@ -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<CreateEntry>()
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)

View File

@@ -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,
)

View File

@@ -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())