Refactor CredentialIdentityStore scaffolding (#520)

This commit is contained in:
Leendert de Borst
2025-10-25 17:04:17 +02:00
parent ea4d72ceca
commit 5185dfa41d
11 changed files with 770 additions and 555 deletions

View File

@@ -4,7 +4,6 @@ import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import net.aliasvault.app.utils.Helpers
import net.aliasvault.app.vaultstore.getPasskeysForCredential
import net.aliasvault.app.vaultstore.models.Credential
import net.aliasvault.app.vaultstore.models.Passkey
import net.aliasvault.app.vaultstore.passkey.PasskeyHelper
@@ -81,27 +80,19 @@ class CredentialIdentityStore private constructor(context: Context) {
/**
* Save credential identities from a list of credentials.
* This extracts passkey metadata and stores it in SharedPreferences.
* @param credentials List of credentials with passkeys
* @param vaultStore VaultStore instance to query passkeys
* @param db Database connection
*/
fun saveCredentialIdentities(
credentials: List<Credential>,
vaultStore: net.aliasvault.app.vaultstore.VaultStore,
db: android.database.sqlite.SQLiteDatabase,
) {
fun saveCredentialIdentities(vaultStore: net.aliasvault.app.vaultstore.VaultStore) {
try {
val passkeyIdentities = mutableListOf<PasskeyIdentity>()
// Extract passkey identities from credentials
credentials.forEach { credential ->
// Get passkeys for this credential
val passkeys = vaultStore.getPasskeysForCredential(credential.id, db)
// Get all passkeys with their credentials in a single efficient query
// This replaces the N+1 query pattern that was calling getPasskeysForCredential() for each credential
val passkeysWithCredentials = vaultStore.getAllPasskeysWithCredentials()
passkeys.forEach { passkey ->
if (!passkey.isDeleted) {
passkeyIdentities.add(createPasskeyIdentity(passkey, credential))
}
passkeysWithCredentials.forEach { (passkey, credential) ->
if (!passkey.isDeleted) {
passkeyIdentities.add(createPasskeyIdentity(passkey, credential))
}
}

View File

@@ -11,7 +11,6 @@ import androidx.fragment.app.FragmentActivity
import net.aliasvault.app.R
import net.aliasvault.app.utils.Helpers
import net.aliasvault.app.vaultstore.VaultStore
import net.aliasvault.app.vaultstore.getPasskeyById
import net.aliasvault.app.vaultstore.keystoreprovider.AndroidKeystoreProvider
import net.aliasvault.app.vaultstore.keystoreprovider.KeystoreOperationCallback
import net.aliasvault.app.vaultstore.passkey.PasskeyAuthenticator

View File

@@ -27,11 +27,9 @@ import net.aliasvault.app.exceptions.VaultOperationException
import net.aliasvault.app.utils.Helpers
import net.aliasvault.app.vaultstore.PasskeyWithCredentialInfo
import net.aliasvault.app.vaultstore.VaultStore
import net.aliasvault.app.vaultstore.createCredentialWithPasskey
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.JSONObject
import java.util.Date
@@ -293,7 +291,7 @@ class PasskeyFormFragment : Fragment() {
rpId = viewModel.rpId,
userName = viewModel.userName,
displayName = displayName,
passkey = passkey,
passkeyObj = passkey,
logo = logo,
)
@@ -634,13 +632,9 @@ class PasskeyFormFragment : Fragment() {
*/
private fun updateCredentialIdentityCache() {
try {
val credentials = vaultStore.getAllCredentials()
val db = vaultStore.database
if (db != null) {
val identityStore = CredentialIdentityStore.getInstance(requireContext())
identityStore.saveCredentialIdentities(credentials, vaultStore, db)
Log.d(TAG, "Updated credential identity cache")
}
val identityStore = CredentialIdentityStore.getInstance(requireContext())
identityStore.saveCredentialIdentities(vaultStore)
Log.d(TAG, "Updated credential identity cache")
} catch (e: Exception) {
Log.w(TAG, "Failed to update credential identity cache", e)
// Non-critical error, don't throw

View File

@@ -12,7 +12,6 @@ import net.aliasvault.app.R
import net.aliasvault.app.credentialprovider.models.PasskeyRegistrationViewModel
import net.aliasvault.app.utils.Helpers
import net.aliasvault.app.vaultstore.VaultStore
import net.aliasvault.app.vaultstore.getPasskeysWithCredentialInfo
import net.aliasvault.app.vaultstore.keystoreprovider.AndroidKeystoreProvider
import net.aliasvault.app.vaultstore.keystoreprovider.KeystoreOperationCallback
import net.aliasvault.app.vaultstore.storageprovider.AndroidStorageProvider

View File

@@ -1022,21 +1022,11 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
// Execute registration in background thread
Thread {
try {
// Get all credentials from the vault
val credentials = vaultStore.getAllCredentials()
// Get database connection
val db = vaultStore.database
if (db == null) {
Log.w(TAG, "Database not available - vault may be locked")
return@Thread
}
// Save credential identities to the identity store
val identityStore = net.aliasvault.app.credentialprovider.CredentialIdentityStore.getInstance(
reactApplicationContext,
)
identityStore.saveCredentialIdentities(credentials, vaultStore, db)
identityStore.saveCredentialIdentities(vaultStore)
} catch (e: Exception) {
Log.e(TAG, "Error registering credential identities in background", e)
}

View File

@@ -1,4 +1,4 @@
package net.aliasvault.app.vaultstore
package net.aliasvault.app.utils
/**
* Application configuration constants.

View File

@@ -0,0 +1,652 @@
package net.aliasvault.app.vaultstore
import android.content.ContentValues
import android.database.Cursor
import android.util.Log
import net.aliasvault.app.utils.DateHelpers
import net.aliasvault.app.vaultstore.models.Alias
import net.aliasvault.app.vaultstore.models.Credential
import net.aliasvault.app.vaultstore.models.Passkey
import net.aliasvault.app.vaultstore.models.Service
import net.aliasvault.app.vaultstore.passkey.PasskeyHelper
import java.util.Calendar
import java.util.Date
import java.util.TimeZone
import java.util.UUID
/**
* Handles passkey operations for the vault.
* This class uses composition to organize passkey-specific functionality.
*
* This is a Kotlin port of the iOS Swift implementation:
* - Reference: apps/mobile-app/ios/VaultStoreKit/VaultStore+Passkey.swift
*
* IMPORTANT: Keep all implementations synchronized. Changes to the public interface must be
* reflected in all ports. Method names, parameters, and behavior should remain consistent.
*/
class VaultPasskey(
private val database: VaultDatabase,
) {
companion object {
private const val TAG = "VaultPasskey"
/**
* Minimum date definition for default values.
*/
private val MIN_DATE: Date = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply {
set(Calendar.YEAR, 1)
set(Calendar.MONTH, Calendar.JANUARY)
set(Calendar.DAY_OF_MONTH, 1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.time
}
// region Passkey Queries
/**
* Get a passkey by its credential ID (the WebAuthn credential ID, not the parent Credential UUID).
*/
fun getPasskeyByCredentialId(credentialId: ByteArray): Passkey? {
val db = database.dbConnection ?: return null
// Convert credentialId bytes to UUID string for lookup
val credentialIdString = try {
PasskeyHelper.bytesToGuid(credentialId)
} catch (e: Exception) {
Log.e(TAG, "Failed to convert credentialId bytes to UUID string", e)
return null
}
val query = """
SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey,
DisplayName, CreatedAt, UpdatedAt, IsDeleted
FROM Passkeys
WHERE Id = ? AND IsDeleted = 0
LIMIT 1
""".trimIndent()
val cursor = db.rawQuery(query, arrayOf(credentialIdString.uppercase()))
cursor.use {
if (it.moveToFirst()) {
return parsePasskeyRow(it)
}
}
return null
}
/**
* Get all passkeys for a credential.
*/
fun getPasskeysForCredential(credentialId: UUID): List<Passkey> {
val db = database.dbConnection ?: return emptyList()
val query = """
SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey,
DisplayName, CreatedAt, UpdatedAt, IsDeleted
FROM Passkeys
WHERE CredentialId = ? AND IsDeleted = 0
ORDER BY CreatedAt DESC
""".trimIndent()
val passkeys = mutableListOf<Passkey>()
val cursor = db.rawQuery(query, arrayOf(credentialId.toString().uppercase()))
cursor.use {
while (it.moveToNext()) {
parsePasskeyRow(it)?.let { passkey ->
passkeys.add(passkey)
}
}
}
return passkeys
}
/**
* Get all passkeys for a specific relying party identifier (RP ID).
*/
fun getPasskeysForRpId(rpId: String): List<Passkey> {
val db = database.dbConnection ?: return emptyList()
val query = """
SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey,
DisplayName, CreatedAt, UpdatedAt, IsDeleted
FROM Passkeys
WHERE RpId = ? AND IsDeleted = 0
ORDER BY CreatedAt DESC
""".trimIndent()
val passkeys = mutableListOf<Passkey>()
val cursor = db.rawQuery(query, arrayOf(rpId))
cursor.use {
while (it.moveToNext()) {
parsePasskeyRow(it)?.let { passkey ->
passkeys.add(passkey)
}
}
}
return passkeys
}
/**
* Get passkeys with credential info for a specific rpId and optionally username.
* Used for finding existing passkeys that might be replaced during registration.
*/
fun getPasskeysWithCredentialInfo(
rpId: String,
userName: String? = null,
userId: ByteArray? = null,
): List<PasskeyWithCredentialInfo> {
val db = database.dbConnection ?: return emptyList()
val query = """
SELECT p.Id, p.CredentialId, p.RpId, p.UserHandle, p.PublicKey, p.PrivateKey, p.PrfKey,
p.DisplayName, p.CreatedAt, p.UpdatedAt, p.IsDeleted,
c.Username, s.Name
FROM Passkeys p
JOIN Credentials c ON p.CredentialId = c.Id
JOIN Services s ON c.ServiceId = s.Id
WHERE p.RpId = ? AND p.IsDeleted = 0 AND c.IsDeleted = 0
ORDER BY p.CreatedAt DESC
""".trimIndent()
val results = mutableListOf<PasskeyWithCredentialInfo>()
val cursor = db.rawQuery(query, arrayOf(rpId))
cursor.use {
while (it.moveToNext()) {
val passkey = parsePasskeyRow(it) ?: continue
val credUsername = if (!it.isNull(11)) it.getString(11) else null
val serviceName = if (!it.isNull(12)) it.getString(12) else null
// Filter by username or userId if provided
var matches = true
if (userName != null && credUsername != userName) {
matches = false
}
if (userId != null && passkey.userHandle != null && !userId.contentEquals(passkey.userHandle)) {
matches = false
}
if (matches) {
results.add(
PasskeyWithCredentialInfo(
passkey = passkey,
serviceName = serviceName,
username = credUsername,
),
)
}
}
}
return results
}
/**
* Get all passkeys with their associated credentials in a single query.
* This is much more efficient than calling getPasskeysForCredential() for each credential.
* Uses a JOIN to get passkeys and their credentials in one database query.
*/
fun getAllPasskeysWithCredentials(): List<PasskeyWithCredential> {
val db = database.dbConnection ?: return emptyList()
val query = """
SELECT
p.Id, p.CredentialId, p.RpId, p.UserHandle, p.PublicKey, p.PrivateKey, p.PrfKey,
p.DisplayName, p.CreatedAt as PasskeyCreatedAt, p.UpdatedAt as PasskeyUpdatedAt, p.IsDeleted as PasskeyIsDeleted,
c.Id as CredId, c.Username, s.Name as ServiceName, c.CreatedAt as CredCreatedAt, c.UpdatedAt as CredUpdatedAt
FROM Passkeys p
INNER JOIN Credentials c ON p.CredentialId = c.Id
INNER JOIN Services s ON c.ServiceId = s.Id
WHERE p.IsDeleted = 0 AND c.IsDeleted = 0
ORDER BY p.CreatedAt DESC
""".trimIndent()
val results = mutableListOf<PasskeyWithCredential>()
val cursor = db.rawQuery(query, null)
cursor.use {
while (it.moveToNext()) {
try {
// Parse passkey (columns 0-10)
val passkey = parsePasskeyRowFromJoin(it) ?: continue
// Parse credential info (columns 11-15)
val credentialId = UUID.fromString(it.getString(11))
val username = if (!it.isNull(12)) it.getString(12) else null
val serviceName = if (!it.isNull(13)) it.getString(13) else null
// Create a minimal Credential object with the data we have
val credential = Credential(
id = credentialId,
username = username,
service = Service(
id = UUID.randomUUID(),
name = serviceName,
url = null,
logo = null,
createdAt = Date(),
updatedAt = Date(),
isDeleted = false,
),
alias = null,
notes = null,
password = null,
createdAt = DateHelpers.parseDateString(it.getString(14)) ?: MIN_DATE,
updatedAt = DateHelpers.parseDateString(it.getString(15)) ?: MIN_DATE,
isDeleted = false,
)
results.add(PasskeyWithCredential(passkey, credential))
} catch (e: Exception) {
Log.e(TAG, "Error parsing passkey with credential row", e)
}
}
}
return results
}
/**
* Get a passkey by its ID.
*/
fun getPasskeyById(passkeyId: UUID): Passkey? {
val db = database.dbConnection ?: return null
val query = """
SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey,
DisplayName, CreatedAt, UpdatedAt, IsDeleted
FROM Passkeys
WHERE Id = ? AND IsDeleted = 0
LIMIT 1
""".trimIndent()
val cursor = db.rawQuery(query, arrayOf(passkeyId.toString().uppercase()))
cursor.use {
if (it.moveToFirst()) {
return parsePasskeyRow(it)
}
}
return null
}
// endregion
// region Passkey Storage
/**
* Insert a new passkey into the database.
*/
fun insertPasskey(passkey: Passkey) {
val db = database.dbConnection ?: error("Vault not unlocked")
val insert = """
INSERT INTO Passkeys (Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey,
DisplayName, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
val publicKeyString = String(passkey.publicKey, Charsets.UTF_8)
val privateKeyString = String(passkey.privateKey, Charsets.UTF_8)
db.execSQL(
insert,
arrayOf(
passkey.id.toString().uppercase(),
passkey.parentCredentialId.toString().uppercase(),
passkey.rpId,
passkey.userHandle,
publicKeyString,
privateKeyString,
passkey.prfKey,
passkey.displayName,
DateHelpers.toStandardFormat(passkey.createdAt),
DateHelpers.toStandardFormat(passkey.updatedAt),
if (passkey.isDeleted) 1 else 0,
),
)
}
/**
* Create a new credential with an associated passkey.
*/
fun createCredentialWithPasskey(
rpId: String,
userName: String?,
displayName: String,
passkey: Passkey,
logo: ByteArray? = null,
): Credential {
val db = database.dbConnection ?: error("Vault not unlocked")
db.beginTransaction()
try {
val credentialId = passkey.parentCredentialId
val now = Date()
val timestamp = DateHelpers.toStandardFormat(now)
// Create a minimal service for the RP
val serviceId = UUID.randomUUID()
val serviceValues = ContentValues().apply {
put("Id", serviceId.toString().uppercase())
put("Name", displayName)
put("Url", "https://$rpId")
if (logo != null) {
put("Logo", logo)
} else {
putNull("Logo")
}
put("CreatedAt", timestamp)
put("UpdatedAt", timestamp)
put("IsDeleted", 0)
}
db.insert("Services", null, serviceValues)
// Create a minimal alias with empty fields and default birthdate
val aliasId = UUID.randomUUID()
val aliasInsert = """
INSERT INTO Aliases (Id, FirstName, LastName, NickName, BirthDate, Gender, Email,
CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
db.execSQL(
aliasInsert,
arrayOf(
aliasId.toString().uppercase(),
"",
"",
"",
DateHelpers.toStandardFormat(MIN_DATE),
"",
"",
timestamp,
timestamp,
0,
),
)
// Create the credential with the alias
val credentialInsert = """
INSERT INTO Credentials (Id, ServiceId, AliasId, Username, Notes, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
db.execSQL(
credentialInsert,
arrayOf(
credentialId.toString().uppercase(),
serviceId.toString().uppercase(),
aliasId.toString().uppercase(),
userName,
null,
timestamp,
timestamp,
0,
),
)
// Insert the passkey
insertPasskey(passkey)
// Commit transaction
database.commitTransaction()
// Return the credential
val service = Service(
id = serviceId,
name = displayName,
url = "https://$rpId",
logo = logo,
createdAt = now,
updatedAt = now,
isDeleted = false,
)
val alias = Alias(
id = aliasId,
gender = "",
firstName = "",
lastName = "",
nickName = "",
birthDate = MIN_DATE,
email = "",
createdAt = now,
updatedAt = now,
isDeleted = false,
)
return Credential(
id = credentialId,
alias = alias,
service = service,
username = userName,
notes = null,
password = null,
createdAt = now,
updatedAt = now,
isDeleted = false,
)
} catch (e: Exception) {
db.endTransaction()
throw e
}
}
/**
* Replace an existing passkey with a new one.
*/
fun replacePasskey(
oldPasskeyId: UUID,
newPasskey: Passkey,
displayName: String,
logo: ByteArray? = null,
) {
val db = database.dbConnection ?: error("Vault not unlocked")
val now = Date()
val timestamp = DateHelpers.toStandardFormat(now)
// Get the old passkey to find its credential
val oldPasskey = getPasskeyById(oldPasskeyId)
?: throw VaultPasskeyError.PasskeyNotFound("Passkey not found: $oldPasskeyId")
val credentialId = oldPasskey.parentCredentialId
// Update the credential's service with new logo if provided
if (logo != null) {
val credQuery = """
SELECT ServiceId FROM Credentials WHERE Id = ? LIMIT 1
""".trimIndent()
val cursor = db.rawQuery(credQuery, arrayOf(credentialId.toString().uppercase()))
cursor.use {
if (it.moveToFirst()) {
val serviceId = it.getString(0)
val serviceUpdate = """
UPDATE Services
SET Logo = ?, Name = ?, UpdatedAt = ?
WHERE Id = ?
""".trimIndent()
db.execSQL(serviceUpdate, arrayOf(logo, displayName, timestamp, serviceId))
}
}
}
// Delete the old passkey
val deleteQuery = """
UPDATE Passkeys
SET IsDeleted = 1, UpdatedAt = ?
WHERE Id = ?
""".trimIndent()
db.execSQL(deleteQuery, arrayOf(timestamp, oldPasskeyId.toString().uppercase()))
// Create the new passkey with the same credential ID
val updatedPasskey = newPasskey.copy(
parentCredentialId = credentialId,
displayName = displayName,
createdAt = now,
updatedAt = now,
isDeleted = false,
)
insertPasskey(updatedPasskey)
}
// endregion
// region Helper Methods
/**
* Parse a passkey row from database query.
*/
private fun parsePasskeyRow(cursor: Cursor): Passkey? {
try {
val idString = cursor.getString(0)
val parentCredentialIdString = cursor.getString(1)
val rpId = cursor.getString(2)
val userHandle = if (!cursor.isNull(3)) cursor.getBlob(3) else null
val publicKeyString = cursor.getString(4)
val privateKeyString = cursor.getString(5)
val prfKey = if (!cursor.isNull(6)) cursor.getBlob(6) else null
val displayName = cursor.getString(7)
val createdAtString = cursor.getString(8)
val updatedAtString = cursor.getString(9)
val isDeleted = cursor.getInt(10) == 1
val id = UUID.fromString(idString)
val parentCredentialId = UUID.fromString(parentCredentialIdString)
val createdAt = DateHelpers.parseDateString(createdAtString) ?: MIN_DATE
val updatedAt = DateHelpers.parseDateString(updatedAtString) ?: MIN_DATE
val publicKeyData = publicKeyString.toByteArray(Charsets.UTF_8)
val privateKeyData = privateKeyString.toByteArray(Charsets.UTF_8)
return Passkey(
id = id,
parentCredentialId = parentCredentialId,
rpId = rpId,
userHandle = userHandle,
userName = null,
publicKey = publicKeyData,
privateKey = privateKeyData,
prfKey = prfKey,
displayName = displayName,
createdAt = createdAt,
updatedAt = updatedAt,
isDeleted = isDeleted,
)
} catch (e: Exception) {
Log.e(TAG, "Error parsing passkey row", e)
return null
}
}
/**
* Parse a passkey row from a JOIN query.
*/
private fun parsePasskeyRowFromJoin(cursor: Cursor): Passkey? {
try {
val idString = cursor.getString(0)
val parentCredentialIdString = cursor.getString(1)
val rpId = cursor.getString(2)
val userHandle = if (!cursor.isNull(3)) cursor.getBlob(3) else null
val publicKeyString = cursor.getString(4)
val privateKeyString = cursor.getString(5)
val prfKey = if (!cursor.isNull(6)) cursor.getBlob(6) else null
val displayName = cursor.getString(7)
val createdAtString = cursor.getString(8)
val updatedAtString = cursor.getString(9)
val isDeleted = cursor.getInt(10) == 1
val id = UUID.fromString(idString)
val parentCredentialId = UUID.fromString(parentCredentialIdString)
val createdAt = DateHelpers.parseDateString(createdAtString) ?: MIN_DATE
val updatedAt = DateHelpers.parseDateString(updatedAtString) ?: MIN_DATE
val publicKeyData = publicKeyString.toByteArray(Charsets.UTF_8)
val privateKeyData = privateKeyString.toByteArray(Charsets.UTF_8)
return Passkey(
id = id,
parentCredentialId = parentCredentialId,
rpId = rpId,
userHandle = userHandle,
userName = null,
publicKey = publicKeyData,
privateKey = privateKeyData,
prfKey = prfKey,
displayName = displayName,
createdAt = createdAt,
updatedAt = updatedAt,
isDeleted = isDeleted,
)
} catch (e: Exception) {
Log.e(TAG, "Error parsing passkey row from JOIN", e)
return null
}
}
// endregion
}
/**
* Data class to hold passkey with credential info.
*/
data class PasskeyWithCredentialInfo(
/** The passkey. */
val passkey: Passkey,
/** The service name from the credential. */
val serviceName: String?,
/** The username from the credential. */
val username: String?,
)
/**
* Data class to hold passkey with its associated credential.
*/
data class PasskeyWithCredential(
/** The passkey. */
val passkey: Passkey,
/** The credential this passkey belongs to. */
val credential: Credential,
)
/**
* VaultPasskey-specific errors.
*/
sealed class VaultPasskeyError(message: String) : Exception(message) {
/**
* Error indicating vault is not unlocked.
*/
class VaultNotUnlocked(message: String) : VaultPasskeyError(message)
/**
* Error indicating passkey was not found.
*/
class PasskeyNotFound(message: String) : VaultPasskeyError(message)
/**
* Error indicating credential was not found.
*/
class CredentialNotFound(message: String) : VaultPasskeyError(message)
/**
* Error indicating a database operation failure.
*/
class DatabaseError(message: String) : VaultPasskeyError(message)
}

View File

@@ -70,6 +70,7 @@ class VaultStore(
private val sync = VaultSync(databaseComponent, metadata, crypto)
private val mutate = VaultMutate(databaseComponent, query, metadata)
private val cache = VaultCache(crypto, databaseComponent, keystoreProvider, storageProvider)
private val passkey = VaultPasskey(databaseComponent)
// endregion
@@ -445,6 +446,105 @@ class VaultStore(
// endregion
// region Passkey Methods
/**
* Get a passkey by its credential ID (the WebAuthn credential ID).
*/
fun getPasskeyByCredentialId(credentialId: ByteArray): net.aliasvault.app.vaultstore.models.Passkey? {
return passkey.getPasskeyByCredentialId(credentialId)
}
/**
* Get all passkeys for a credential.
*/
@Suppress("UnusedParameter")
fun getPasskeysForCredential(
credentialId: java.util.UUID,
db: android.database.sqlite.SQLiteDatabase,
): List<net.aliasvault.app.vaultstore.models.Passkey> {
return passkey.getPasskeysForCredential(credentialId)
}
/**
* Get all passkeys for a specific relying party identifier (RP ID).
*/
@Suppress("UnusedParameter")
fun getPasskeysForRpId(
rpId: String,
db: android.database.sqlite.SQLiteDatabase,
): List<net.aliasvault.app.vaultstore.models.Passkey> {
return passkey.getPasskeysForRpId(rpId)
}
/**
* Get passkeys with credential info for a specific rpId.
*/
@Suppress("UnusedParameter")
fun getPasskeysWithCredentialInfo(
rpId: String,
userName: String? = null,
userId: ByteArray? = null,
db: android.database.sqlite.SQLiteDatabase,
): List<PasskeyWithCredentialInfo> {
return passkey.getPasskeysWithCredentialInfo(rpId, userName, userId)
}
/**
* Get all passkeys with their associated credentials in a single query.
*/
fun getAllPasskeysWithCredentials(): List<PasskeyWithCredential> {
return passkey.getAllPasskeysWithCredentials()
}
/**
* Get a passkey by its ID.
*/
@Suppress("UnusedParameter")
fun getPasskeyById(
passkeyId: java.util.UUID,
db: android.database.sqlite.SQLiteDatabase,
): net.aliasvault.app.vaultstore.models.Passkey? {
return passkey.getPasskeyById(passkeyId)
}
/**
* Insert a new passkey into the database.
*/
@Suppress("UnusedParameter")
fun insertPasskey(passkeyObj: net.aliasvault.app.vaultstore.models.Passkey, db: android.database.sqlite.SQLiteDatabase) {
passkey.insertPasskey(passkeyObj)
}
/**
* Create a credential with a passkey.
*/
fun createCredentialWithPasskey(
rpId: String,
userName: String?,
displayName: String,
passkeyObj: net.aliasvault.app.vaultstore.models.Passkey,
logo: ByteArray? = null,
): net.aliasvault.app.vaultstore.models.Credential {
return passkey.createCredentialWithPasskey(rpId, userName, displayName, passkeyObj, logo)
}
/**
* Replace an existing passkey with a new one.
*/
@Suppress("UnusedParameter")
fun replacePasskey(
oldPasskeyId: java.util.UUID,
newPasskey: net.aliasvault.app.vaultstore.models.Passkey,
displayName: String,
logo: ByteArray? = null,
db: android.database.sqlite.SQLiteDatabase,
) {
passkey.replacePasskey(oldPasskeyId, newPasskey, displayName, logo)
}
// endregion
// region Cache Methods
/**

View File

@@ -1,514 +0,0 @@
package net.aliasvault.app.vaultstore
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.util.Log
import net.aliasvault.app.utils.DateHelpers
import net.aliasvault.app.vaultstore.models.Alias
import net.aliasvault.app.vaultstore.models.Credential
import net.aliasvault.app.vaultstore.models.Passkey
import net.aliasvault.app.vaultstore.models.Service
import net.aliasvault.app.vaultstore.passkey.PasskeyHelper
import java.util.Calendar
import java.util.Date
import java.util.TimeZone
import java.util.UUID
/**
* VaultStorePasskey
* Extension methods for VaultStore to handle passkey operations
*
* This is a Kotlin port of the iOS Swift implementation:
* - Reference: apps/mobile-app/ios/VaultStoreKit/VaultStore+Passkey.swift
*
* IMPORTANT: Keep all implementations synchronized. Changes to the public interface must be
* reflected in all ports. Method names, parameters, and behavior should remain consistent.
*/
private const val TAG = "VaultStorePasskey"
/**
* Minimum date definition for default values.
*/
private val MIN_DATE: Date = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply {
set(Calendar.YEAR, 1)
set(Calendar.MONTH, Calendar.JANUARY)
set(Calendar.DAY_OF_MONTH, 1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.time
/**
* Get a passkey by its credential ID (the WebAuthn credential ID, not the parent Credential UUID).
*/
fun VaultStore.getPasskeyByCredentialId(credentialId: ByteArray, db: SQLiteDatabase): Passkey? {
// Convert credentialId bytes to UUID string for lookup
val credentialIdString = try {
PasskeyHelper.bytesToGuid(credentialId)
} catch (e: Exception) {
Log.e(TAG, "Failed to convert credentialId bytes to UUID string", e)
return null
}
val query = """
SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey,
DisplayName, CreatedAt, UpdatedAt, IsDeleted
FROM Passkeys
WHERE Id = ? AND IsDeleted = 0
LIMIT 1
""".trimIndent()
val cursor = db.rawQuery(query, arrayOf(credentialIdString.uppercase()))
cursor.use {
if (it.moveToFirst()) {
return parsePasskeyRow(it)
}
}
return null
}
/**
* Get all passkeys for a credential.
*/
fun VaultStore.getPasskeysForCredential(credentialId: UUID, db: SQLiteDatabase): List<Passkey> {
val query = """
SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey,
DisplayName, CreatedAt, UpdatedAt, IsDeleted
FROM Passkeys
WHERE CredentialId = ? AND IsDeleted = 0
ORDER BY CreatedAt DESC
""".trimIndent()
val passkeys = mutableListOf<Passkey>()
val cursor = db.rawQuery(query, arrayOf(credentialId.toString().uppercase()))
cursor.use {
while (it.moveToNext()) {
parsePasskeyRow(it)?.let { passkey ->
passkeys.add(passkey)
}
}
}
return passkeys
}
/**
* Get all passkeys for a specific relying party identifier (RP ID).
*/
fun VaultStore.getPasskeysForRpId(rpId: String, db: SQLiteDatabase): List<Passkey> {
val query = """
SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey,
DisplayName, CreatedAt, UpdatedAt, IsDeleted
FROM Passkeys
WHERE RpId = ? AND IsDeleted = 0
ORDER BY CreatedAt DESC
""".trimIndent()
val passkeys = mutableListOf<Passkey>()
val cursor = db.rawQuery(query, arrayOf(rpId))
cursor.use {
while (it.moveToNext()) {
parsePasskeyRow(it)?.let { passkey ->
passkeys.add(passkey)
}
}
}
return passkeys
}
/**
* Data class to hold passkey with credential info.
*/
data class PasskeyWithCredentialInfo(
/** The passkey. */
val passkey: Passkey,
/** The service name from the credential. */
val serviceName: String?,
/** The username from the credential. */
val username: String?,
)
/**
* Get passkeys with credential info for a specific rpId and optionally username.
* Used for finding existing passkeys that might be replaced during registration.
*/
fun VaultStore.getPasskeysWithCredentialInfo(
rpId: String,
userName: String? = null,
userId: ByteArray? = null,
db: SQLiteDatabase,
): List<PasskeyWithCredentialInfo> {
val query = """
SELECT p.Id, p.CredentialId, p.RpId, p.UserHandle, p.PublicKey, p.PrivateKey, p.PrfKey,
p.DisplayName, p.CreatedAt, p.UpdatedAt, p.IsDeleted,
c.Username, s.Name
FROM Passkeys p
JOIN Credentials c ON p.CredentialId = c.Id
JOIN Services s ON c.ServiceId = s.Id
WHERE p.RpId = ? AND p.IsDeleted = 0 AND c.IsDeleted = 0
ORDER BY p.CreatedAt DESC
""".trimIndent()
val results = mutableListOf<PasskeyWithCredentialInfo>()
val cursor = db.rawQuery(query, arrayOf(rpId))
cursor.use {
while (it.moveToNext()) {
val passkey = parsePasskeyRow(it) ?: continue
val credUsername = if (!it.isNull(11)) it.getString(11) else null
val serviceName = if (!it.isNull(12)) it.getString(12) else null
// Filter by username or userId if provided
var matches = true
if (userName != null && credUsername != userName) {
matches = false
}
if (userId != null && passkey.userHandle != null && !userId.contentEquals(passkey.userHandle)) {
matches = false
}
if (matches) {
results.add(
PasskeyWithCredentialInfo(
passkey = passkey,
serviceName = serviceName,
username = credUsername,
),
)
}
}
}
return results
}
/**
* Insert a new passkey into the database.
*/
fun VaultStore.insertPasskey(passkey: Passkey, db: SQLiteDatabase) {
val insert = """
INSERT INTO Passkeys (Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey,
DisplayName, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
val publicKeyString = String(passkey.publicKey, Charsets.UTF_8)
val privateKeyString = String(passkey.privateKey, Charsets.UTF_8)
db.execSQL(
insert,
arrayOf(
passkey.id.toString().uppercase(),
passkey.parentCredentialId.toString().uppercase(),
passkey.rpId,
passkey.userHandle,
publicKeyString,
privateKeyString,
passkey.prfKey,
passkey.displayName,
DateHelpers.toStandardFormat(passkey.createdAt),
DateHelpers.toStandardFormat(passkey.updatedAt),
if (passkey.isDeleted) 1 else 0,
),
)
}
/**
* Create a new credential with an associated passkey.
* This method handles the database transaction internally.
*/
fun VaultStore.createCredentialWithPasskey(
rpId: String,
userName: String?,
displayName: String,
passkey: Passkey,
logo: ByteArray? = null,
): Credential {
val db = database ?: error("Vault not unlocked")
db.beginTransaction()
try {
val credentialId = passkey.parentCredentialId
val now = Date()
val timestamp = DateHelpers.toStandardFormat(now)
// Create a minimal service for the RP
val serviceId = UUID.randomUUID()
// Use ContentValues to properly handle BLOB type for logo
val serviceValues = android.content.ContentValues().apply {
put("Id", serviceId.toString().uppercase())
put("Name", displayName)
put("Url", "https://$rpId")
if (logo != null) {
put("Logo", logo) // ContentValues handles ByteArray -> BLOB correctly
} else {
putNull("Logo")
}
put("CreatedAt", timestamp)
put("UpdatedAt", timestamp)
put("IsDeleted", 0)
}
db.insert("Services", null, serviceValues)
// Create a minimal alias with empty fields and default birthdate
val aliasId = UUID.randomUUID()
val aliasInsert = """
INSERT INTO Aliases (Id, FirstName, LastName, NickName, BirthDate, Gender, Email,
CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
db.execSQL(
aliasInsert,
arrayOf(
aliasId.toString().uppercase(),
"",
"",
"",
DateHelpers.toStandardFormat(MIN_DATE),
"",
"",
timestamp,
timestamp,
0,
),
)
// Create the credential with the alias
val credentialInsert = """
INSERT INTO Credentials (Id, ServiceId, AliasId, Username, Notes, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
db.execSQL(
credentialInsert,
arrayOf(
credentialId.toString().uppercase(),
serviceId.toString().uppercase(),
aliasId.toString().uppercase(),
userName,
null,
timestamp,
timestamp,
0,
),
)
// Insert the passkey
insertPasskey(passkey, db)
// Return the credential
val service = Service(
id = serviceId,
name = displayName,
url = "https://$rpId",
logo = logo,
createdAt = now,
updatedAt = now,
isDeleted = false,
)
val alias = Alias(
id = aliasId,
gender = "",
firstName = "",
lastName = "",
nickName = "",
birthDate = MIN_DATE,
email = "",
createdAt = now,
updatedAt = now,
isDeleted = false,
)
// Commit transaction and persist to encrypted vault file
commitTransaction()
return Credential(
id = credentialId,
alias = alias,
service = service,
username = userName,
notes = null,
password = null,
createdAt = now,
updatedAt = now,
isDeleted = false,
)
} catch (e: Exception) {
// Rollback on error
db.endTransaction()
throw e
}
}
/**
* Replace an existing passkey with a new one.
* This deletes the old passkey and creates a new one with the same credential.
*/
fun VaultStore.replacePasskey(
oldPasskeyId: UUID,
newPasskey: Passkey,
displayName: String,
logo: ByteArray? = null,
db: SQLiteDatabase,
) {
val now = Date()
val timestamp = DateHelpers.toStandardFormat(now)
// Get the old passkey to find its credential
val oldPasskey = getPasskeyById(oldPasskeyId, db)
?: throw VaultStorePasskeyError.PasskeyNotFound("Passkey not found: $oldPasskeyId")
val credentialId = oldPasskey.parentCredentialId
// Update the credential's service with new logo if provided
if (logo != null) {
// Get the service ID from the credential
val credQuery = """
SELECT ServiceId FROM Credentials WHERE Id = ? LIMIT 1
""".trimIndent()
val cursor = db.rawQuery(credQuery, arrayOf(credentialId.toString().uppercase()))
cursor.use {
if (it.moveToFirst()) {
val serviceId = it.getString(0)
// Update the service with new logo and displayName
val serviceUpdate = """
UPDATE Services
SET Logo = ?, Name = ?, UpdatedAt = ?
WHERE Id = ?
""".trimIndent()
db.execSQL(serviceUpdate, arrayOf(logo, displayName, timestamp, serviceId))
}
}
}
// Delete the old passkey
val deleteQuery = """
UPDATE Passkeys
SET IsDeleted = 1, UpdatedAt = ?
WHERE Id = ?
""".trimIndent()
db.execSQL(deleteQuery, arrayOf(timestamp, oldPasskeyId.toString().uppercase()))
// Create the new passkey with the same credential ID
val updatedPasskey = newPasskey.copy(
parentCredentialId = credentialId,
displayName = displayName,
createdAt = now,
updatedAt = now,
isDeleted = false,
)
insertPasskey(updatedPasskey, db)
}
/**
* Get a passkey by its ID.
*/
fun VaultStore.getPasskeyById(passkeyId: UUID, db: SQLiteDatabase): Passkey? {
val query = """
SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey,
DisplayName, CreatedAt, UpdatedAt, IsDeleted
FROM Passkeys
WHERE Id = ? AND IsDeleted = 0
LIMIT 1
""".trimIndent()
// Always convert the UUID to uppercase when querying the DB
val upperPasskeyId = passkeyId.toString().uppercase()
val cursor = db.rawQuery(query, arrayOf(upperPasskeyId))
cursor.use {
if (it.moveToFirst()) {
return parsePasskeyRow(it)
}
}
return null
}
/**
* Parse a passkey row from database query.
*/
private fun parsePasskeyRow(cursor: Cursor): Passkey? {
try {
val idString = cursor.getString(0)
val parentCredentialIdString = cursor.getString(1)
val rpId = cursor.getString(2)
val userHandle = if (!cursor.isNull(3)) cursor.getBlob(3) else null
val publicKeyString = cursor.getString(4)
val privateKeyString = cursor.getString(5)
val prfKey = if (!cursor.isNull(6)) cursor.getBlob(6) else null
val displayName = cursor.getString(7)
val createdAtString = cursor.getString(8)
val updatedAtString = cursor.getString(9)
val isDeleted = cursor.getInt(10) == 1
// Parse UUIDs
val id = UUID.fromString(idString)
val parentCredentialId = UUID.fromString(parentCredentialIdString)
// Parse dates
val createdAt = DateHelpers.parseDateString(createdAtString) ?: MIN_DATE
val updatedAt = DateHelpers.parseDateString(updatedAtString) ?: MIN_DATE
// Parse public/private keys
val publicKeyData = publicKeyString.toByteArray(Charsets.UTF_8)
val privateKeyData = privateKeyString.toByteArray(Charsets.UTF_8)
return Passkey(
id = id,
parentCredentialId = parentCredentialId,
rpId = rpId,
userHandle = userHandle,
userName = null, // userName not stored in DB, derived from parent credential
publicKey = publicKeyData,
privateKey = privateKeyData,
prfKey = prfKey,
displayName = displayName,
createdAt = createdAt,
updatedAt = updatedAt,
isDeleted = isDeleted,
)
} catch (e: Exception) {
Log.e(TAG, "Error parsing passkey row", e)
return null
}
}
/**
* VaultStore passkey-specific errors.
*/
sealed class VaultStorePasskeyError(message: String) : Exception(message) {
/**
* Error indicating vault is not unlocked.
*/
class VaultNotUnlocked(message: String) : VaultStorePasskeyError(message)
/**
* Error indicating passkey was not found.
*/
class PasskeyNotFound(message: String) : VaultStorePasskeyError(message)
/**
* Error indicating credential was not found.
*/
class CredentialNotFound(message: String) : VaultStorePasskeyError(message)
/**
* Error indicating a database operation failure.
*/
class DatabaseError(message: String) : VaultStorePasskeyError(message)
}

View File

@@ -3,6 +3,8 @@ package net.aliasvault.app.vaultstore
import android.util.Log
import net.aliasvault.app.exceptions.SerializationException
import net.aliasvault.app.exceptions.VaultOperationException
import net.aliasvault.app.utils.AppInfo
import net.aliasvault.app.vaultstore.utils.VersionComparison
import org.json.JSONObject
/**

View File

@@ -1,4 +1,6 @@
package net.aliasvault.app.vaultstore
package net.aliasvault.app.vaultstore.utils
import net.aliasvault.app.utils.AppInfo
/**
* Utility for comparing semantic version strings