Update passkey auth and register flow (#1404)

This commit is contained in:
Leendert de Borst
2026-01-17 20:03:56 +01:00
parent 088105011b
commit 85226efb98
9 changed files with 675 additions and 68 deletions
@@ -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.
@@ -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()
@@ -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,
@@ -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 }
}
}
@@ -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
@@ -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>