diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasskeyDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasskeyDao.kt index 0c9e18378..65af96f4a 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasskeyDao.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasskeyDao.kt @@ -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 + abstract suspend fun getPasskeysForLogin(loginId: ItemId): List + + @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, + ) + + /** + * 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) { + 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): Boolean + abstract suspend fun doesCredentialIdsExist(credentialIds: Set): Boolean @Query( """ @@ -31,5 +57,5 @@ internal interface PasskeyDao { WHERE pk.rp = :rpId """ ) - suspend fun getPasskeysForRP(rpId: String): List + abstract suspend fun getPasskeysForRP(rpId: String): List } diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LoginProjection.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LoginProjection.kt index 642d634e7..286e27235 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LoginProjection.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LoginProjection.kt @@ -32,7 +32,7 @@ internal data class LoginProjection( entityColumn = "login_id", entity = PasskeyEntity::class ) - val rpEntity: List, + val passkeys: List, @Relation( parentColumn = "id", diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/PasskeyRefPojo.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/PasskeyRefPojo.kt new file mode 100644 index 000000000..a634e1400 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/PasskeyRefPojo.kt @@ -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() +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/RP.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/RP.kt deleted file mode 100644 index e9140b327..000000000 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/RP.kt +++ /dev/null @@ -1,5 +0,0 @@ -package de.davis.keygo.core.item.data.local.pojo - -internal data class RP( - val rp: String -) \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapper.kt index a5ac86f6a..409f1f783 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapper.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapper.kt @@ -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 @@ -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, diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImpl.kt index b7d93e906..7c6f3f38e 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImpl.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImpl.kt @@ -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 @@ -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 = @@ -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( diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Login.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Login.kt index 0d1f63eec..16c488627 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Login.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Login.kt @@ -13,7 +13,14 @@ data class Login( val domainInfos: Set, val passwordCredential: PasswordCredential?, val totp: Totp?, - val passkeyRPs: Set = 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, override val vaultId: VaultId, override val name: String, override val keyInformation: KeyInformation, @@ -30,5 +37,5 @@ data class Login( get() = !username.isNullOrBlank() || passwordCredential != null || totp != null - || passkeyRPs.isNotEmpty() + || passkeys.isNotEmpty() } diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/PasskeyRef.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/PasskeyRef.kt new file mode 100644 index 000000000..a9a575bf7 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/PasskeyRef.kt @@ -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)" +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/DomainMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/DomainMapperTest.kt index 69102d4cf..aeeb19ff6 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/DomainMapperTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/DomainMapperTest.kt @@ -30,6 +30,7 @@ class DomainMapperTest { score = PasswordScore.Strong, ), totp = null, + passkeys = emptySet(), vaultId = newVaultId(), name = "Test", keyInformation = KeyInformation(wrappedKey = byteArrayOf(), keyNonce = byteArrayOf()), diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt index 3e495ed65..ef5897c42 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt @@ -37,6 +37,7 @@ class ItemMapperTest { score = PasswordScore.Strong, ), totp = null, + passkeys = emptySet(), note = note, pinned = pinned, vaultId = newVaultId(), diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapperTest.kt index c088dacb0..897040685 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapperTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapperTest.kt @@ -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 { @@ -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) @@ -91,6 +117,7 @@ class LoginMapperTest { id: ItemId = newItemId(), passwordEntity: PasswordEntity?, tags: Set = emptySet(), + passkeys: List = emptyList(), ): LoginProjection = LoginProjection( loginEntity = LoginEntity(id = id, username = "alice"), item = ItemProjection( @@ -110,7 +137,7 @@ class LoginMapperTest { tags = tags, ), passwordEntity = passwordEntity, - rpEntity = emptyList(), + passkeys = passkeys, domains = emptyList(), totp = null, ) @@ -123,6 +150,7 @@ class LoginMapperTest { domainInfos = emptySet(), passwordCredential = passwordCredential, totp = null, + passkeys = emptySet(), vaultId = newVaultId(), name = "Test", keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImplTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImplTest.kt index b9b6123f9..e0e284c38 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImplTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImplTest.kt @@ -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 @@ -17,12 +18,14 @@ 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.domain.model.Totp +import de.davis.keygo.core.item.passkeyRef import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess import io.mockk.coEvery @@ -30,12 +33,12 @@ import io.mockk.coVerify import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic -import kotlinx.coroutines.test.runTest import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest class LoginRepositoryImplTest { @@ -46,6 +49,7 @@ class LoginRepositoryImplTest { private val domainInfoDao = mockk(relaxed = true) private val totpDao = mockk(relaxed = true) private val tagDao = mockk(relaxed = true) + private val passkeyDao = mockk(relaxed = true) private val repository = LoginRepositoryImpl( database = database, @@ -55,6 +59,7 @@ class LoginRepositoryImplTest { domainInfoDao = domainInfoDao, totpDao = totpDao, tagDao = tagDao, + passkeyDao = passkeyDao, ) @BeforeTest @@ -208,12 +213,51 @@ class LoginRepositoryImplTest { assertEquals(error, result.error) } + @Test + fun `createOrUpdateLogin drops passkeys whose credential is absent from the login`() = runTest { + val kept = passkeyRef("example.org") + val login = testLogin(totpProvider = null, passkeys = setOf(kept)) + + val result = repository.createOrUpdateLogin(login) + + assertTrue(result.isSuccess()) + coVerify(exactly = 1) { + passkeyDao.deleteCredentialsNotIn(login.id, listOf(kept.credentialId)) + } + } + + @Test + fun `createOrUpdateLogin with no passkeys drops every passkey the login holds`() = runTest { + // An empty set is how "the last passkey was removed" is expressed. There is no way to + // distinguish it from "unknown", which is why Login.passkeys has no default. + val login = testLogin(totpProvider = null) + + val result = repository.createOrUpdateLogin(login) + + assertTrue(result.isSuccess()) + coVerify(exactly = 1) { passkeyDao.deleteCredentialsNotIn(login.id, emptyList()) } + } + + @Test + fun `createOrUpdateLogin returns Failure when the passkey delete throws`() = runTest { + // The delete shares the login's transaction, so a failure rolls the whole write back and + // surfaces as a Failure rather than escaping uncaught after the login row has landed. + val error = RuntimeException("db error") + coEvery { passkeyDao.deleteCredentialsNotIn(any(), any()) } throws error + + val result = repository.createOrUpdateLogin(testLogin(totpProvider = null)) + + assertTrue(result.isFailure()) + assertEquals(error, result.error) + } + private fun testLogin( passwordCredential: PasswordCredential? = PasswordCredential( secret = PasswordSecret(EncryptedPayload.EMPTY), score = PasswordScore.Strong, ), tags: Set = emptySet(), + passkeys: Set = emptySet(), totpProvider: ((ItemId) -> Totp)? = null, ): Login { val id = newItemId() @@ -223,6 +267,7 @@ class LoginRepositoryImplTest { domainInfos = emptySet(), passwordCredential = passwordCredential, totp = totpProvider?.invoke(id), + passkeys = passkeys, name = "Test", note = null, pinned = false, diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/LoginTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/LoginTest.kt index f0395d38b..ffe1b5948 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/LoginTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/LoginTest.kt @@ -2,6 +2,7 @@ package de.davis.keygo.core.item.domain.model import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.passkeyRef import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -51,8 +52,8 @@ class LoginTest { } @Test - fun `hasAnyContent is true when passkeyRPs is non-empty`() { - val login = baseLogin(passkeyRPs = setOf("example.com")) + fun `hasAnyContent is true when passkeys is non-empty`() { + val login = baseLogin(passkeys = setOf(passkeyRef("example.com"))) assertTrue(login.hasAnyContent) } @@ -60,14 +61,14 @@ class LoginTest { username: String? = null, passwordCredential: PasswordCredential? = null, totp: Totp? = null, - passkeyRPs: Set = emptySet(), + passkeys: Set = emptySet(), ): Login = Login( id = newItemId(), username = username, domainInfos = emptySet(), passwordCredential = passwordCredential, totp = totp, - passkeyRPs = passkeyRPs, + passkeys = passkeys, vaultId = newVaultId(), name = "Test", keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt index 7dad88e28..996fd9b7f 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt @@ -29,6 +29,7 @@ class ObserveAllTagsSortedUseCaseTest { domainInfos = emptySet(), passwordCredential = null, totp = null, + passkeys = emptySet(), vaultId = newVaultId(), name = name, keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt index eb303355a..b8ffb17cd 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt @@ -36,6 +36,7 @@ class UpsertItemUseCaseTest { score = PasswordScore.Strong, ), totp = null, + passkeys = emptySet(), name = name, note = null, pinned = false, diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/PasskeyRefs.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/PasskeyRefs.kt new file mode 100644 index 000000000..99845e6e8 --- /dev/null +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/PasskeyRefs.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.core.item + +import de.davis.keygo.core.item.domain.model.PasskeyRef + +/** + * A [PasskeyRef] for [rp] with a credential id derived from [discriminator]. + * + * Two calls with the same [rp] and different discriminators produce the two distinct credentials a + * login can hold for one site, which is the case that distinguishes deleting by credential id from + * deleting by relying party. + */ +fun passkeyRef(rp: String, discriminator: String = ""): PasskeyRef = PasskeyRef( + credentialId = "$rp/$discriminator".toByteArray(), + rp = rp, +) diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/text/HtmlStringResource.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/text/HtmlStringResource.kt new file mode 100644 index 000000000..efdfaec84 --- /dev/null +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/text/HtmlStringResource.kt @@ -0,0 +1,24 @@ +package de.davis.keygo.core.ui.text + +import android.text.TextUtils +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml + +/** + * Resolves the HTML-formatted string resource [id] and parses its markup into an [AnnotatedString]. + * + * Every entry of [formatArgs] is HTML-escaped before it is substituted, so only the resource itself + * can contribute markup. An argument that is not ours to trust, such as a relying party id taken + * verbatim from an incoming credential request, therefore renders as plain text instead of turning + * into a link or breaking out of the resource's own formatting. + */ +@Composable +@ReadOnlyComposable +fun htmlStringResource(@StringRes id: Int, vararg formatArgs: String): AnnotatedString = + AnnotatedString.fromHtml( + stringResource(id, *Array(formatArgs.size) { TextUtils.htmlEncode(formatArgs[it]) }), + ) diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AddRegistrableDomainsToLoginUseCaseTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AddRegistrableDomainsToLoginUseCaseTest.kt index df94c45c6..f211964ce 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AddRegistrableDomainsToLoginUseCaseTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AddRegistrableDomainsToLoginUseCaseTest.kt @@ -39,6 +39,7 @@ class AddRegistrableDomainsToLoginUseCaseTest { domainInfos = domainInfos, passwordCredential = null, totp = null, + passkeys = emptySet(), note = null, pinned = false, vaultId = vaultId, diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/DoesItemHaveDomainReferencesUseCaseTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/DoesItemHaveDomainReferencesUseCaseTest.kt index 3b5ede7cd..3f58d2f27 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/DoesItemHaveDomainReferencesUseCaseTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/domain/usecase/DoesItemHaveDomainReferencesUseCaseTest.kt @@ -40,6 +40,7 @@ class DoesItemHaveDomainReferencesUseCaseTest { domainInfos = domainInfos, passwordCredential = null, totp = null, + passkeys = emptySet(), note = null, pinned = false, vaultId = vaultId, diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt index cbf700ff0..fb8038195 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModelTest.kt @@ -143,6 +143,7 @@ internal class AutofillViewModelTest { domainInfos = domainInfos, passwordCredential = null, totp = null, + passkeys = emptySet(), vaultId = newVaultId(), name = name, keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), diff --git a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/SuggestionFinderTest.kt b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/SuggestionFinderTest.kt index b15a21b7e..d0247ccd8 100644 --- a/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/SuggestionFinderTest.kt +++ b/feature/autofill/src/test/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/SuggestionFinderTest.kt @@ -54,6 +54,7 @@ internal class SuggestionFinderTest { domainInfos = setOf(DomainInfo(value = "https://example.com", eTLD1 = "example.com")), passwordCredential = null, totp = null, + passkeys = emptySet(), vaultId = vaultId, name = "UserOnly", keyInformation = keyInfo, @@ -67,6 +68,7 @@ internal class SuggestionFinderTest { domainInfos = setOf(DomainInfo(value = "https://example.com", eTLD1 = "example.com")), passwordCredential = minimalPassword, totp = null, + passkeys = emptySet(), vaultId = vaultId, name = "PassOnly", keyInformation = keyInfo, @@ -80,6 +82,7 @@ internal class SuggestionFinderTest { domainInfos = setOf(DomainInfo(value = "https://example.com", eTLD1 = "example.com")), passwordCredential = minimalPassword, totp = null, + passkeys = emptySet(), vaultId = vaultId, name = "Both", keyInformation = keyInfo, @@ -93,6 +96,7 @@ internal class SuggestionFinderTest { domainInfos = setOf(DomainInfo(value = "https://other.com", eTLD1 = "other.com")), passwordCredential = null, totp = minimalTotp, + passkeys = emptySet(), vaultId = vaultId, name = "Neither", keyInformation = keyInfo, @@ -196,6 +200,7 @@ internal class SuggestionFinderTest { domainInfos = setOf(DomainInfo(value = "https://example.com", eTLD1 = "example.com")), passwordCredential = null, totp = null, + passkeys = emptySet(), vaultId = vaultId, name = "Extra1", keyInformation = keyInfo, @@ -209,6 +214,7 @@ internal class SuggestionFinderTest { domainInfos = setOf(DomainInfo(value = "https://example.com", eTLD1 = "example.com")), passwordCredential = null, totp = null, + passkeys = emptySet(), vaultId = vaultId, name = "Extra2", keyInformation = keyInfo, @@ -222,6 +228,7 @@ internal class SuggestionFinderTest { domainInfos = setOf(DomainInfo(value = "https://example.com", eTLD1 = "example.com")), passwordCredential = null, totp = null, + passkeys = emptySet(), vaultId = vaultId, name = "Extra3", keyInformation = keyInfo, diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/BackupTestData.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/BackupTestData.kt index 3dc1c5b72..e8fac0136 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/BackupTestData.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/BackupTestData.kt @@ -10,6 +10,7 @@ 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.Passkey +import de.davis.keygo.core.item.domain.model.PasskeyRef import de.davis.keygo.core.item.domain.model.PasskeyUser import de.davis.keygo.core.item.domain.model.PasswordCredential import de.davis.keygo.core.item.domain.model.PasswordScore @@ -61,7 +62,7 @@ fun testLogin( websites: Set = emptySet(), tags: Set = emptySet(), note: String? = null, - passkeyRPs: Set = emptySet(), + passkeys: Set = emptySet(), ) = Login( id = id, vaultId = vaultId, @@ -72,7 +73,7 @@ fun testLogin( PasswordCredential(secret = PasswordSecret(secretPayload(it)), score = PasswordScore.Strong) }, totp = totpSecret?.let { Totp(loginId = id, secret = Totp.Secret(secretPayload(it))) }, - passkeyRPs = passkeyRPs, + passkeys = passkeys, keyInformation = emptyKey, tags = tags.mapNotNull { Tag.of(it) }.toSet(), note = note, diff --git a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupCollectorTest.kt b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupCollectorTest.kt index 7d1d4c920..b0d600acc 100644 --- a/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupCollectorTest.kt +++ b/feature/backup/src/test/kotlin/de/davis/keygo/feature/backup/domain/BackupCollectorTest.kt @@ -7,6 +7,7 @@ import de.davis.keygo.core.item.FakePasskeyRepository import de.davis.keygo.core.item.FakeVaultRepository import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.passkeyRef import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProviderFactory import de.davis.keygo.core.security.crypto.FakeKeyStoreManager @@ -20,9 +21,6 @@ import de.davis.keygo.feature.backup.testCard import de.davis.keygo.feature.backup.testLogin import de.davis.keygo.feature.backup.testPasskey import de.davis.keygo.feature.backup.testVault -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.runTest import java.time.YearMonth import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.Test @@ -30,6 +28,9 @@ import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest class BackupCollectorTest { @@ -258,7 +259,7 @@ class BackupCollectorTest { vaultId = vault.id, id = loginId, name = "Email", - passkeyRPs = setOf("example.com", "example.org"), + passkeys = setOf(passkeyRef("example.com"), passkeyRef("example.org")), ) ) passkeyRepo.seed( @@ -287,11 +288,11 @@ class BackupCollectorTest { } @Test - fun `passkeys are exported even when the login's passkeyRPs set is empty`() = runTest { + fun `passkeys are exported even when the login's passkeys set is empty`() = runTest { val vault = testVault(name = "Personal") vaultRepo.seed(vault) val loginId = newItemId() - // passkeyRPs deliberately left empty (default) - the table is the source of truth. + // passkeys deliberately left empty (default) - the table is the source of truth. loginRepo.seed(testLogin(vaultId = vault.id, id = loginId, name = "Email")) passkeyRepo.seed(testPasskey(loginId = loginId, rp = "example.com", privateKey = "pk-one")) diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyActivity.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyActivity.kt index bb0f03adf..6cea68dab 100644 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyActivity.kt +++ b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyActivity.kt @@ -27,6 +27,8 @@ import androidx.compose.ui.res.stringResource import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.CreatePublicKeyCredentialResponse import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.domerrors.InvalidStateError +import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException import androidx.credentials.provider.PendingIntentHandler import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -39,6 +41,7 @@ import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.security.domain.model.BiometricPolicy import de.davis.keygo.core.security.domain.model.BiometricString import de.davis.keygo.core.security.presentation.rememberBiometricCryptoController +import de.davis.keygo.core.ui.text.htmlStringResource import de.davis.keygo.core.ui.theme.KeyGoTheme import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess @@ -54,9 +57,6 @@ import kotlinx.serialization.Serializable import org.koin.androidx.viewmodel.ext.android.viewModel -@Serializable -private data object AuthenticatedHome - @Serializable private data object ListDest @@ -74,7 +74,8 @@ internal class CreatePasskeyActivity : FragmentActivity() { val callingRequest = request?.callingRequest as? CreatePublicKeyCredentialRequest ?: return cancel("Invalid CreatePublicKeyCredentialRequest") - viewModel.setRequest(callingRequest) + val success = viewModel.setRequest(callingRequest.requestJson) + if (!success) return cancel("Failed to set request") setResult(RESULT_CANCELED) @@ -91,10 +92,6 @@ internal class CreatePasskeyActivity : FragmentActivity() { ObserveAsEvents(flow = viewModel.event) { when (it) { CreatePasskeyEvent.Abort -> cancel() - CreatePasskeyEvent.ShowList -> authenticatedNavController.navigate(ListDest) { - popUpTo { inclusive = true } - } - is CreatePasskeyEvent.Finish -> finishWithSuccess(it.responseJson) is CreatePasskeyEvent.OpenConfirmationDialog -> { @@ -140,6 +137,30 @@ internal class CreatePasskeyActivity : FragmentActivity() { ) } + val excluded by viewModel.excluded.collectAsStateWithLifecycle() + val rp by viewModel.rp.collectAsStateWithLifecycle() + if (excluded) { + AlertDialog( + onDismissRequest = ::finishExcluded, + title = { + Text(stringResource(R.string.passkey_already_exists)) + }, + confirmButton = { + Button(onClick = ::finishExcluded) { + Text(stringResource(R.string.ok)) + } + }, + text = { + Text( + text = if (rp.isNotBlank()) + htmlStringResource(R.string.passkey_already_exists_message, rp) + else htmlStringResource(R.string.passkey_already_exists_message_generic) + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + val biometricCryptoController = rememberBiometricCryptoController() val biometricUnlockAdapter = rememberBiometricUnlockAdapter() @@ -185,15 +206,11 @@ internal class CreatePasskeyActivity : FragmentActivity() { Scaffold { innerPadding -> NavHost( navController = authenticatedNavController, - startDestination = AuthenticatedHome, + startDestination = ListDest, modifier = Modifier .padding(innerPadding) .consumeWindowInsets(innerPadding), ) { - composable { - // empty placeholder while operation runs - } - composable { PasskeyItemListScreen( onItemClick = viewModel::onItemClicked, @@ -205,7 +222,7 @@ internal class CreatePasskeyActivity : FragmentActivity() { composable { LoginScreen( - pendingPasskeyCount = 1, + pendingPasskeyRP = rp, loginCreated = { viewModel.associatePasskeyAndFinish(it) }, @@ -263,6 +280,25 @@ internal class CreatePasskeyActivity : FragmentActivity() { finish() } + /** + * Reports the relying party's own exclusion list back to it. + * + * [InvalidStateError] is what WebAuthn defines for "this authenticator already holds a + * credential for that user", so the site can say so instead of showing a generic failure. + * The framework forwards a provider exception only on RESULT_OK; RESULT_CANCELED is reported + * as a plain user cancellation and would hide it. + */ + private fun finishExcluded() { + val response = Intent() + PendingIntentHandler.setCreateCredentialException( + response, + CreatePublicKeyCredentialDomException(InvalidStateError()), + ) + + setResult(RESULT_OK, response) + finish() + } + private fun cancel(errorMsg: String? = null) { val result = errorMsg?.let { val response = Intent() diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyEvent.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyEvent.kt index 06822d96f..60acf3cb4 100644 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyEvent.kt +++ b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyEvent.kt @@ -4,7 +4,6 @@ import de.davis.keygo.core.item.domain.alias.ItemId internal sealed interface CreatePasskeyEvent { data object Abort : CreatePasskeyEvent - data object ShowList : CreatePasskeyEvent data class OpenConfirmationDialog( val itemId: ItemId, val itemName: String, diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyViewModel.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyViewModel.kt index f11ffd91d..add08a097 100644 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyViewModel.kt +++ b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyViewModel.kt @@ -1,7 +1,6 @@ package de.davis.keygo.feature.credentials.presentation.create.activity import android.util.Log -import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.davis.keygo.core.identity.domain.model.UnlockError @@ -13,19 +12,22 @@ import de.davis.keygo.core.item.domain.repository.PasskeyRepository import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.encrypt import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository +import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.fold import de.davis.keygo.core.util.getOrNull import de.davis.keygo.feature.credentials.presentation.auth.SessionAuthState import de.davis.keygo.feature.credentials.presentation.auth.UnlockOutcome import de.davis.keygo.feature.credentials.presentation.auth.mapUnlockError import de.davis.keygo.rust.passkey.PasskeyManager -import de.davis.keygo.rust.passkey.getExcludedCredentialIds +import de.davis.keygo.rust.passkey.getPasskeyInformation import de.davis.keygo.rust.passkey.registerWithResult import de.davisalessandro.keygo.rust.RegistrationResponse +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel @@ -44,75 +46,91 @@ internal class CreatePasskeyViewModel( private val _authState = MutableStateFlow(SessionAuthState.TryBiometric) val authState = _authState.asStateFlow() + private val _excluded = MutableStateFlow(false) + val excluded = _excluded.asStateFlow() + private val biometricChannel = Channel(Channel.BUFFERED) val biometricFlow = biometricChannel.receiveAsFlow() - private var pendingRequest: CreatePublicKeyCredentialRequest? = null - private var registrationResponse: RegistrationResponse? = null + /** + * The request's own `rp.id`, which the relying party may leave empty, until registration + * replaces it with the id the authenticator resolved. + */ + private val _rp = MutableStateFlow("") + val rp = _rp.asStateFlow() - init { - viewModelScope.launch { - val account = accountRepository.getOrNull() - val biometricUsable = biometricAvailabilityRepository.availability() - && account?.biometricWrappedArk != null - - if (biometricUsable) { - _authState.value = SessionAuthState.TryBiometric - biometricChannel.send(Unit) - } else - _authState.value = SessionAuthState.NeedsPassword - } - } + /** Completed by [onUnlocked]. */ + private val unlocked = CompletableDeferred() - fun setRequest(request: CreatePublicKeyCredentialRequest) { - pendingRequest = request - } + /** Completed by [associatePasskeyAndFinish]. */ + private val chosenItem = CompletableDeferred() - fun onUnlocked() { - if (_authState.value == SessionAuthState.Authenticated) return - _authState.value = SessionAuthState.Authenticated - val req = pendingRequest ?: return - runOperation(req) - } + private var started = false - fun onUnlockFailed(error: UnlockError) { - when (mapUnlockError(error)) { - UnlockOutcome.Abort -> viewModelScope.launch { abort("biometric $error") } - UnlockOutcome.NeedsPassword -> _authState.value = SessionAuthState.NeedsPassword - } + /** + * Parses [requestJson] and starts the flow. False means the request is unusable. + * + * The activity calls this again on every configuration change; only the first call does + * anything. + */ + fun setRequest(requestJson: String): Boolean { + if (started) return true + + val information = passkeyManager.getPasskeyInformation(requestJson).getOrNull() + ?: return false + + started = true + _rp.update { information.rp } + start(requestJson, information.excludeCredentials.toSet()) + + return true } - private fun runOperation(request: CreatePublicKeyCredentialRequest) { + /** + * The whole flow as one coroutine, so the order of the steps is the order of the lines and the + * two waits on the user are plain suspension points. + */ + private fun start(requestJson: String, excludeCredentials: Set) { viewModelScope.launch { - val idsToExclude = - passkeyManager.getExcludedCredentialIds(request.requestJson).getOrNull() - ?.toSet() - ?: return@launch abort("Failed to get excluded IDs") + // The passkey table is not gated behind the session and credential ids are not secrets, + // so an excluded request is answered without making the user authenticate for a + // registration that can never succeed. + if (passkeyRepository.doCredentialIdsExist(excludeCredentials)) + return@launch _excluded.update { true } - val shouldAbort = passkeyRepository.doCredentialIdsExist(idsToExclude) - if (shouldAbort) return@launch abort("Credential ID already exists") + requestUnlock() + unlocked.await() - registrationResponse = passkeyManager.registerWithResult(request.requestJson) - .getOrNull() ?: return@launch abort("Failed to register passkey") + // Before the item screen, so a failure aborts while there is nothing to leave behind. + val response = passkeyManager.registerWithResult(requestJson) + .orAbort("Failed to register passkey") ?: return@launch - _event.send(CreatePasskeyEvent.ShowList) + _rp.update { response.rp } + _authState.update { SessionAuthState.Authenticated } + + storeAndFinish(response, chosenItem.await()) } } - fun associatePasskeyAndFinish(itemId: ItemId) { - viewModelScope.launch { - val response = registrationResponse ?: return@launch abort("Response was null") + private suspend fun requestUnlock() { + val biometricUsable = biometricAvailabilityRepository.availability() + && accountRepository.getOrNull()?.biometricWrappedArk != null - val encryptedPrivateKey = cryptographicScopeProvider.itemScope(itemId = itemId) { - Passkey.PrivateKey.encrypt(response.privateKey) - }.fold( - onSuccess = { it }, - onFailure = { return@launch abort("Failed to encrypt passkey private key: $it") } - ) + if (biometricUsable) { + _authState.update { SessionAuthState.TryBiometric } + biometricChannel.send(Unit) + } else _authState.update { SessionAuthState.NeedsPassword } + } + + private suspend fun storeAndFinish(response: RegistrationResponse, itemId: ItemId) { + val privateKey = cryptographicScopeProvider.itemScope(itemId = itemId) { + Passkey.PrivateKey.encrypt(response.privateKey) + }.orAbort("Failed to encrypt passkey private key") ?: return - val passkey = Passkey( + passkeyRepository.createPasskey( + Passkey( credentialId = response.credentialId, - privateKey = encryptedPrivateKey, + privateKey = privateKey, rp = response.rp, loginId = itemId, user = PasskeyUser( @@ -120,24 +138,49 @@ internal class CreatePasskeyViewModel( displayName = response.userDisplayName, ), ) + ) + _event.send(CreatePasskeyEvent.Finish(response.response)) + } + + fun onUnlocked() { + unlocked.complete(Unit) + } - passkeyRepository.createPasskey(passkey) - _event.send(CreatePasskeyEvent.Finish(response.response)) + fun onUnlockFailed(error: UnlockError) { + when (mapUnlockError(error)) { + UnlockOutcome.Abort -> viewModelScope.launch { abort("biometric $error") } + UnlockOutcome.NeedsPassword -> _authState.update { SessionAuthState.NeedsPassword } } } + /** + * Names the login the passkey belongs to. Only the first call counts: a second tap landing + * before the dialog recomposes away would otherwise store the credential twice. + */ + fun associatePasskeyAndFinish(itemId: ItemId) { + chosenItem.complete(itemId) + } + fun onItemClicked(itemId: ItemId) { _event.trySend( CreatePasskeyEvent.OpenConfirmationDialog( itemId = itemId, itemName = "N/A", - rp = registrationResponse?.rp ?: "N/A" + rp = _rp.value, ) ) } - private suspend fun abort(msg: String? = null) { - msg?.let { Log.w(TAG, "Aborting: $it") } + private suspend fun Result.orAbort(reason: String): S? = fold( + onSuccess = { it }, + onFailure = { + abort("$reason: $it") + null + }, + ) + + private suspend fun abort(msg: String) { + Log.w(TAG, "Aborting: $msg") _event.send(CreatePasskeyEvent.Abort) } diff --git a/feature/credentials/src/main/res/values/strings.xml b/feature/credentials/src/main/res/values/strings.xml index ea4c74fb9..dda5aa806 100644 --- a/feature/credentials/src/main/res/values/strings.xml +++ b/feature/credentials/src/main/res/values/strings.xml @@ -5,6 +5,11 @@ Link Passkey Do you want to link the passkey for %s to this item? + Passkey already saved + You already have a passkey for <b>%s</b> in KeyGo. Delete it from the item first if you want to replace it. + You already have a passkey for this site in KeyGo. Delete it from the item first if you want to replace it. + Yes No + OK \ No newline at end of file diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertLogin.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertLogin.kt index 328b76ccb..dc6ed900a 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertLogin.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertLogin.kt @@ -3,6 +3,7 @@ package de.davis.keygo.feature.item.core.domain.model import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.model.DomainInfo +import de.davis.keygo.core.item.domain.model.PasskeyRef import de.davis.keygo.core.item.domain.model.Tag @ConsistentCopyVisibility @@ -15,7 +16,8 @@ data class UpsertLogin private constructor( val domains: FieldUpdate>, override val tags: FieldUpdate>, override val note: FieldUpdate, - val hasPendingPasskey: Boolean = false, + val removedPasskeys: Set, + val pendingPasskey: Boolean, ) : UpsertItem { companion object { fun create( @@ -27,7 +29,7 @@ data class UpsertLogin private constructor( domains: Set = emptySet(), tags: Set = emptySet(), note: String? = null, - hasPendingPasskey: Boolean = false, + pendingPasskey: Boolean = false, ) = UpsertLogin( upsertType = UpsertType.Create(vaultId), name = FieldUpdate.Set(name), @@ -37,7 +39,9 @@ data class UpsertLogin private constructor( username = if (!username.isNullOrBlank()) FieldUpdate.Set(username) else FieldUpdate.Clear, domains = if (domains.isNotEmpty()) FieldUpdate.Set(domains) else FieldUpdate.Clear, tags = if (tags.isNotEmpty()) FieldUpdate.Set(tags) else FieldUpdate.Clear, - hasPendingPasskey = hasPendingPasskey, + // A brand-new login holds no passkeys, so there is nothing to remove. + removedPasskeys = emptySet(), + pendingPasskey = pendingPasskey, ) fun update( @@ -50,6 +54,8 @@ data class UpsertLogin private constructor( domains: FieldUpdate> = keep(), tags: FieldUpdate> = keep(), note: FieldUpdate = keep(), + removedPasskeys: Set = emptySet(), + pendingPasskey: Boolean = false, ) = UpsertLogin( upsertType = UpsertType.Update(itemId, vaultId), name = name, @@ -59,6 +65,8 @@ data class UpsertLogin private constructor( tags = tags, username = username, domains = domains, + removedPasskeys = removedPasskeys, + pendingPasskey = pendingPasskey, ) } } diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCase.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCase.kt index f26ceb94a..1a12afbbb 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCase.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCase.kt @@ -63,8 +63,10 @@ class CreateNewOrUpdateLoginUseCase( override suspend fun fetchExisting(id: ItemId): Login? = loginRepository.getLoginById(id) + // buildCreate/buildUpdate already resolve the login's effective passkeys, so this only has to + // account for the one that is not on the item yet. override fun isEmpty(item: Login, upsert: UpsertLogin): Boolean = - !item.hasAnyContent && !upsert.hasPendingPasskey + !upsert.pendingPasskey && !item.hasAnyContent override fun relocate(item: Login, vaultId: VaultId, keyInformation: KeyInformation): Login = item.copy(vaultId = vaultId, keyInformation = keyInformation) @@ -99,6 +101,8 @@ class CreateNewOrUpdateLoginUseCase( tags = upsert.tags.getValue().orEmpty(), passwordCredential = newPasswordCredential, totp = totp?.await(), + // A pending passkey is written by its own flow once this id exists. + passkeys = emptySet(), note = upsert.note.getValue(), pinned = false, keyInformation = keyInformation, @@ -131,6 +135,9 @@ class CreateNewOrUpdateLoginUseCase( tags = upsert.tags.on(existing.tags).orEmpty(), passwordCredential = newPasswordCredential, totp = upsert.totpUriOrSecret.on(existing.totp, totp), + // Resolved against what the table holds now, not against what the caller last saw, so + // a passkey attached from elsewhere since then survives this save. + passkeys = existing.passkeys - upsert.removedPasskeys, note = upsert.note.on(existing.note), ) } diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/ChipFormGroup.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/ChipFormGroup.kt index a4ef53afc..785918f20 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/ChipFormGroup.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/ChipFormGroup.kt @@ -9,12 +9,8 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.KeyboardActionHandler @@ -24,17 +20,7 @@ import androidx.compose.foundation.text.input.delete import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.text.input.then -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.DropdownMenuGroup -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.DropdownMenuPopup -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextFieldLabelScope @@ -51,18 +37,15 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import de.davis.keygo.feature.item.core.R import kotlinx.coroutines.flow.collectLatest @Stable @@ -275,68 +258,6 @@ fun ChipFormGroup( } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun MenuChip( - chipText: String, - onDeleteClick: () -> Unit, - onModifyClick: () -> Unit -) { - var expanded by rememberSaveable { mutableStateOf(false) } - - Box( - modifier = Modifier.wrapContentSize(Alignment.TopStart) - ) { - InputChip( - selected = false, - onClick = { expanded = !expanded }, - label = { Text(text = chipText) } - ) - - DropdownMenuPopup( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.widthIn(min = 175.dp) - ) { - DropdownMenuGroup( - shapes = MenuDefaults.groupShape(0, 1), - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - DropdownMenuItem( - onClick = { - expanded = false - onModifyClick() - }, - text = { Text(text = stringResource(R.string.edit)) }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Edit, - modifier = Modifier.size(MenuDefaults.LeadingIconSize), - contentDescription = null - ) - }, - shape = MenuDefaults.itemShape(0, 2).shape, - ) - DropdownMenuItem( - onClick = { - expanded = false - onDeleteClick() - }, - text = { Text(text = stringResource(R.string.delete)) }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Delete, - modifier = Modifier.size(MenuDefaults.LeadingIconSize), - contentDescription = null - ) - }, - shape = MenuDefaults.itemShape(1, 2).shape, - ) - } - } - } -} - private fun CharSequence.splitChipInput(delimiters: Set): List = split(delimiters = delimiters.toCharArray()) .mapNotNull { it.trim().takeIf(String::isNotBlank) } diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/MenuChip.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/MenuChip.kt new file mode 100644 index 000000000..36fb61fd0 --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/MenuChip.kt @@ -0,0 +1,96 @@ +package de.davis.keygo.feature.item.core.presentation.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.DropdownMenuGroup +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenuPopup +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.InputChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.davis.keygo.feature.item.core.R + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun MenuChip( + chipText: String, + onDeleteClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onModifyClick: (() -> Unit)? = null, +) { + var expanded by rememberSaveable { mutableStateOf(false) } + val itemCount = if (onModifyClick != null) 2 else 1 + + Box( + modifier = modifier.wrapContentSize(Alignment.TopStart) + ) { + InputChip( + selected = false, + onClick = { expanded = !expanded }, + label = { Text(text = chipText) }, + enabled = enabled + ) + + DropdownMenuPopup( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.widthIn(min = 175.dp) + ) { + DropdownMenuGroup( + shapes = MenuDefaults.groupShape(0, 1), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + onModifyClick?.let { onModify -> + DropdownMenuItem( + onClick = { + expanded = false + onModify() + }, + text = { Text(text = stringResource(R.string.edit)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Edit, + modifier = Modifier.size(MenuDefaults.LeadingIconSize), + contentDescription = null + ) + }, + shape = MenuDefaults.itemShape(0, itemCount).shape, + ) + } + DropdownMenuItem( + onClick = { + expanded = false + onDeleteClick() + }, + text = { Text(text = stringResource(R.string.delete)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + modifier = Modifier.size(MenuDefaults.LeadingIconSize), + contentDescription = null + ) + }, + shape = MenuDefaults.itemShape(itemCount - 1, itemCount).shape, + ) + } + } + } +} diff --git a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt index 133dc9221..08838a2e8 100644 --- a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt +++ b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt @@ -11,6 +11,7 @@ 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 @@ -18,6 +19,7 @@ import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.model.Totp import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase +import de.davis.keygo.core.item.passkeyRef import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davis.keygo.core.util.Result @@ -32,8 +34,6 @@ import de.davis.keygo.rust.FakeTotpService import de.davis.keygo.rust.totp.TotpService import de.davisalessandro.keygo.rust.Algorithm import de.davisalessandro.keygo.rust.TotpInfo -import kotlinx.coroutines.test.runTest -import kotlin.time.Clock import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContains @@ -44,6 +44,8 @@ import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlinx.coroutines.test.runTest class CreateNewOrUpdateLoginUseCaseTest { @@ -104,6 +106,184 @@ class CreateNewOrUpdateLoginUseCaseTest { assertEquals(setOf(ItemUpsertError.Empty), result.error) } + @Test + fun `create with a pending passkey and nothing else returns Success`() = runTest { + val result = useCase( + UpsertLogin.create( + vaultId = defaultVault.id, + name = "My site", + password = null, + username = null, + totpUriOrSecret = null, + pendingPasskey = true, + ) + ) + + assertTrue(result.isSuccess()) + } + + @Test + fun `update clearing the only credential while a passkey is pending returns Success`() = + runTest { + val existing = testLogin(username = null, totp = null) + loginRepository.seed(existing) + + val result = useCase( + UpsertLogin.update( + itemId = existing.id, + password = clear(), + pendingPasskey = true, + ) + ) + + assertTrue(result.isSuccess()) + } + + // The use case does not touch the passkey table: it resolves the login's effective credentials, + // and LoginRepository drops the rows that fall outside that set as part of the same write. + // These assert the resolved set; LoginRepositoryImplTest covers the deletion itself. + + @Test + fun `update deleting a passkey drops it from the saved login`() = runTest { + val removed = passkeyRef("example.com") + val kept = passkeyRef("example.org") + val existing = testLogin(passkeys = setOf(removed, kept)) + loginRepository.seed(existing) + + val result = useCase( + UpsertLogin.update( + itemId = existing.id, + removedPasskeys = setOf(removed), + ) + ) + + assertTrue(result.isSuccess()) + assertEquals(setOf(kept), loginRepository.getLoginById(existing.id)?.passkeys) + } + + @Test + fun `update deleting one of two passkeys for the same RP keeps the other`() = runTest { + // Two accounts on one site are two credentials sharing an rp. Removals key on the + // credential id, so dropping one leaves the other in place; keying on the rp would take + // both while the dialog only ever spoke about one. + val removed = passkeyRef("example.com", discriminator = "first") + val kept = passkeyRef("example.com", discriminator = "second") + val existing = testLogin(passkeys = setOf(removed, kept)) + loginRepository.seed(existing) + + val result = useCase( + UpsertLogin.update( + itemId = existing.id, + removedPasskeys = setOf(removed), + ) + ) + + assertTrue(result.isSuccess()) + assertEquals(setOf(kept), loginRepository.getLoginById(existing.id)?.passkeys) + } + + @Test + fun `update deleting the only passkey of an otherwise empty login returns Empty`() = runTest { + val existing = testLogin( + username = null, + passwordCredential = null, + passkeys = setOf(passkeyRef("example.com")), + ) + loginRepository.seed(existing) + + val result = useCase( + UpsertLogin.update( + itemId = existing.id, + removedPasskeys = setOf(passkeyRef("example.com")), + ) + ) + + assertTrue(result.isFailure()) + assertContains(result.error, ItemUpsertError.Empty) + } + + @Test + fun `a passkey attached after the form loaded survives an unrelated removal`() = runTest { + // The editing screen reads a login's passkeys once, and passkeys can be attached to an + // existing login from the passkey activity while that screen is open. Removals travel as a + // delta for exactly this reason: the effective set is resolved against a fresh read here, + // so a passkey the form never saw is not in the delta and stays. The table below holds + // both; the form that produced this delta only ever saw "example.com". + val attachedLater = passkeyRef("attached-later.example") + val existing = testLogin(passkeys = setOf(passkeyRef("example.com"), attachedLater)) + loginRepository.seed(existing) + + val result = useCase( + UpsertLogin.update( + itemId = existing.id, + removedPasskeys = setOf(passkeyRef("example.com")), + ) + ) + + assertTrue(result.isSuccess()) + assertEquals(setOf(attachedLater), loginRepository.getLoginById(existing.id)?.passkeys) + } + + @Test + fun `update rejected as Empty never reaches the repository`() = runTest { + val existing = testLogin( + username = null, + passwordCredential = null, + passkeys = setOf(passkeyRef("example.com")), + ) + loginRepository.seed(existing) + + useCase( + UpsertLogin.update( + itemId = existing.id, + removedPasskeys = setOf(passkeyRef("example.com")), + ) + ) + + // Validation runs before the write, so the stored login still holds the passkey and the + // repository never gets the chance to delete its row. + assertEquals( + setOf(passkeyRef("example.com")), + loginRepository.getLoginById(existing.id)?.passkeys, + ) + } + + @Test + fun `update deleting the only passkey while a password remains returns Success`() = runTest { + val existing = testLogin(passkeys = setOf(passkeyRef("example.com"))) + loginRepository.seed(existing) + + val result = useCase( + UpsertLogin.update( + itemId = existing.id, + removedPasskeys = setOf(passkeyRef("example.com")), + ) + ) + + assertTrue(result.isSuccess()) + assertTrue(loginRepository.getLoginById(existing.id)?.passkeys.orEmpty().isEmpty()) + } + + @Test + fun `a save can register one passkey and remove another at the same time`() = runTest { + // Not reachable from the UI today: the passkey activity only opens the editor on a new + // login, which holds no passkeys to remove. A guard against the two ever being treated as + // alternatives again if that flow gains an "edit existing" entry point. + val existing = testLogin(passkeys = setOf(passkeyRef("old.example"))) + loginRepository.seed(existing) + + val result = useCase( + UpsertLogin.update( + itemId = existing.id, + removedPasskeys = setOf(passkeyRef("old.example")), + pendingPasskey = true, + ) + ) + + assertTrue(result.isSuccess()) + assertTrue(loginRepository.getLoginById(existing.id)?.passkeys.orEmpty().isEmpty()) + } + @Test fun `create with password only returns Success`() = runTest { val result = useCase( @@ -808,6 +988,7 @@ class CreateNewOrUpdateLoginUseCaseTest { ), totp: Totp? = null, timestamp: Timestamp = Timestamp(), + passkeys: Set = emptySet(), ) = Login( id = newItemId(), name = name, @@ -815,6 +996,7 @@ class CreateNewOrUpdateLoginUseCaseTest { domainInfos = emptySet(), passwordCredential = passwordCredential, totp = totp, + passkeys = passkeys, note = null, pinned = false, vaultId = defaultVault.id, diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/DeletePasskeyDialog.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/DeletePasskeyDialog.kt new file mode 100644 index 000000000..655c966a0 --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/DeletePasskeyDialog.kt @@ -0,0 +1,70 @@ +package de.davis.keygo.feature.item.create.presentation.component + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import de.davis.keygo.core.item.domain.model.PasskeyRef +import de.davis.keygo.core.ui.text.htmlStringResource +import de.davis.keygo.core.ui.theme.KeyGoTheme +import de.davis.keygo.feature.item.create.R +import de.davis.keygo.feature.item.create.presentation.login.model.DialogState +import de.davis.keygo.feature.item.core.R as ItemCoreR + +@Composable +fun DeletePasskeyDialog( + state: DialogState.DeletePasskey, + onConfirmDeletion: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text(text = stringResource(R.string.delete_passkey)) + }, + confirmButton = { + Button(onClick = onDismissRequest) { + Text(text = stringResource(R.string.cancel)) + } + }, + icon = { + Icon( + imageVector = Icons.Default.DeleteForever, + contentDescription = null, + ) + }, + modifier = modifier, + dismissButton = { + TextButton(onClick = onConfirmDeletion) { + Text(text = stringResource(ItemCoreR.string.delete)) + } + }, + text = { + Text( + text = htmlStringResource(R.string.delete_passkey_warning, state.passkey.rp) + ) + }, + ) +} + +@Preview +@Composable +private fun DeletePasskeyDialogPreview() { + KeyGoTheme { + DeletePasskeyDialog( + state = DialogState.DeletePasskey( + passkey = PasskeyRef(credentialId = byteArrayOf(1), rp = "example.com"), + ), + onConfirmDeletion = {}, + onDismissRequest = {}, + ) + } +} diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt index 8053700b5..9af37217b 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt @@ -3,6 +3,7 @@ package de.davis.keygo.feature.item.create.presentation.login import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -24,6 +25,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -37,6 +39,7 @@ import androidx.compose.ui.unit.dp 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.DomainInfo +import de.davis.keygo.core.item.domain.model.PasskeyRef import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.Vault @@ -47,9 +50,11 @@ import de.davis.keygo.core.ui.theme.KeyGoTheme import de.davis.keygo.feature.item.core.presentation.component.ChipFormGroup import de.davis.keygo.feature.item.core.presentation.component.CreateOrModifyItemTopAppBar import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField +import de.davis.keygo.feature.item.core.presentation.component.MenuChip import de.davis.keygo.feature.item.core.presentation.component.gatherPendingItems import de.davis.keygo.feature.item.core.presentation.transformation.rememberSchemeStrippingTransformation import de.davis.keygo.feature.item.create.R +import de.davis.keygo.feature.item.create.presentation.component.DeletePasskeyDialog import de.davis.keygo.feature.item.create.presentation.component.FormGroup import de.davis.keygo.feature.item.create.presentation.component.ItemContentWrapper import de.davis.keygo.feature.item.create.presentation.component.KeyGoItemForm @@ -59,6 +64,7 @@ import de.davis.keygo.feature.item.create.presentation.component.TAG_DELIMITERS import de.davis.keygo.feature.item.create.presentation.component.TotpParseErrorDialog import de.davis.keygo.feature.item.create.presentation.login.model.DialogState import de.davis.keygo.feature.item.create.presentation.login.model.LoginBaseState +import de.davis.keygo.feature.item.create.presentation.login.model.LoginPasskeyInfo import de.davis.keygo.feature.item.create.presentation.login.model.LoginUiEvent import de.davis.keygo.feature.item.create.presentation.login.model.LoginUiState import de.davis.keygo.feature.item.create.presentation.model.ItemUiEvent @@ -154,6 +160,33 @@ private fun LoginReadyContent( onTagSubmitted = { onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnAddTags(it))) }, onDeleteTag = { onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnRemoveTag(it))) }, ) { + if (state.passkeys.isNotEmpty()) item(key = "passkey_information") { + FormGroup( + title = stringResource(R.string.passkey_information), + modifier = Modifier, + ) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.passkeys.forEach { passkeyInfo -> + key(passkeyInfo) { + MenuChip( + chipText = passkeyInfo.rpId, + onDeleteClick = { + passkeyInfo.ref?.let { + onEvent(LoginUiEvent.OnDeletePasskeyRequest(it)) + } + }, + enabled = !passkeyInfo.pending + ) + } + } + } + } + } + item(key = "password_information") { var forceCompact by rememberSaveable { mutableStateOf(false) } @@ -294,6 +327,19 @@ private fun LoginReadyContent( modifier = Modifier.fillMaxWidth(), ) } + + is DialogState.DeletePasskey -> { + DeletePasskeyDialog( + state = state.dialogState, + onConfirmDeletion = { + onEvent(LoginUiEvent.OnConfirmPasskeyDeletion) + }, + onDismissRequest = { + onEvent(LoginUiEvent.OnPasskeyDeletionDismiss) + }, + modifier = Modifier.fillMaxWidth(), + ) + } } } @@ -333,6 +379,17 @@ private fun LoginContentPreview() { eTLD1 = "example.com", ), ), + passkeys = setOf( + LoginPasskeyInfo( + rpId = "example.com", + ref = PasskeyRef(byteArrayOf(1), "example.com"), + ), + LoginPasskeyInfo( + rpId = "example.com", + ref = PasskeyRef(byteArrayOf(2), "example.com"), + ), + LoginPasskeyInfo(rpId = "example.org", ref = null), + ), ), shared = SharedItemState( nameTextFieldState = TextFieldState(), diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginScreen.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginScreen.kt index f73d23567..e894c1966 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginScreen.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginScreen.kt @@ -15,7 +15,7 @@ fun LoginScreen( detailPaneInformation: DetailPaneInformation = DetailPaneInformation.Init.New( itemType = VaultItemType.Login, ), - pendingPasskeyCount: Int = 0, + pendingPasskeyRP: String? = null, loginCreated: (ItemId) -> Unit, navigateBack: () -> Unit, ) { @@ -26,8 +26,8 @@ fun LoginScreen( viewmodel.init(detailPaneInformation) } - LaunchedEffect(pendingPasskeyCount) { - viewmodel.setPendingPasskeyCount(pendingPasskeyCount) + LaunchedEffect(pendingPasskeyRP) { + viewmodel.setPendingPasskeyCount(pendingPasskeyRP) } ObserveAsEvents(viewmodel.itemCreatedEvent) { diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt index 3668199e2..b52b8ae67 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt @@ -36,6 +36,7 @@ import de.davis.keygo.feature.item.create.R import de.davis.keygo.feature.item.create.presentation.ItemViewModel import de.davis.keygo.feature.item.create.presentation.login.model.DialogState import de.davis.keygo.feature.item.create.presentation.login.model.LoginBaseState +import de.davis.keygo.feature.item.create.presentation.login.model.LoginPasskeyInfo import de.davis.keygo.feature.item.create.presentation.login.model.LoginUiEvent import de.davis.keygo.feature.item.create.presentation.login.model.OverrideTotpField import de.davis.keygo.feature.item.create.presentation.model.ItemUiState @@ -113,8 +114,19 @@ internal class LoginViewModel( .launchIn(viewModelScope) } - fun setPendingPasskeyCount(count: Int) { - _base.update { it.copy(pendingPasskeyCount = count) } + /** + * Shows a passkey for [rp] as pending until the item is saved. + * + * A blank id is dropped along with a null one. `rp.id` is optional per WebAuthn, so a request + * that leaves it out reaches us as an empty string, which would otherwise show up as a blank + * chip, make a name-only login look saveable, and leave the confirmation dialog asking about + * nothing. + */ + fun setPendingPasskeyCount(rp: String?) { + if (rp.isNullOrBlank()) return + _base.update { + it.copy(passkeys = it.passkeys + LoginPasskeyInfo(rpId = rp, ref = null)) + } } fun init(information: DetailPaneInformation) { @@ -192,7 +204,13 @@ internal class LoginViewModel( totpTextFieldState = TextFieldState(decrypted.second ?: ""), usernameTextFieldState = TextFieldState(login.username ?: ""), domains = login.domainInfos, - existingPasskeyCount = login.passkeyRPs.size, + passkeys = login.passkeys.mapTo(mutableSetOf()) { passkey -> + LoginPasskeyInfo( + rpId = passkey.rp, + ref = passkey, + ) + }, + deletedPasskeys = emptySet(), dialogState = DialogState.None, updating = true, ) @@ -237,6 +255,8 @@ internal class LoginViewModel( val base = ready.base val assignedTags = ready.shared.itemAssignedTags val selectedVaultId = ready.shared.vaultsState.selectedVaultId + // Independent: a save can both register a passkey and drop another one. + val pendingPasskey = base.passkeys.any { it.pending } viewModelScope.launch { val upsert = itemId?.let { itemId -> UpsertLogin.update( @@ -249,6 +269,8 @@ internal class LoginViewModel( password = fieldUpdate(base.passwordTextFieldState.text.toString()), totpUriOrSecret = fieldUpdate(base.totpTextFieldState.text.toString()), note = fieldUpdate(notesTextFieldState.text.toString()), + removedPasskeys = base.deletedPasskeys, + pendingPasskey = pendingPasskey, ) } ?: UpsertLogin.create( vaultId = selectedVaultId, @@ -259,7 +281,7 @@ internal class LoginViewModel( password = base.passwordTextFieldState.text.toString(), totpUriOrSecret = base.totpTextFieldState.text.toString(), note = notesTextFieldState.text.toString(), - hasPendingPasskey = base.pendingPasskeyCount > 0, + pendingPasskey = pendingPasskey, ) createNewOrUpdateLogin( @@ -397,6 +419,30 @@ internal class LoginViewModel( } } + is LoginUiEvent.OnDeletePasskeyRequest -> { + _base.update { + it.copy(dialogState = DialogState.DeletePasskey(passkey = event.passkey)) + } + } + + is LoginUiEvent.OnPasskeyDeletionDismiss -> { + _base.update { it.copy(dialogState = DialogState.None) } + } + + is LoginUiEvent.OnConfirmPasskeyDeletion -> { + val dialogState = _base.value.dialogState as? DialogState.DeletePasskey ?: return + + _base.update { + it.copy( + passkeys = it.passkeys + .filterNot { passkey -> passkey.ref == dialogState.passkey } + .toSet(), + deletedPasskeys = it.deletedPasskeys + dialogState.passkey, + dialogState = DialogState.None, + ) + } + } + is LoginUiEvent.OnAddDomains -> { event.domains.forEach { domain -> val registrableDomain = registrableDomainResolver.resolve(domain) diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/DialogState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/DialogState.kt index 2f96a11b8..2c008e25e 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/DialogState.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/DialogState.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.item.create.presentation.login.model +import de.davis.keygo.core.item.domain.model.PasskeyRef import de.davis.keygo.core.item.domain.model.lite.LiteLogin sealed interface DialogState { @@ -7,4 +8,6 @@ sealed interface DialogState { data object TotpParseError : DialogState data class SelectItemForModification(val items: List) : DialogState data class OverrideTotp(val fields: Set) : DialogState + + data class DeletePasskey(val passkey: PasskeyRef) : DialogState } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiEvent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiEvent.kt index 3dfd32617..9e868b8ed 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiEvent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiEvent.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.item.create.presentation.login.model import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.PasskeyRef import de.davis.keygo.feature.item.core.presentation.login.model.FieldType import de.davis.keygo.feature.item.create.presentation.model.ItemUiEvent @@ -11,6 +12,10 @@ internal sealed interface LoginUiEvent { data object OnCloseBottomSheet : LoginUiEvent data object OnScanCodeRequest : LoginUiEvent + data class OnDeletePasskeyRequest(val passkey: PasskeyRef) : LoginUiEvent + data object OnConfirmPasskeyDeletion : LoginUiEvent + data object OnPasskeyDeletionDismiss : LoginUiEvent + data class OnDeleteDomain(val value: String) : LoginUiEvent data class OnAddDomains(val domains: Set) : LoginUiEvent diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiState.kt index 8c9bb4f4f..9c75a64ce 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiState.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiState.kt @@ -3,33 +3,56 @@ package de.davis.keygo.feature.item.create.presentation.login.model import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Stable import de.davis.keygo.core.item.domain.model.DomainInfo +import de.davis.keygo.core.item.domain.model.PasskeyRef import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.feature.item.core.presentation.model.InputFieldError import de.davis.keygo.feature.item.create.presentation.model.ItemUiState internal typealias LoginUiState = ItemUiState +/** + * One passkey chip. There is one per credential, not per relying party: a login can hold two + * credentials for the same site and the user has to be able to tell them apart and drop one. + * + * [ref] is null while the passkey activity is still registering, because a credential id only + * exists once the item it belongs to does. + */ +internal data class LoginPasskeyInfo( + val rpId: String, + val ref: PasskeyRef?, +) { + val pending: Boolean + get() = ref == null +} + @Stable internal data class LoginBaseState( val passwordTextFieldState: TextFieldState = TextFieldState(), val totpTextFieldState: TextFieldState = TextFieldState(), val usernameTextFieldState: TextFieldState = TextFieldState(), val domains: Set = emptySet(), + val passkeys: Set = emptySet(), + val deletedPasskeys: Set = emptySet(), val strengthScore: PasswordScore = PasswordScore.None, val generatePasswordBottomSheetVisible: Boolean = false, val dialogState: DialogState = DialogState.None, val nameError: InputFieldError? = null, - val existingPasskeyCount: Int = 0, - val pendingPasskeyCount: Int = 0, val scanning: Boolean = false, val updating: Boolean = false, ) { + /** + * Whether the form holds anything worth saving. + * + * A pending passkey deletion deliberately does not count. A login whose last passkey is gone + * holds nothing, and there is no reason to keep it, so Save stays disabled exactly as it does + * when the last password or TOTP secret is cleared. Leaving the screen without saving keeps the + * passkey. + */ val hasAnyContent: Boolean get() = passwordTextFieldState.text.isNotBlank() || totpTextFieldState.text.isNotBlank() || usernameTextFieldState.text.isNotBlank() - || existingPasskeyCount > 0 - || pendingPasskeyCount > 0 + || passkeys.isNotEmpty() fun canSave(name: CharSequence): Boolean = name.isNotBlank() && hasAnyContent } diff --git a/feature/item/create/src/main/res/values/strings.xml b/feature/item/create/src/main/res/values/strings.xml index 73d07949d..c8294dacb 100644 --- a/feature/item/create/src/main/res/values/strings.xml +++ b/feature/item/create/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ Password Information Credit Card Information Domain Information + Passkey Information Tag information Override TOTP fields? @@ -29,6 +30,10 @@ Warning OK + Cancel + + Delete Passkey + The passkey for <b>%s</b> is removed when you save. You will no longer be able to sign in there with it, and it can not be restored. Generate Password diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt index c643416f6..89ae7a34d 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt @@ -548,7 +548,7 @@ private fun ViewLoginContentPreview() { ViewLoginContent( state = ViewLoginState( name = "Login 1", - passkeyRPs = setOf("example.com", "example.org"), + passkeyRPs = listOf("example.com", "example.org"), password = ObfuscatedString("Password"), passwordStrengthScore = PasswordScore.Ridiculous, totpState = TotpState.HasTotp( diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt index cab65063b..06a557900 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt @@ -104,7 +104,7 @@ internal class ViewLoginViewModel( ViewLoginState( name = login.name, vaultMetadata = vaultMetadata, - passkeyRPs = login.passkeyRPs, + passkeyRPs = login.passkeys.map { it.rp }, password = obfuscated, passwordStrengthScore = login.passwordCredential?.score, username = login.username.orEmpty(), diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ViewLoginState.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ViewLoginState.kt index 1435de40d..e768dc83e 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ViewLoginState.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ViewLoginState.kt @@ -10,7 +10,8 @@ import de.davis.keygo.core.item.domain.model.VaultMetadata data class ViewLoginState( val name: String = "", val vaultMetadata: VaultMetadata? = null, - val passkeyRPs: Set = emptySet(), + /** One entry per credential, so two passkeys for the same site both show. */ + val passkeyRPs: List = emptyList(), val password: ObfuscatedString? = null, val passwordStrengthScore: PasswordScore? = null, val totpState: TotpState = TotpState.NoTotp, diff --git a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt index 756aa955b..fc1cde566 100644 --- a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt +++ b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt @@ -367,6 +367,7 @@ class MoveItemsToVaultUseCaseTest { totp = totpPlaintext?.let { Totp(loginId = id, secret = Totp.Secret.encrypt(it)) }, + passkeys = emptySet(), note = note, pinned = pinned, vaultId = vault.id, diff --git a/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/mapper/LegacyItemConverter.kt b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/mapper/LegacyItemConverter.kt index a9136e428..0f43545cd 100644 --- a/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/mapper/LegacyItemConverter.kt +++ b/legacy-migration/src/main/kotlin/de/davis/keygo/legacy_migration/domain/mapper/LegacyItemConverter.kt @@ -87,6 +87,8 @@ internal class LegacyItemConverter( .orEmpty(), passwordCredential = credential, totp = null, + // The legacy database had no passkeys, and these are freshly minted ids. + passkeys = emptySet(), vaultId = vaultId, name = item.title, keyInformation = keyInformation, diff --git a/rust/rust-code/bindings/src/passkey.rs b/rust/rust-code/bindings/src/passkey.rs index 886ab2bfb..899c05dfd 100644 --- a/rust/rust-code/bindings/src/passkey.rs +++ b/rust/rust-code/bindings/src/passkey.rs @@ -1,6 +1,7 @@ use lib::passkey::provider::{ProviderError, provide_passkey}; use lib::passkey::registration::{ - KeyGoRegistrationResponse, RegistrationError, get_exclusion_list, register_passkey, + KeyGoRegistrationResponse, PasskeyInformation as CorePasskeyInformation, RegistrationError, + get_passkey_information, register_passkey, }; use std::sync::Arc; @@ -69,6 +70,21 @@ impl From for RegistrationResponse { } } +#[derive(uniffi::Record)] +pub struct PasskeyInformation { + pub exclude_credentials: Vec>, + pub rp: String, +} + +impl From for PasskeyInformation { + fn from(value: CorePasskeyInformation) -> Self { + Self { + exclude_credentials: value.exclude_credentials, + rp: value.rp, + } + } +} + #[derive(uniffi::Object)] pub struct RustPasskey; @@ -89,11 +105,13 @@ impl RustPasskey { .map_err(Into::into) } - pub async fn excluded_credentials( + pub fn passkey_information( &self, json_request: String, - ) -> Result>, PasskeyError> { - get_exclusion_list(&json_request).await.map_err(Into::into) + ) -> Result { + get_passkey_information(&json_request) + .map(Into::into) + .map_err(Into::into) } pub async fn authenticate( diff --git a/rust/rust-code/lib/src/passkey/registration.rs b/rust/rust-code/lib/src/passkey/registration.rs index 56aa11334..d35f049f7 100644 --- a/rust/rust-code/lib/src/passkey/registration.rs +++ b/rust/rust-code/lib/src/passkey/registration.rs @@ -29,14 +29,29 @@ pub enum RegistrationError { KeyEncodeError(PasskeyCodecError), } -pub async fn get_exclusion_list(json_request: &str) -> Result>, RegistrationError> { +pub struct PasskeyInformation { + pub exclude_credentials: Vec>, + pub rp: String, +} + +pub fn get_passkey_information( + json_request: &str, +) -> Result { let creation_options: PublicKeyCredentialCreationOptions = serde_json::from_str(json_request).map_err(|_| InvalidJsonFormat)?; - let list = creation_options.exclude_credentials.unwrap_or_default(); - let ids = list.iter().map(|desc| desc.id.clone().into()).collect(); + let exclude_credentials: Vec> = creation_options + .exclude_credentials + .unwrap_or_default() + .into_iter() + .map(|desc| desc.id.into()) + .collect(); + let rp = creation_options.rp.id.unwrap_or_default(); - Ok(ids) + Ok(PasskeyInformation { + exclude_credentials, + rp, + }) } pub async fn register_passkey( diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt b/rust/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt index a5443c930..d8fe0e0df 100644 --- a/rust/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt +++ b/rust/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt @@ -2,6 +2,7 @@ package de.davis.keygo.rust.passkey import de.davis.keygo.core.util.Result import de.davisalessandro.keygo.rust.PasskeyException +import de.davisalessandro.keygo.rust.PasskeyInformation import de.davisalessandro.keygo.rust.RegistrationResponse import de.davisalessandro.keygo.rust.RustPasskeyInterface import kotlinx.coroutines.Dispatchers @@ -31,15 +32,13 @@ suspend fun RustPasskeyInterface.registerWithResult( ) } -suspend fun RustPasskeyInterface.getExcludedCredentialIds( +fun RustPasskeyInterface.getPasskeyInformation( requestJson: String -): Result, PasskeyException> = withContext(Dispatchers.Default) { - runCatching { - excludedCredentials(requestJson) - }.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it as PasskeyException) } - ) -} +): Result = runCatching { + passkeyInformation(requestJson) +}.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as PasskeyException) } +) typealias PasskeyManager = RustPasskeyInterface \ No newline at end of file diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakePasskeyManager.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakePasskeyManager.kt index 49b6272c2..6f3811e31 100644 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakePasskeyManager.kt +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakePasskeyManager.kt @@ -1,5 +1,6 @@ package de.davis.keygo.rust +import de.davisalessandro.keygo.rust.PasskeyInformation import de.davisalessandro.keygo.rust.RegistrationResponse import de.davisalessandro.keygo.rust.RustPasskeyInterface @@ -13,7 +14,7 @@ class FakePasskeyManager : RustPasskeyInterface { val authenticateCalls = mutableListOf() val registerCalls = mutableListOf() - val excludedCredentialsCalls = mutableListOf() + val passkeyInformationCalls = mutableListOf() /** Result returned by [authenticate]. Throws if null. */ var authenticateResult: String? = null @@ -21,8 +22,11 @@ class FakePasskeyManager : RustPasskeyInterface { /** Result returned by [register]. Throws if null. */ var registerResult: RegistrationResponse? = null - /** Result returned by [excludedCredentials]. */ - var excludedCredentialsResult: List = emptyList() + /** Result returned by [passkeyInformation]. */ + var passkeyInformationResult: PasskeyInformation = PasskeyInformation( + excludeCredentials = emptyList(), + rp = "example.com", + ) override suspend fun authenticate( jsonRequest: String, @@ -33,9 +37,9 @@ class FakePasskeyManager : RustPasskeyInterface { return authenticateResult ?: error("authenticateResult not set on FakePasskeyManager") } - override suspend fun excludedCredentials(jsonRequest: String): List { - excludedCredentialsCalls += jsonRequest - return excludedCredentialsResult + override fun passkeyInformation(jsonRequest: String): PasskeyInformation { + passkeyInformationCalls += jsonRequest + return passkeyInformationResult } override suspend fun register(jsonRequest: String): RegistrationResponse {