Update passkey auth and register flow (#1404)
This commit is contained in:
+244
-17
@@ -44,15 +44,21 @@ class PasskeyFormFragment : Fragment() {
|
||||
private const val TAG = "PasskeyFormFragment"
|
||||
private const val ARG_IS_REPLACE = "is_replace"
|
||||
private const val ARG_PASSKEY_ID = "passkey_id"
|
||||
private const val ARG_ITEM_ID = "item_id"
|
||||
|
||||
/**
|
||||
* Create a new instance of PasskeyFormFragment.
|
||||
*
|
||||
* @param isReplace Whether this is a passkey replacement operation.
|
||||
* @param passkeyId The ID of the passkey to replace (if isReplace is true).
|
||||
* @param itemId The ID of the existing Item to merge passkey into (if merging).
|
||||
*/
|
||||
fun newInstance(isReplace: Boolean, passkeyId: String?): PasskeyFormFragment {
|
||||
fun newInstance(isReplace: Boolean, passkeyId: String?, itemId: String? = null): PasskeyFormFragment {
|
||||
return PasskeyFormFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putBoolean(ARG_IS_REPLACE, isReplace)
|
||||
passkeyId?.let { putString(ARG_PASSKEY_ID, it) }
|
||||
itemId?.let { putString(ARG_ITEM_ID, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +69,9 @@ class PasskeyFormFragment : Fragment() {
|
||||
private lateinit var webApiService: WebApiService
|
||||
|
||||
private var isReplace: Boolean = false
|
||||
private var isMerge: Boolean = false
|
||||
private var passkeyToReplace: PasskeyWithCredentialInfo? = null
|
||||
private var itemToMerge: net.aliasvault.app.vaultstore.ItemWithCredentialInfo? = null
|
||||
|
||||
// UI elements
|
||||
private lateinit var headerSubtitle: TextView
|
||||
@@ -83,10 +91,17 @@ class PasskeyFormFragment : Fragment() {
|
||||
super.onCreate(savedInstanceState)
|
||||
isReplace = arguments?.getBoolean(ARG_IS_REPLACE) ?: false
|
||||
val passkeyId = arguments?.getString(ARG_PASSKEY_ID)
|
||||
val itemId = arguments?.getString(ARG_ITEM_ID)
|
||||
|
||||
passkeyToReplace = passkeyId?.let {
|
||||
viewModel.getPasskeyById(UUID.fromString(it))
|
||||
}
|
||||
|
||||
itemToMerge = itemId?.let {
|
||||
viewModel.getItemById(UUID.fromString(it))
|
||||
}
|
||||
isMerge = itemToMerge != null
|
||||
|
||||
// Initialize services
|
||||
vaultStore = VaultStore.getExistingInstance()
|
||||
?: throw VaultOperationException("VaultStore not initialized")
|
||||
@@ -120,18 +135,29 @@ class PasskeyFormFragment : Fragment() {
|
||||
loadingIndicator = view.findViewById(R.id.loadingIndicator)
|
||||
|
||||
// Update UI based on mode
|
||||
if (isReplace && passkeyToReplace != null) {
|
||||
headerTitle.text = getString(R.string.replace_passkey_title)
|
||||
headerSubtitle.visibility = View.GONE
|
||||
infoExplanationText.text = getString(R.string.passkey_replace_explanation)
|
||||
displayNameInput.setText(passkeyToReplace?.passkey?.displayName)
|
||||
saveButton.text = getString(R.string.passkey_replace_button)
|
||||
} else {
|
||||
headerTitle.text = getString(R.string.create_passkey_title)
|
||||
headerSubtitle.visibility = View.GONE
|
||||
infoExplanationText.text = getString(R.string.passkey_create_explanation)
|
||||
displayNameInput.setText(viewModel.rpName ?: viewModel.rpId)
|
||||
saveButton.text = getString(R.string.passkey_create_button)
|
||||
when {
|
||||
isReplace && passkeyToReplace != null -> {
|
||||
headerTitle.text = getString(R.string.replace_passkey)
|
||||
headerSubtitle.visibility = View.GONE
|
||||
infoExplanationText.text = getString(R.string.passkey_replace_explanation)
|
||||
displayNameInput.setText(passkeyToReplace?.passkey?.displayName)
|
||||
saveButton.text = getString(R.string.replace_passkey)
|
||||
}
|
||||
isMerge && itemToMerge != null -> {
|
||||
headerTitle.text = getString(R.string.add_passkey)
|
||||
headerSubtitle.visibility = View.VISIBLE
|
||||
headerSubtitle.text = getString(R.string.add_passkey_subtitle)
|
||||
infoExplanationText.text = getString(R.string.passkey_merge_explanation)
|
||||
displayNameInput.setText(itemToMerge?.serviceName ?: viewModel.rpName ?: viewModel.rpId)
|
||||
saveButton.text = getString(R.string.add_passkey)
|
||||
}
|
||||
else -> {
|
||||
headerTitle.text = getString(R.string.create_passkey_title)
|
||||
headerSubtitle.visibility = View.GONE
|
||||
infoExplanationText.text = getString(R.string.passkey_create_explanation)
|
||||
displayNameInput.setText(viewModel.rpName ?: viewModel.rpId)
|
||||
saveButton.text = getString(R.string.passkey_create_button)
|
||||
}
|
||||
}
|
||||
|
||||
// Set website
|
||||
@@ -175,10 +201,16 @@ class PasskeyFormFragment : Fragment() {
|
||||
|
||||
// Start passkey creation in coroutine
|
||||
lifecycleScope.launch {
|
||||
if (isReplace && passkeyToReplace != null) {
|
||||
replacePasskeyFlow(displayName, passkeyToReplace!!)
|
||||
} else {
|
||||
createPasskeyFlow(displayName)
|
||||
when {
|
||||
isReplace && passkeyToReplace != null -> {
|
||||
replacePasskeyFlow(displayName, passkeyToReplace!!)
|
||||
}
|
||||
isMerge && itemToMerge != null -> {
|
||||
mergePasskeyFlow(displayName, itemToMerge!!)
|
||||
}
|
||||
else -> {
|
||||
createPasskeyFlow(displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -600,6 +632,201 @@ class PasskeyFormFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge passkey into existing Item flow.
|
||||
* Adds a passkey to an existing credential (Item) that has user/pass but no passkey.
|
||||
*/
|
||||
private suspend fun mergePasskeyFlow(
|
||||
displayName: String,
|
||||
item: net.aliasvault.app.vaultstore.ItemWithCredentialInfo,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Step 1: Sync vault before adding passkey to ensure we have latest data
|
||||
withContext(Dispatchers.Main) {
|
||||
showLoading(getString(R.string.passkey_checking_connection))
|
||||
}
|
||||
|
||||
val syncResult = vaultStore.syncVaultWithServer(webApiService)
|
||||
if (!syncResult.success && !syncResult.wasOffline) {
|
||||
// Server connectivity check failed - show appropriate error dialog
|
||||
withContext(Dispatchers.Main) {
|
||||
showSyncErrorAlert(Exception(syncResult.error ?: "Sync failed"))
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
|
||||
// Step 2: Create passkey
|
||||
withContext(Dispatchers.Main) {
|
||||
showLoading(getString(R.string.passkey_creating))
|
||||
}
|
||||
|
||||
// Extract favicon (optional)
|
||||
var logo: ByteArray? = null
|
||||
try {
|
||||
logo = webApiService.extractFavicon("https://${viewModel.rpId}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Favicon extraction failed", e)
|
||||
// Continue without logo
|
||||
}
|
||||
|
||||
// Generate passkey credentials
|
||||
val passkeyId = UUID.randomUUID()
|
||||
val credentialId = PasskeyHelper.guidToBytes(passkeyId.toString())
|
||||
|
||||
// Parse request to get challenge
|
||||
val requestObj = JSONObject(viewModel.requestJson)
|
||||
val challenge = requestObj.optString("challenge", "")
|
||||
|
||||
// Use the origin set by PasskeyRegistrationActivity
|
||||
val requestOrigin = viewModel.origin
|
||||
?: throw PasskeyOperationException("Origin not available")
|
||||
|
||||
// Extract PRF inputs if present
|
||||
val prfInputs = extractPrfInputs(requestObj)
|
||||
val enablePrf = prfInputs != null
|
||||
|
||||
// Create the passkey using PasskeyAuthenticator
|
||||
val passkeyResult = PasskeyAuthenticator.createPasskey(
|
||||
credentialId = credentialId,
|
||||
rpId = viewModel.rpId,
|
||||
userId = viewModel.userId,
|
||||
userName = viewModel.userName,
|
||||
userDisplayName = viewModel.userDisplayName,
|
||||
uvPerformed = true,
|
||||
enablePrf = enablePrf,
|
||||
prfInputs = prfInputs,
|
||||
)
|
||||
|
||||
// Create Passkey model object
|
||||
val now = Date()
|
||||
val passkey = Passkey(
|
||||
id = passkeyId,
|
||||
parentItemId = item.itemId, // Link to existing Item
|
||||
rpId = viewModel.rpId,
|
||||
userHandle = viewModel.userId,
|
||||
userName = viewModel.userName,
|
||||
publicKey = passkeyResult.publicKey,
|
||||
privateKey = passkeyResult.privateKey,
|
||||
prfKey = passkeyResult.prfSecret,
|
||||
displayName = displayName,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
isDeleted = false,
|
||||
)
|
||||
|
||||
// Step 3: Add passkey to existing Item in database
|
||||
withContext(Dispatchers.Main) {
|
||||
showLoading(getString(R.string.passkey_saving))
|
||||
}
|
||||
|
||||
vaultStore.addPasskeyToExistingItem(
|
||||
itemId = item.itemId,
|
||||
passkeyObj = passkey,
|
||||
logo = logo,
|
||||
)
|
||||
|
||||
// Step 4: 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 added locally", e)
|
||||
// Show error dialog but continue - passkey is still saved locally
|
||||
withContext(Dispatchers.Main) {
|
||||
showSyncErrorAlert(e)
|
||||
delay(2000)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Update credential identity cache
|
||||
updateCredentialIdentityCache()
|
||||
|
||||
// Build response (same as create flow)
|
||||
val credentialIdB64 = Helpers.bytesToBase64url(credentialId)
|
||||
val attestationObjectB64 = Helpers.bytesToBase64url(passkeyResult.attestationObject)
|
||||
|
||||
val clientDataJson = buildClientDataJson(challenge, requestOrigin)
|
||||
val clientDataJsonB64 = Helpers.bytesToBase64url(clientDataJson.toByteArray(Charsets.UTF_8))
|
||||
|
||||
val responseJson = JSONObject().apply {
|
||||
put("id", credentialIdB64)
|
||||
put("rawId", credentialIdB64)
|
||||
put("type", "public-key")
|
||||
put("authenticatorAttachment", "cross-platform")
|
||||
put(
|
||||
"response",
|
||||
JSONObject().apply {
|
||||
put("clientDataJSON", clientDataJsonB64)
|
||||
put("attestationObject", attestationObjectB64)
|
||||
put("authenticatorData", Helpers.bytesToBase64url(passkeyResult.authenticatorData))
|
||||
put(
|
||||
"transports",
|
||||
org.json.JSONArray().apply {
|
||||
put("internal")
|
||||
},
|
||||
)
|
||||
put("publicKey", Helpers.bytesToBase64url(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", Helpers.bytesToBase64url(prfResults.first))
|
||||
prfResults.second?.let {
|
||||
put("second", Helpers.bytesToBase64url(it))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
put("clientExtensionResults", JSONObject())
|
||||
}
|
||||
}
|
||||
|
||||
val response = CreatePublicKeyCredentialResponse(responseJson.toString())
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
hideLoading()
|
||||
val resultIntent = Intent()
|
||||
try {
|
||||
PendingIntentHandler.setCreateCredentialResponse(resultIntent, response)
|
||||
requireActivity().setResult(Activity.RESULT_OK, resultIntent)
|
||||
requireActivity().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 adding passkey to existing item", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
showError(getString(R.string.passkey_creation_failed) + ": ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract PRF extension inputs from request.
|
||||
* Note: PRF needs to be fully tested, we did not get PRF eval in the request from CredMan so far.
|
||||
|
||||
+21
-7
@@ -213,11 +213,18 @@ class PasskeyRegistrationActivity : FragmentActivity() {
|
||||
val db = vaultStore.database
|
||||
|
||||
if (db != null) {
|
||||
// Get existing passkeys for the rpId (can be replaced)
|
||||
viewModel.existingPasskeys = vaultStore.getPasskeysWithCredentialInfo(
|
||||
rpId = viewModel.rpId,
|
||||
userName = viewModel.userName,
|
||||
userId = viewModel.userId,
|
||||
)
|
||||
|
||||
// Get existing Items without passkeys (can have passkey merged into them)
|
||||
viewModel.existingItemsWithoutPasskey = vaultStore.getItemsWithoutPasskeyForRpId(
|
||||
rpId = viewModel.rpId,
|
||||
userName = viewModel.userName,
|
||||
)
|
||||
}
|
||||
|
||||
// Set content view with fragment container
|
||||
@@ -226,11 +233,14 @@ class PasskeyRegistrationActivity : FragmentActivity() {
|
||||
// Only initialize fragments if this is a fresh onCreate (not a configuration change)
|
||||
if (savedInstanceState == null) {
|
||||
// Decide which fragment to show
|
||||
if (viewModel.existingPasskeys.isEmpty()) {
|
||||
// No existing passkeys - show form directly
|
||||
showFormFragment(isReplace = false, passkeyId = null)
|
||||
val hasExistingPasskeys = viewModel.existingPasskeys.isNotEmpty()
|
||||
val hasExistingItems = viewModel.existingItemsWithoutPasskey.isNotEmpty()
|
||||
|
||||
if (!hasExistingPasskeys && !hasExistingItems) {
|
||||
// No existing passkeys or items - show form directly
|
||||
showFormFragment(isReplace = false, passkeyId = null, itemId = null)
|
||||
} else {
|
||||
// Existing passkeys found - show selection view
|
||||
// Existing passkeys or items found - show selection view
|
||||
showSelectionFragment()
|
||||
}
|
||||
}
|
||||
@@ -251,10 +261,14 @@ class PasskeyRegistrationActivity : FragmentActivity() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show form fragment for creating or replacing a passkey.
|
||||
* Show form fragment for creating, replacing, or merging a passkey.
|
||||
*
|
||||
* @param isReplace Whether this is a passkey replacement operation.
|
||||
* @param passkeyId The ID of the passkey to replace (if isReplace is true).
|
||||
* @param itemId The ID of the existing Item to merge passkey into (if merging).
|
||||
*/
|
||||
private fun showFormFragment(isReplace: Boolean, passkeyId: String?) {
|
||||
val fragment = PasskeyFormFragment.newInstance(isReplace, passkeyId)
|
||||
fun showFormFragment(isReplace: Boolean, passkeyId: String?, itemId: String? = null) {
|
||||
val fragment = PasskeyFormFragment.newInstance(isReplace, passkeyId, itemId)
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragmentContainer, fragment)
|
||||
.commit()
|
||||
|
||||
+60
-23
@@ -14,7 +14,7 @@ import net.aliasvault.app.credentialprovider.models.PasskeyRegistrationViewModel
|
||||
|
||||
/**
|
||||
* Fragment that shows the passkey selection screen.
|
||||
* Displays options to create new or replace existing passkeys.
|
||||
* Displays options to create new, replace existing passkeys, or merge with existing credentials.
|
||||
*/
|
||||
class PasskeySelectionFragment : Fragment() {
|
||||
|
||||
@@ -35,6 +35,9 @@ class PasskeySelectionFragment : Fragment() {
|
||||
val headerSubtitle = view.findViewById<TextView>(R.id.headerSubtitle)
|
||||
val createNewButton = view.findViewById<MaterialButton>(R.id.createNewButton)
|
||||
val existingPasskeysContainer = view.findViewById<LinearLayout>(R.id.existingPasskeysContainer)
|
||||
val existingPasskeysSection = view.findViewById<View>(R.id.existingPasskeysSection)
|
||||
val existingItemsSection = view.findViewById<View>(R.id.existingItemsSection)
|
||||
val existingItemsContainer = view.findViewById<LinearLayout>(R.id.existingItemsContainer)
|
||||
val cancelButton = view.findViewById<MaterialButton>(R.id.cancelButton)
|
||||
|
||||
// Set title and subtitle
|
||||
@@ -44,33 +47,67 @@ class PasskeySelectionFragment : Fragment() {
|
||||
// Set up create new button
|
||||
createNewButton.setOnClickListener {
|
||||
viewModel.onCreateNewSelected()
|
||||
navigateToForm(isReplace = false, passkeyId = null)
|
||||
navigateToForm(isReplace = false, passkeyId = null, itemId = null)
|
||||
}
|
||||
|
||||
// Populate existing passkeys list
|
||||
val inflater = LayoutInflater.from(requireContext())
|
||||
viewModel.existingPasskeys.forEach { passkeyInfo ->
|
||||
val itemView = inflater.inflate(R.layout.item_existing_passkey, existingPasskeysContainer, false)
|
||||
val layoutInflater = LayoutInflater.from(requireContext())
|
||||
|
||||
val displayNameView = itemView.findViewById<TextView>(R.id.passkeyDisplayName)
|
||||
val subtitleView = itemView.findViewById<TextView>(R.id.passkeySubtitle)
|
||||
// Show existing Items without passkeys section (for merging)
|
||||
if (viewModel.existingItemsWithoutPasskey.isNotEmpty()) {
|
||||
existingItemsSection?.visibility = View.VISIBLE
|
||||
|
||||
displayNameView.text = passkeyInfo.passkey.displayName
|
||||
val subtitle = buildString {
|
||||
passkeyInfo.username?.let { append(it) }
|
||||
if (passkeyInfo.username != null && passkeyInfo.serviceName != null) {
|
||||
append(" • ")
|
||||
viewModel.existingItemsWithoutPasskey.forEach { itemInfo ->
|
||||
val itemView = layoutInflater.inflate(R.layout.item_existing_passkey, existingItemsContainer, false)
|
||||
|
||||
val displayNameView = itemView.findViewById<TextView>(R.id.passkeyDisplayName)
|
||||
val subtitleView = itemView.findViewById<TextView>(R.id.passkeySubtitle)
|
||||
|
||||
displayNameView.text = itemInfo.serviceName ?: viewModel.rpId
|
||||
val subtitle = buildString {
|
||||
itemInfo.username?.let { append(it) }
|
||||
}
|
||||
passkeyInfo.serviceName?.let { append(it) }
|
||||
}
|
||||
subtitleView.text = subtitle.ifEmpty { viewModel.rpId }
|
||||
subtitleView.text = subtitle.ifEmpty { itemInfo.url ?: viewModel.rpId }
|
||||
|
||||
itemView.setOnClickListener {
|
||||
viewModel.onReplaceSelected(passkeyInfo)
|
||||
navigateToForm(isReplace = true, passkeyId = passkeyInfo.passkey.id.toString())
|
||||
}
|
||||
itemView.setOnClickListener {
|
||||
viewModel.onMergeSelected(itemInfo)
|
||||
navigateToForm(isReplace = false, passkeyId = null, itemId = itemInfo.itemId.toString())
|
||||
}
|
||||
|
||||
existingPasskeysContainer.addView(itemView)
|
||||
existingItemsContainer?.addView(itemView)
|
||||
}
|
||||
} else {
|
||||
existingItemsSection?.visibility = View.GONE
|
||||
}
|
||||
|
||||
// Show existing passkeys section (for replacement)
|
||||
if (viewModel.existingPasskeys.isNotEmpty()) {
|
||||
existingPasskeysSection?.visibility = View.VISIBLE
|
||||
|
||||
viewModel.existingPasskeys.forEach { passkeyInfo ->
|
||||
val itemView = layoutInflater.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 { viewModel.rpId }
|
||||
|
||||
itemView.setOnClickListener {
|
||||
viewModel.onReplaceSelected(passkeyInfo)
|
||||
navigateToForm(isReplace = true, passkeyId = passkeyInfo.passkey.id.toString(), itemId = null)
|
||||
}
|
||||
|
||||
existingPasskeysContainer.addView(itemView)
|
||||
}
|
||||
} else {
|
||||
existingPasskeysSection?.visibility = View.GONE
|
||||
}
|
||||
|
||||
// Set up cancel button
|
||||
@@ -80,8 +117,8 @@ class PasskeySelectionFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToForm(isReplace: Boolean, passkeyId: String?) {
|
||||
val fragment = PasskeyFormFragment.newInstance(isReplace, passkeyId)
|
||||
private fun navigateToForm(isReplace: Boolean, passkeyId: String?, itemId: String?) {
|
||||
val fragment = PasskeyFormFragment.newInstance(isReplace, passkeyId, itemId)
|
||||
parentFragmentManager.beginTransaction()
|
||||
.setCustomAnimations(
|
||||
R.anim.slide_in_right,
|
||||
|
||||
+32
-1
@@ -1,6 +1,7 @@
|
||||
package net.aliasvault.app.credentialprovider.models
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import net.aliasvault.app.vaultstore.ItemWithCredentialInfo
|
||||
import net.aliasvault.app.vaultstore.PasskeyWithCredentialInfo
|
||||
import java.util.UUID
|
||||
|
||||
@@ -35,21 +36,32 @@ class PasskeyRegistrationViewModel : ViewModel() {
|
||||
/** The user ID as a byte array. */
|
||||
var userId: ByteArray? = null
|
||||
|
||||
/** List of existing passkeys for the relying party. */
|
||||
/** List of existing passkeys for the relying party (can be replaced). */
|
||||
var existingPasskeys: List<PasskeyWithCredentialInfo> = emptyList()
|
||||
|
||||
/** List of existing Items without passkeys (can have passkey merged into them). */
|
||||
var existingItemsWithoutPasskey: List<ItemWithCredentialInfo> = emptyList()
|
||||
|
||||
/** The passkey selected to be replaced, if any. */
|
||||
var selectedPasskeyToReplace: PasskeyWithCredentialInfo? = null
|
||||
|
||||
/** The Item selected to add passkey to (merge), if any. */
|
||||
var selectedItemToMerge: ItemWithCredentialInfo? = null
|
||||
|
||||
/** Whether the user is in replace mode (true) or create new mode (false). */
|
||||
var isReplaceMode: Boolean = false
|
||||
|
||||
/** Whether the user is in merge mode (adding passkey to existing credential). */
|
||||
var isMergeMode: Boolean = false
|
||||
|
||||
/**
|
||||
* Called when the user selects to create a new passkey.
|
||||
*/
|
||||
fun onCreateNewSelected() {
|
||||
isReplaceMode = false
|
||||
isMergeMode = false
|
||||
selectedPasskeyToReplace = null
|
||||
selectedItemToMerge = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,7 +69,19 @@ class PasskeyRegistrationViewModel : ViewModel() {
|
||||
*/
|
||||
fun onReplaceSelected(passkeyInfo: PasskeyWithCredentialInfo) {
|
||||
isReplaceMode = true
|
||||
isMergeMode = false
|
||||
selectedPasskeyToReplace = passkeyInfo
|
||||
selectedItemToMerge = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user selects to merge passkey into an existing Item.
|
||||
*/
|
||||
fun onMergeSelected(itemInfo: ItemWithCredentialInfo) {
|
||||
isReplaceMode = false
|
||||
isMergeMode = true
|
||||
selectedPasskeyToReplace = null
|
||||
selectedItemToMerge = itemInfo
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,4 +90,11 @@ class PasskeyRegistrationViewModel : ViewModel() {
|
||||
fun getPasskeyById(id: UUID): PasskeyWithCredentialInfo? {
|
||||
return existingPasskeys.firstOrNull { it.passkey.id == id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an Item by its ID from the existing items without passkey list.
|
||||
*/
|
||||
fun getItemById(id: UUID): ItemWithCredentialInfo? {
|
||||
return existingItemsWithoutPasskey.firstOrNull { it.itemId == id }
|
||||
}
|
||||
}
|
||||
|
||||
+137
@@ -123,6 +123,106 @@ class VaultPasskey(
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Items that match an rpId but don't have a passkey yet.
|
||||
* Used for finding existing credentials that could have a passkey added to them.
|
||||
*
|
||||
* @param rpId The relying party identifier to match against the login URL.
|
||||
* @param userName Optional username to filter by.
|
||||
* @return List of ItemWithCredentialInfo objects representing Items without passkeys.
|
||||
*/
|
||||
fun getItemsWithoutPasskeyForRpId(
|
||||
rpId: String,
|
||||
userName: String? = null,
|
||||
): List<ItemWithCredentialInfo> {
|
||||
val db = database.dbConnection ?: return emptyList()
|
||||
|
||||
// Query Items that:
|
||||
// 1. Have a URL containing the rpId
|
||||
// 2. Don't have an associated passkey
|
||||
// 3. Are not deleted
|
||||
val query = """
|
||||
SELECT i.Id, i.Name, i.CreatedAt, i.UpdatedAt,
|
||||
fv_url.Value as Url,
|
||||
fv_username.Value as Username,
|
||||
fv_password.Value as Password
|
||||
FROM Items i
|
||||
INNER JOIN FieldValues fv_url ON fv_url.ItemId = i.Id
|
||||
AND fv_url.FieldKey = ?
|
||||
AND fv_url.IsDeleted = 0
|
||||
LEFT JOIN FieldValues fv_username ON fv_username.ItemId = i.Id
|
||||
AND fv_username.FieldKey = ?
|
||||
AND fv_username.IsDeleted = 0
|
||||
LEFT JOIN FieldValues fv_password ON fv_password.ItemId = i.Id
|
||||
AND fv_password.FieldKey = ?
|
||||
AND fv_password.IsDeleted = 0
|
||||
WHERE i.IsDeleted = 0
|
||||
AND i.DeletedAt IS NULL
|
||||
AND i.ItemType = 'Login'
|
||||
AND (LOWER(fv_url.Value) LIKE ? OR LOWER(fv_url.Value) LIKE ?)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM Passkeys p
|
||||
WHERE p.ItemId = i.Id AND p.IsDeleted = 0
|
||||
)
|
||||
ORDER BY i.UpdatedAt DESC
|
||||
""".trimIndent()
|
||||
|
||||
val rpIdLower = rpId.lowercase()
|
||||
val urlPattern1 = "%$rpIdLower%"
|
||||
val urlPattern2 = "%${rpIdLower.replace("www.", "")}%"
|
||||
|
||||
val results = mutableListOf<ItemWithCredentialInfo>()
|
||||
val cursor = db.query(
|
||||
query,
|
||||
arrayOf(
|
||||
FieldKey.LOGIN_URL,
|
||||
FieldKey.LOGIN_USERNAME,
|
||||
FieldKey.LOGIN_PASSWORD,
|
||||
urlPattern1,
|
||||
urlPattern2,
|
||||
),
|
||||
)
|
||||
|
||||
cursor.use {
|
||||
while (it.moveToNext()) {
|
||||
val itemIdString = it.getString(0)
|
||||
val itemName = if (!it.isNull(1)) it.getString(1) else null
|
||||
val itemCreatedAt = if (!it.isNull(2)) it.getString(2) else null
|
||||
val itemUpdatedAt = if (!it.isNull(3)) it.getString(3) else null
|
||||
val url = if (!it.isNull(4)) it.getString(4) else null
|
||||
val itemUsername = if (!it.isNull(5)) it.getString(5) else null
|
||||
val hasPassword = !it.isNull(6) && it.getString(6).isNotEmpty()
|
||||
|
||||
// Filter by username if provided
|
||||
if (userName != null && itemUsername != userName) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
val itemId = UUID.fromString(itemIdString)
|
||||
val createdAt = DateHelpers.parseDateString(itemCreatedAt ?: "") ?: MIN_DATE
|
||||
val updatedAt = DateHelpers.parseDateString(itemUpdatedAt ?: "") ?: MIN_DATE
|
||||
|
||||
results.add(
|
||||
ItemWithCredentialInfo(
|
||||
itemId = itemId,
|
||||
serviceName = itemName,
|
||||
url = url,
|
||||
username = itemUsername,
|
||||
hasPassword = hasPassword,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt,
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing item row", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all passkeys with their associated items in a single query.
|
||||
* This is much more efficient than calling getPasskeysForItem() for each item.
|
||||
@@ -241,6 +341,21 @@ class VaultPasskey(
|
||||
passkeyRepository.replace(oldPasskeyId, newPasskey, displayName, logo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a passkey to an existing Item (merge passkey into existing credential).
|
||||
*
|
||||
* @param itemId The UUID of the existing Item to add the passkey to.
|
||||
* @param passkey The passkey to add.
|
||||
* @param logo Optional logo to update/add.
|
||||
*/
|
||||
fun addPasskeyToExistingItem(
|
||||
itemId: UUID,
|
||||
passkey: Passkey,
|
||||
logo: ByteArray? = null,
|
||||
) {
|
||||
passkeyRepository.addPasskeyToExistingItem(itemId, passkey, logo)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Helper Methods
|
||||
@@ -364,6 +479,28 @@ data class PasskeyWithItem(
|
||||
val item: Item,
|
||||
)
|
||||
|
||||
/**
|
||||
* Data class to hold Item info for Items without passkeys.
|
||||
* Used for showing existing credentials that can have a passkey added.
|
||||
*
|
||||
* @property itemId The UUID of the item.
|
||||
* @property serviceName The service name (Item.Name).
|
||||
* @property url The login URL.
|
||||
* @property username The username from field values.
|
||||
* @property hasPassword Whether the item has a password.
|
||||
* @property createdAt When the item was created.
|
||||
* @property updatedAt When the item was last updated.
|
||||
*/
|
||||
data class ItemWithCredentialInfo(
|
||||
val itemId: UUID,
|
||||
val serviceName: String?,
|
||||
val url: String?,
|
||||
val username: String?,
|
||||
val hasPassword: Boolean,
|
||||
val createdAt: Date,
|
||||
val updatedAt: Date,
|
||||
)
|
||||
|
||||
/**
|
||||
* VaultPasskey-specific errors.
|
||||
*/
|
||||
|
||||
@@ -672,6 +672,28 @@ class VaultStore(
|
||||
passkey.replacePasskey(oldPasskeyId, newPasskey, displayName, logo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Items that match an rpId but don't have a passkey yet.
|
||||
* Used for finding existing credentials that could have a passkey added to them.
|
||||
*/
|
||||
fun getItemsWithoutPasskeyForRpId(
|
||||
rpId: String,
|
||||
userName: String? = null,
|
||||
): List<ItemWithCredentialInfo> {
|
||||
return passkey.getItemsWithoutPasskeyForRpId(rpId, userName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a passkey to an existing Item (merge passkey into existing credential).
|
||||
*/
|
||||
fun addPasskeyToExistingItem(
|
||||
itemId: java.util.UUID,
|
||||
passkeyObj: net.aliasvault.app.vaultstore.models.Passkey,
|
||||
logo: ByteArray? = null,
|
||||
) {
|
||||
passkey.addPasskeyToExistingItem(itemId, passkeyObj, logo)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Cache Methods
|
||||
|
||||
+101
-2
@@ -184,15 +184,33 @@ class PasskeyRepository(database: VaultDatabase) : BaseRepository(database) {
|
||||
val now = Date()
|
||||
val timestamp = DateHelpers.toStandardFormat(now)
|
||||
|
||||
// Create the item
|
||||
// Create logo if provided
|
||||
val logoId = if (logo != null) {
|
||||
val logoIdGen = generateId()
|
||||
// TODO: Insert logo into Logos table with deduplication
|
||||
val source = rpId.lowercase().replace("www.", "")
|
||||
|
||||
executeUpdate(
|
||||
"""
|
||||
INSERT INTO Logos (Id, Source, FileData, MimeType, FetchedAt, CreatedAt, UpdatedAt, IsDeleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
arrayOf(
|
||||
logoIdGen,
|
||||
source,
|
||||
logo, // ByteArray for FileData
|
||||
"image/png",
|
||||
null, // FetchedAt
|
||||
timestamp,
|
||||
timestamp,
|
||||
0,
|
||||
),
|
||||
)
|
||||
logoIdGen
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// Create the Item
|
||||
executeUpdate(
|
||||
"""
|
||||
INSERT INTO Items (Id, Name, ItemType, LogoId, FolderId, CreatedAt, UpdatedAt, IsDeleted, DeletedAt)
|
||||
@@ -324,6 +342,87 @@ class PasskeyRepository(database: VaultDatabase) : BaseRepository(database) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a passkey to an existing Item (merge passkey into existing credential).
|
||||
*
|
||||
* @param itemId The UUID of the existing Item to add the passkey to.
|
||||
* @param passkey The passkey to add (will have its parentItemId updated).
|
||||
* @param logo Optional logo to update/add to the item.
|
||||
*/
|
||||
fun addPasskeyToExistingItem(
|
||||
itemId: UUID,
|
||||
passkey: Passkey,
|
||||
logo: ByteArray? = null,
|
||||
) {
|
||||
withTransaction {
|
||||
val now = Date()
|
||||
val timestamp = DateHelpers.toStandardFormat(now)
|
||||
|
||||
// Optionally update/add logo
|
||||
if (logo != null) {
|
||||
val rpId = passkey.rpId
|
||||
val source = rpId.lowercase().replace("www.", "")
|
||||
|
||||
// Check if item already has a logo
|
||||
val itemResults = executeQuery(
|
||||
"SELECT LogoId FROM Items WHERE Id = ?",
|
||||
arrayOf(itemId.toString().uppercase()),
|
||||
)
|
||||
|
||||
val existingLogoId = itemResults.firstOrNull()?.get("LogoId") as? String
|
||||
|
||||
if (existingLogoId != null) {
|
||||
// Update existing logo
|
||||
executeUpdate(
|
||||
"UPDATE Logos SET FileData = ?, UpdatedAt = ? WHERE Id = ?",
|
||||
arrayOf(logo, timestamp, existingLogoId),
|
||||
)
|
||||
} else {
|
||||
// Create new logo
|
||||
val newLogoId = generateId()
|
||||
executeUpdate(
|
||||
"""
|
||||
INSERT INTO Logos (Id, Source, FileData, MimeType, FetchedAt, CreatedAt, UpdatedAt, IsDeleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
arrayOf(
|
||||
newLogoId,
|
||||
source,
|
||||
logo,
|
||||
"image/png",
|
||||
null,
|
||||
timestamp,
|
||||
timestamp,
|
||||
0,
|
||||
),
|
||||
)
|
||||
|
||||
// Link logo to item
|
||||
executeUpdate(
|
||||
"UPDATE Items SET LogoId = ?, UpdatedAt = ? WHERE Id = ?",
|
||||
arrayOf(newLogoId, timestamp, itemId.toString().uppercase()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update item's UpdatedAt timestamp
|
||||
executeUpdate(
|
||||
"UPDATE Items SET UpdatedAt = ? WHERE Id = ?",
|
||||
arrayOf(timestamp, itemId.toString().uppercase()),
|
||||
)
|
||||
|
||||
// Create the passkey with the existing item ID
|
||||
val passkeyToInsert = passkey.copy(
|
||||
parentItemId = itemId,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
isDeleted = false,
|
||||
)
|
||||
|
||||
insert(passkeyToInsert)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
/**
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
android:text="@string/passkey_create_new_button"
|
||||
android:textColor="#FFFFFF"
|
||||
android:backgroundTint="@color/av_primary"
|
||||
android:layout_marginBottom="36dp"
|
||||
android:layout_marginBottom="24dp"
|
||||
app:cornerRadius="8dp"
|
||||
app:icon="@android:drawable/ic_lock_lock"
|
||||
app:iconTint="#FFFFFF"
|
||||
@@ -69,24 +69,61 @@
|
||||
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" />
|
||||
|
||||
<!-- Section: Add to existing credential (no passkey yet) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/existingPasskeysContainer"
|
||||
android:id="@+id/existingItemsSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
<!-- Passkey items will be added programmatically -->
|
||||
android:orientation="vertical"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/passkey_add_to_existing"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/av_text"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="4dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/existingItemsContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
<!-- Items without passkeys will be added programmatically -->
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Section: Replace existing passkey -->
|
||||
<LinearLayout
|
||||
android:id="@+id/existingPasskeysSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<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" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/existingPasskeysContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
<!-- Passkey items will be added programmatically -->
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<string name="passkey_registration_title">Create Passkey</string>
|
||||
<string name="create_passkey_title">Create New Passkey</string>
|
||||
<string name="create_passkey_subtitle">Register a new passkey for this website. It will be securely stored in your vault and automatically synced across your devices with AliasVault.</string>
|
||||
<string name="replace_passkey_title">Replace Passkey</string>
|
||||
<string name="replace_passkey">Replace Passkey</string>
|
||||
<string name="passkey_display_name_label">Passkey Name</string>
|
||||
<string name="passkey_display_name_hint">Enter a name for this passkey</string>
|
||||
<string name="passkey_website_label">Website</string>
|
||||
@@ -45,9 +45,12 @@
|
||||
<string name="passkey_create_explanation">This creates a new passkey and stores it in your vault. It will be automatically synced across all your devices that use AliasVault.</string>
|
||||
<string name="passkey_create_new_button">Create New Passkey</string>
|
||||
<string name="passkey_select_to_replace">Or, replace an existing passkey:</string>
|
||||
<string name="passkey_replace_button">Replace Passkey</string>
|
||||
<string name="passkey_add_to_existing">Or, add passkey to an existing item:</string>
|
||||
<string name="passkey_replace_explanation">This will replace the existing passkey with a new one. Please be aware that your old passkey will be overwritten and no longer accessible. If you wish to create a separate passkey instead, go back to the previous screen.</string>
|
||||
<string name="passkey_merge_explanation">This will add a passkey to your existing item. Your password and other data will be preserved.</string>
|
||||
<string name="passkey_replacing">Replacing passkey…</string>
|
||||
<string name="add_passkey">Add Passkey</string>
|
||||
<string name="add_passkey_subtitle">Adding passkey to existing item</string>
|
||||
<string name="passkey_checking_connection">Checking connection…</string>
|
||||
<string name="passkey_retrieving">Retrieving passkey…</string>
|
||||
<string name="passkey_verifying">Verifying…</string>
|
||||
|
||||
Reference in New Issue
Block a user