[PM-44191] fix: Passkey allowCredentials and excludeCredentials never match (#7431)

This commit is contained in:
Bernd Schoolmann authored and GitHub committed 2026-10-02 03:50:32 +00:00
1 parent 64cfaad068
commit 802427a290
2 files changed
+189 -3

No files matched your search

@@ -15,6 +15,12 @@ import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import timber.log.Timber
import java.nio.ByteBuffer
import java.util.Base64
import java.util.UUID
private const val B64_PREFIX: String = "b64."
private const val UUID_BYTE_COUNT: Int = 16
/**
* Primary implementation of [Fido2CredentialStore].
@@ -138,18 +144,39 @@ class Fido2CredentialStoreImpl(
val fido2CredentialIds = cipherListView.login
?.fido2Credentials
.orEmpty()
.map { it.credentialId.toByteArray() }
.mapNotNull { it.credentialId.toGuidBytesOrNull() }
val hasIntersectingCredentials = credentialIds
?.intersect(fido2CredentialIds)
.orEmpty()
.isNotEmpty()
.any { id -> fido2CredentialIds.any { it.contentEquals(id) } }
hasMatchingRpId &&
(skipCredentialIdFiltering || hasIntersectingCredentials)
}
}
/**
* Convert a stored credential ID to its raw bytes, mirroring the SDK's
* `string_to_guid_bytes`. Ids prefixed with "b64." are Base64 URL-safe encoded; all others
* are UUIDs (16 big-endian bytes). Returns null when the id is invalid.
*/
private fun String.toGuidBytesOrNull(): ByteArray? {
return if (startsWith(B64_PREFIX)) {
runCatching { Base64.getUrlDecoder().decode(removePrefix(B64_PREFIX)) }
.getOrNull()
} else {
runCatching { UUID.fromString(this) }
.map {
ByteBuffer
.allocate(UUID_BYTE_COUNT)
.putLong(it.mostSignificantBits)
.putLong(it.leastSignificantBits)
.array()
}
.getOrNull()
}
}
private fun GetCipherResult.toCipherViewOrNull(): CipherView? {
return when (this) {
GetCipherResult.CipherNotFound -> {
@@ -0,0 +1,159 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.vault.CipherListViewType
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.DecryptCipherListResult
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.nio.ByteBuffer
import java.util.Base64
import java.util.UUID
class Fido2CredentialStoreImplTest {
private val mutableDecryptCipherListResultStateFlow =
MutableStateFlow<DataState<DecryptCipherListResult>>(DataState.Loading)
private val authRepository: AuthRepository = mockk()
private val vaultSdkSource: VaultSdkSource = mockk()
private val vaultRepository: VaultRepository = mockk {
every { decryptCipherListResultStateFlow } returns mutableDecryptCipherListResultStateFlow
}
private val fido2CredentialStore = Fido2CredentialStoreImpl(
authRepository = authRepository,
vaultSdkSource = vaultSdkSource,
vaultRepository = vaultRepository,
)
@BeforeEach
fun setUp() {
mutableDecryptCipherListResultStateFlow.value = DataState.Loaded(
data = createMockDecryptCipherListResult(
number = 1,
successes = listOf(
createFido2CipherListView(number = 1, credentialId = UUID_CREDENTIAL_ID),
createFido2CipherListView(number = 2, credentialId = B64_CREDENTIAL_ID),
),
),
)
coEvery {
vaultRepository.getCipher(cipherId = "mockId-1")
} returns GetCipherResult.Success(cipherView = UUID_CIPHER_VIEW)
coEvery {
vaultRepository.getCipher(cipherId = "mockId-2")
} returns GetCipherResult.Success(cipherView = B64_CIPHER_VIEW)
}
@Test
fun `findCredentials should match UUID credential id against fresh raw bytes`() = runTest {
val result = fido2CredentialStore.findCredentials(
ids = listOf(UUID.fromString(UUID_CREDENTIAL_ID).toRawBytes()),
ripId = RP_ID,
userHandle = null,
)
assertEquals(listOf(UUID_CIPHER_VIEW), result)
}
@Test
fun `findCredentials should exclude ciphers with non-matching credential ids`() = runTest {
val result = fido2CredentialStore.findCredentials(
ids = listOf(UUID.randomUUID().toRawBytes()),
ripId = RP_ID,
userHandle = null,
)
assertEquals(emptyList<CipherView>(), result)
}
@Test
fun `findCredentials should match b64 credential id against decoded bytes`() = runTest {
val result = fido2CredentialStore.findCredentials(
ids = listOf(B64_CREDENTIAL_BYTES.copyOf()),
ripId = RP_ID,
userHandle = null,
)
assertEquals(listOf(B64_CIPHER_VIEW), result)
}
@Test
fun `findCredentials should match excludeCredentials ids during registration`() = runTest {
// Registration passes the exclude list with the new user's handle; a match must be found
// so the authenticator reports the credential as already registered.
val result = fido2CredentialStore.findCredentials(
ids = listOf(UUID.fromString(UUID_CREDENTIAL_ID).toRawBytes()),
ripId = RP_ID,
userHandle = byteArrayOf(1, 2, 3),
)
assertEquals(listOf(UUID_CIPHER_VIEW), result)
}
@Test
fun `findCredentials should skip credential id filtering when ids are null`() = runTest {
val result = fido2CredentialStore.findCredentials(
ids = null,
ripId = RP_ID,
userHandle = null,
)
assertEquals(listOf(UUID_CIPHER_VIEW, B64_CIPHER_VIEW), result)
}
@Test
fun `findCredentials should skip credential id filtering when ids are empty`() = runTest {
val result = fido2CredentialStore.findCredentials(
ids = emptyList(),
ripId = RP_ID,
userHandle = null,
)
assertEquals(listOf(UUID_CIPHER_VIEW, B64_CIPHER_VIEW), result)
}
}
private const val RP_ID = "mockRpId"
private const val UUID_CREDENTIAL_ID = "3c5d6a1e-8f2b-4c7d-9e0a-1b2c3d4e5f60"
private val B64_CREDENTIAL_BYTES = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
private val B64_CREDENTIAL_ID =
"b64." + Base64.getUrlEncoder().withoutPadding().encodeToString(B64_CREDENTIAL_BYTES)
private val UUID_CIPHER_VIEW = createMockCipherView(number = 1)
private val B64_CIPHER_VIEW = createMockCipherView(number = 2)
private fun UUID.toRawBytes(): ByteArray = ByteBuffer
.allocate(16)
.putLong(mostSignificantBits)
.putLong(leastSignificantBits)
.array()
private fun createFido2CipherListView(
number: Int,
credentialId: String,
) = createMockCipherListView(
number = number,
type = CipherListViewType.Login(
createMockLoginListView(
number = number,
hasFido2 = true,
fido2Credentials = listOf(
createMockFido2CredentialListView(
number = number,
credentialId = credentialId,
rpId = RP_ID,
),
),
),
),
)