Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,50 @@ package de.davis.keygo.core.item.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import de.davis.keygo.core.item.data.local.entity.credential.PasskeyEntity
import de.davis.keygo.core.item.data.local.pojo.PasskeyMetadataPojo
import de.davis.keygo.core.item.domain.alias.ItemId

@Dao
internal interface PasskeyDao {
internal abstract class PasskeyDao {

@Insert
suspend fun insertPasskey(passkey: PasskeyEntity)
abstract suspend fun insertPasskey(passkey: PasskeyEntity)

@Query("SELECT * FROM passkey WHERE credential_id = :credentialId")
suspend fun getPasskey(credentialId: ByteArray): PasskeyEntity?
abstract suspend fun getPasskey(credentialId: ByteArray): PasskeyEntity?

@Query("SELECT * FROM passkey WHERE login_id = :loginId")
suspend fun getPasskeysForLogin(loginId: ItemId): List<PasskeyEntity>
abstract suspend fun getPasskeysForLogin(loginId: ItemId): List<PasskeyEntity>

@Query("DELETE FROM passkey WHERE login_id = :loginId")
protected abstract suspend fun deleteAllPasskeysForLogin(loginId: ItemId)

@Query("DELETE FROM passkey WHERE login_id = :loginId AND credential_id NOT IN (:credentialIds)")
protected abstract suspend fun deletePasskeysForLoginNotIn(
loginId: ItemId,
credentialIds: Collection<ByteArray>,
)

/**
* Drops every passkey [loginId] holds whose credential id is outside [credentialIds].
*
* Keyed on the credential id rather than the relying party: one login can hold two credentials
* for the same site, and deleting by relying party would take both when the user only asked for
* one.
*
* Deletes only. A passkey row carries key material, so it can not be reconstructed from a
* credential id and this can never put one back.
*/
@Transaction
open suspend fun deleteCredentialsNotIn(loginId: ItemId, credentialIds: Collection<ByteArray>) {
if (credentialIds.isEmpty()) deleteAllPasskeysForLogin(loginId)
else deletePasskeysForLoginNotIn(loginId, credentialIds)
}

@Query("SELECT EXISTS (SELECT 1 FROM passkey WHERE credential_id IN (:credentialIds))")
suspend fun doesCredentialIdsExist(credentialIds: Set<ByteArray>): Boolean
abstract suspend fun doesCredentialIdsExist(credentialIds: Set<ByteArray>): Boolean

@Query(
"""
Expand All @@ -31,5 +57,5 @@ internal interface PasskeyDao {
WHERE pk.rp = :rpId
"""
)
suspend fun getPasskeysForRP(rpId: String): List<PasskeyMetadataPojo>
abstract suspend fun getPasskeysForRP(rpId: String): List<PasskeyMetadataPojo>
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ internal data class LoginProjection(
entityColumn = "login_id",
entity = PasskeyEntity::class
)
val rpEntity: List<RP>,
val passkeys: List<PasskeyRefPojo>,

@Relation(
parentColumn = "id",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package de.davis.keygo.core.item.data.local.pojo

import androidx.room.ColumnInfo

/** The columns of a passkey row that identify it, without touching its key material. */
internal data class PasskeyRefPojo(
@ColumnInfo(name = "credential_id")
val credentialId: ByteArray,
val rp: String,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is PasskeyRefPojo) return false

return credentialId.contentEquals(other.credentialId) && rp == other.rp
}

override fun hashCode(): Int = 31 * credentialId.contentHashCode() + rp.hashCode()
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import de.davis.keygo.core.item.data.local.entity.credential.PasswordEntity
import de.davis.keygo.core.item.data.local.pojo.LightweightLogin
import de.davis.keygo.core.item.data.local.pojo.LoginProjection
import de.davis.keygo.core.item.domain.model.Login
import de.davis.keygo.core.item.domain.model.PasskeyRef
import de.davis.keygo.core.item.domain.model.PasswordCredential
import de.davis.keygo.core.item.domain.model.PasswordSecret
import de.davis.keygo.core.item.domain.model.lite.LiteLogin
Expand Down Expand Up @@ -35,7 +36,7 @@ internal fun LoginProjection.toDomain(): Login = Login(
)
},
totp = totp?.toDomain(),
passkeyRPs = rpEntity.map { it.rp }.toSet(),
passkeys = passkeys.mapTo(mutableSetOf()) { PasskeyRef(it.credentialId, it.rp) },
domainInfos = domains.map(DomainInfoEntity::toDomain).toSet(),
vaultId = item.itemEntity.vaultId,
name = item.itemEntity.name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.room.withTransaction
import de.davis.keygo.core.item.data.local.dao.DomainInfoDao
import de.davis.keygo.core.item.data.local.dao.ItemDao
import de.davis.keygo.core.item.data.local.dao.LoginDao
import de.davis.keygo.core.item.data.local.dao.PasskeyDao
import de.davis.keygo.core.item.data.local.dao.PasswordDao
import de.davis.keygo.core.item.data.local.dao.TagDao
import de.davis.keygo.core.item.data.local.dao.TotpDao
Expand Down Expand Up @@ -38,6 +39,7 @@ internal class LoginRepositoryImpl(
private val domainInfoDao: DomainInfoDao,
private val totpDao: TotpDao,
private val tagDao: TagDao,
private val passkeyDao: PasskeyDao,
) : LoginRepository {

override suspend fun createOrUpdateLogin(login: Login): Result<ItemId, Throwable> =
Expand All @@ -56,6 +58,10 @@ internal class LoginRepositoryImpl(
domainInfoDao.syncForLogin(login.id, login.toDomainInfoEntities())
tagDao.syncTags(login.id, login.tags.toTagEntities())

// Delete only: passkeys are created through PasskeyRepository, never from here.
// See Login.passkeys for why this set has to come from a fresh read.
passkeyDao.deleteCredentialsNotIn(login.id, login.passkeys.map { it.credentialId })

login.id
}
}.fold(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@ data class Login(
val domainInfos: Set<DomainInfo>,
val passwordCredential: PasswordCredential?,
val totp: Totp?,
val passkeyRPs: Set<String> = emptySet(),
/**
* Passkeys this login currently holds, one entry per credential.
*
* Has no default and is never inferred: an empty set means "this login holds no passkeys" and
* saving it deletes every passkey row the login has. A caller that does not know has to read
* them rather than leave them out.
*/
val passkeys: Set<PasskeyRef>,
override val vaultId: VaultId,
override val name: String,
override val keyInformation: KeyInformation,
Expand All @@ -30,5 +37,5 @@ data class Login(
get() = !username.isNullOrBlank()
|| passwordCredential != null
|| totp != null
|| passkeyRPs.isNotEmpty()
|| passkeys.isNotEmpty()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package de.davis.keygo.core.item.domain.model

/**
* A login's passkey, as much of it as anything outside the passkey table needs to know.
*
* The credential id is what identifies a passkey, not [rp]. One login can hold two credentials for
* the same relying party, for example two accounts on the same site, so listing or deleting them by
* relying party alone silently treats the two as one.
*
* Carries no key material. It is a handle onto a row, not the row itself.
*/
class PasskeyRef(
val credentialId: ByteArray,
val rp: String,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is PasskeyRef) return false

return credentialId.contentEquals(other.credentialId) && rp == other.rp
}

override fun hashCode(): Int = 31 * credentialId.contentHashCode() + rp.hashCode()

override fun toString(): String = "PasskeyRef(rp=$rp)"
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class DomainMapperTest {
score = PasswordScore.Strong,
),
totp = null,
passkeys = emptySet(),
vaultId = newVaultId(),
name = "Test",
keyInformation = KeyInformation(wrappedKey = byteArrayOf(), keyNonce = byteArrayOf()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class ItemMapperTest {
score = PasswordScore.Strong,
),
totp = null,
passkeys = emptySet(),
note = note,
pinned = pinned,
vaultId = newVaultId(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,30 @@
package de.davis.keygo.core.item.data.mapper

import de.davis.keygo.core.item.data.local.entity.ItemEntity
import de.davis.keygo.core.item.data.local.entity.KeyInformation as EntityKeyInformation
import de.davis.keygo.core.item.data.local.entity.LoginEntity
import de.davis.keygo.core.item.data.local.entity.TagEntity
import de.davis.keygo.core.item.data.local.entity.Timestamp as EntityTimestamp
import de.davis.keygo.core.item.data.local.entity.credential.PasswordEntity
import de.davis.keygo.core.item.data.local.pojo.ItemProjection
import de.davis.keygo.core.item.data.local.pojo.LoginProjection
import de.davis.keygo.core.item.data.local.pojo.PasskeyRefPojo
import de.davis.keygo.core.item.domain.alias.ItemId
import de.davis.keygo.core.item.domain.alias.newItemId
import de.davis.keygo.core.item.domain.alias.newVaultId
import de.davis.keygo.core.item.domain.model.EncryptedPayload
import de.davis.keygo.core.item.domain.model.KeyInformation
import de.davis.keygo.core.item.domain.model.Login
import de.davis.keygo.core.item.domain.model.PasskeyRef
import de.davis.keygo.core.item.domain.model.PasswordCredential
import de.davis.keygo.core.item.domain.model.PasswordScore
import de.davis.keygo.core.item.domain.model.PasswordSecret
import de.davis.keygo.core.item.domain.model.Tag
import de.davis.keygo.core.item.domain.model.Timestamp
import de.davis.keygo.core.item.generated.domain.model.VaultItemType
import de.davis.keygo.core.item.data.local.entity.Timestamp as EntityTimestamp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import de.davis.keygo.core.item.data.local.entity.KeyInformation as EntityKeyInformation

class LoginMapperTest {

Expand All @@ -33,6 +35,30 @@ class LoginMapperTest {
assertNull(login.passwordCredential)
}

@Test
fun `toDomain keeps both credentials a login holds for one relying party`() {
// Two accounts on the same site are two rows sharing an rp. Mapping them onto the rp alone
// collapsed them into a single entry, so the editor showed one chip for two passkeys and
// deleting it took both.
val projection = baseProjection(
passwordEntity = null,
passkeys = listOf(
PasskeyRefPojo(credentialId = byteArrayOf(1), rp = "example.com"),
PasskeyRefPojo(credentialId = byteArrayOf(2), rp = "example.com"),
),
)

val login = projection.toDomain()

assertEquals(
setOf(
PasskeyRef(credentialId = byteArrayOf(1), rp = "example.com"),
PasskeyRef(credentialId = byteArrayOf(2), rp = "example.com"),
),
login.passkeys,
)
}

@Test
fun `Login with null passwordCredential maps to null PasswordEntity`() {
val login = baseLogin(passwordCredential = null)
Expand Down Expand Up @@ -91,6 +117,7 @@ class LoginMapperTest {
id: ItemId = newItemId(),
passwordEntity: PasswordEntity?,
tags: Set<TagEntity> = emptySet(),
passkeys: List<PasskeyRefPojo> = emptyList(),
): LoginProjection = LoginProjection(
loginEntity = LoginEntity(id = id, username = "alice"),
item = ItemProjection(
Expand All @@ -110,7 +137,7 @@ class LoginMapperTest {
tags = tags,
),
passwordEntity = passwordEntity,
rpEntity = emptyList(),
passkeys = passkeys,
domains = emptyList(),
totp = null,
)
Expand All @@ -123,6 +150,7 @@ class LoginMapperTest {
domainInfos = emptySet(),
passwordCredential = passwordCredential,
totp = null,
passkeys = emptySet(),
vaultId = newVaultId(),
name = "Test",
keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()),
Expand Down
Loading
Loading