Add passkey replace flow (#520)

This commit is contained in:
Leendert de Borst
2025-10-21 16:13:10 +02:00
parent 020f11d3a4
commit 219bc88e30
7 changed files with 574 additions and 63 deletions

View File

@@ -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<PasskeyWithCredentialInfo> = 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<TextView>(R.id.passkeyDisplayName)
val subtitleView = itemView.findViewById<TextView>(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
*/

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/av_primary_ripple">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/av_accent_background" />
<corners android:radius="8dp" />
<stroke
android:width="1dp"
android:color="@color/av_accent_border" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/av_background"
android:fitsSystemWindows="true">
<!-- Main content -->
<ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:fitsSystemWindows="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp"
android:paddingBottom="80dp">
<!-- Logo -->
<ImageView
android:id="@+id/logoImage"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="20dp"
android:layout_marginBottom="12dp"
android:src="@drawable/av_logo"
android:contentDescription="@string/app_name" />
<!-- Header -->
<TextView
android:id="@+id/headerTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/passkey_registration_title"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="@color/av_text"
android:gravity="center"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/headerSubtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="@color/av_text_muted"
android:gravity="center"
android:layout_marginBottom="24dp" />
<!-- Create new button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/createNewButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/passkey_create_new_button"
android:textColor="#FFFFFF"
android:backgroundTint="@color/av_primary"
android:layout_marginBottom="36dp"
app:cornerRadius="8dp"
app:icon="@android:drawable/ic_lock_lock"
app:iconTint="#FFFFFF"
app:iconGravity="textStart"
android:paddingVertical="14dp"
android:textSize="16sp" />
<!-- Separator with text -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/passkey_select_to_replace"
android:textSize="14sp"
android:textColor="@color/av_text"
android:textStyle="bold"
android:layout_marginBottom="12dp"
android:paddingStart="4dp"
android:paddingEnd="4dp" />
<!-- Existing passkeys list container -->
<LinearLayout
android:id="@+id/existingPasskeysContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="24dp">
<!-- Passkey items will be added programmatically -->
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<!-- Cancel button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/cancelButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/passkey_cancel_button"
android:textColor="@color/av_primary"
style="@style/Widget.Material3.Button.TextButton"
android:paddingVertical="14dp"
android:textSize="16sp" />
</LinearLayout>
</ScrollView>
<!-- Loading overlay -->
<FrameLayout
android:id="@+id/loadingOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#B3000000"
android:visibility="gone"
android:clickable="true"
android:focusable="true">
<net.aliasvault.app.components.LoadingIndicator
android:id="@+id/loadingIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>
</FrameLayout>

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@drawable/passkey_item_background"
android:clickable="true"
android:focusable="true"
android:padding="16dp"
android:gravity="center_vertical">
<!-- Icon -->
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@android:drawable/ic_lock_lock"
android:tint="@color/av_primary"
android:layout_marginEnd="12dp"
android:contentDescription="@string/passkey_info_icon" />
<!-- Content -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/passkeyDisplayName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="@color/av_text"
android:textStyle="bold"
android:ellipsize="end"
android:maxLines="1" />
<TextView
android:id="@+id/passkeySubtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="@color/av_text_muted"
android:ellipsize="end"
android:maxLines="1"
android:layout_marginTop="2dp" />
</LinearLayout>
<!-- Arrow icon -->
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@android:drawable/ic_menu_more"
android:tint="@color/av_text_muted"
android:layout_marginStart="8dp"
android:contentDescription="@string/passkey_info_icon" />
</LinearLayout>

View File

@@ -6,6 +6,7 @@
<color name="av_accent_background">#202020</color>
<color name="av_accent_border">#444444</color>
<color name="av_primary">#f49541</color>
<color name="av_primary_ripple">#33f49541</color>
<color name="av_secondary">#6b7280</color>
<color name="av_tertiary">#eabf69</color>
<color name="av_icon">#9BA1A6</color>

View File

@@ -24,6 +24,7 @@
<color name="av_accent_background">#ffffff</color>
<color name="av_accent_border">#d1d5db</color>
<color name="av_primary">#f49541</color>
<color name="av_primary_ripple">#33f49541</color>
<color name="av_secondary">#6b7280</color>
<color name="av_tertiary">#eabf69</color>
<color name="av_icon">#687076</color>

View File

@@ -35,4 +35,10 @@
<string name="passkey_retry_button">Retry</string>
<string name="passkey_info_icon">Info icon</string>
<string name="passkey_create_explanation">Create a passkey to securely sign in without passwords</string>
<string name="passkey_create_new_button">Create New Passkey</string>
<string name="passkey_select_to_replace">Select passkey to replace</string>
<string name="passkey_replace_title">Replace Passkey</string>
<string name="passkey_replace_button">Replace Passkey</string>
<string name="passkey_replace_explanation">Replacing this passkey will delete the old one and create a new one</string>
<string name="passkey_replacing">Replacing passkey…</string>
</resources>