[PM-8137] Passkey creation navigation and account switching (#1380)

This commit is contained in:
Patrick Honkonen 2024-06-06 11:36:44 -04:00 committed by Álison Fernandes
parent 19f0990c2f
commit 3a8f3aa0f6
43 changed files with 2690 additions and 29 deletions

View file

@ -0,0 +1,481 @@
{
"apps": [
{
"type": "android",
"info": {
"package_name": "com.android.chrome",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83"
},
{
"build": "userdebug",
"cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.chrome.beta",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "DA:63:3D:34:B6:9E:63:AE:21:03:B4:9D:53:CE:05:2F:C5:F7:F3:C5:3A:AB:94:FD:C2:A2:08:BD:FD:14:24:9C"
},
{
"build": "release",
"cert_fingerprint_sha256": "3D:7A:12:23:01:9A:A3:9D:9E:A0:E3:43:6A:B7:C0:89:6B:FB:4F:B6:79:F4:DE:5F:E7:C2:3F:32:6C:8F:99:4A"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.chrome.dev",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "90:44:EE:5F:EE:4B:BC:5E:21:DD:44:66:54:31:C4:EB:1F:1F:71:A3:27:16:A0:BC:92:7B:CB:B3:92:33:CA:BF"
},
{
"build": "release",
"cert_fingerprint_sha256": "3D:7A:12:23:01:9A:A3:9D:9E:A0:E3:43:6A:B7:C0:89:6B:FB:4F:B6:79:F4:DE:5F:E7:C2:3F:32:6C:8F:99:4A"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.chrome.canary",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "20:19:DF:A1:FB:23:EF:BF:70:C5:BC:D1:44:3C:5B:EA:B0:4F:3F:2F:F4:36:6E:9A:C1:E3:45:76:39:A2:4C:FC"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "org.chromium.chrome",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "C6:AD:B8:B8:3C:6D:4C:17:D2:92:AF:DE:56:FD:48:8A:51:D3:16:FF:8F:2C:11:C5:41:02:23:BF:F8:A7:DB:B3"
},
{
"build": "userdebug",
"cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.google.android.apps.chrome",
"signatures": [
{
"build": "userdebug",
"cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "org.mozilla.fennec_webauthndebug",
"signatures": [
{
"build": "userdebug",
"cert_fingerprint_sha256": "BD:AE:82:02:80:D2:AF:B7:74:94:EF:22:58:AA:78:A9:AE:A1:36:41:7E:8B:C2:3D:C9:87:75:2E:6F:48:E8:48"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "org.mozilla.firefox",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "A7:8B:62:A5:16:5B:44:94:B2:FE:AD:9E:76:A2:80:D2:2D:93:7F:EE:62:51:AE:CE:59:94:46:B2:EA:31:9B:04"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "org.mozilla.firefox_beta",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "A7:8B:62:A5:16:5B:44:94:B2:FE:AD:9E:76:A2:80:D2:2D:93:7F:EE:62:51:AE:CE:59:94:46:B2:EA:31:9B:04"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "org.mozilla.focus",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "org.mozilla.fennec_aurora",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "BC:04:88:83:8D:06:F4:CA:6B:F3:23:86:DA:AB:0D:D8:EB:CF:3E:77:30:78:74:59:F6:2F:B3:CD:14:A1:BA:AA"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "org.mozilla.rocket",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "86:3A:46:F0:97:39:32:B7:D0:19:9B:54:91:12:74:1C:2D:27:31:AC:72:EA:11:B7:52:3A:A9:0A:11:BF:56:91"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.microsoft.emmx.canary",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.microsoft.emmx.dev",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.microsoft.emmx.beta",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.microsoft.emmx",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.microsoft.emmx.rolling",
"signatures": [
{
"build": "userdebug",
"cert_fingerprint_sha256": "32:A2:FC:74:D7:31:10:58:59:E5:A8:5D:F1:6D:95:F1:02:D8:5B:22:09:9B:80:64:C5:D8:91:5C:61:DA:D1:E0"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.microsoft.emmx.local",
"signatures": [
{
"build": "userdebug",
"cert_fingerprint_sha256": "32:A2:FC:74:D7:31:10:58:59:E5:A8:5D:F1:6D:95:F1:02:D8:5B:22:09:9B:80:64:C5:D8:91:5C:61:DA:D1:E0"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.brave.browser",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.brave.browser_beta",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.brave.browser_nightly",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "app.vanadium.browser",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "C6:AD:B8:B8:3C:6D:4C:17:D2:92:AF:DE:56:FD:48:8A:51:D3:16:FF:8F:2C:11:C5:41:02:23:BF:F8:A7:DB:B3"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.vivaldi.browser",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.vivaldi.browser.snapshot",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.vivaldi.browser.sopranos",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.citrix.Receiver",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "3D:D1:12:67:10:69:AB:36:4E:F9:BE:73:9A:B7:B5:EE:15:E1:CD:E9:D8:75:7B:1B:F0:64:F5:0C:55:68:9A:49"
},
{
"build": "release",
"cert_fingerprint_sha256": "CE:B2:23:D7:77:09:F2:B6:BC:0B:3A:78:36:F5:A5:AF:4C:E1:D3:55:F4:A7:28:86:F7:9D:F8:0D:C9:D6:12:2E"
},
{
"build": "release",
"cert_fingerprint_sha256": "AA:D0:D4:57:E6:33:C3:78:25:77:30:5B:C1:B2:D9:E3:81:41:C7:21:DF:0D:AA:6E:29:07:2F:C4:1D:34:F0:AB"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.android.browser",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "C9:00:9D:01:EB:F9:F5:D0:30:2B:C7:1B:2F:E9:AA:9A:47:A4:32:BB:A1:73:08:A3:11:1B:75:D7:B2:14:90:25"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.sec.android.app.sbrowser",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "C8:A2:E9:BC:CF:59:7C:2F:B6:DC:66:BE:E2:93:FC:13:F2:FC:47:EC:77:BC:6B:2B:0D:52:C1:1F:51:19:2A:B8"
},
{
"build": "release",
"cert_fingerprint_sha256": "34:DF:0E:7A:9F:1C:F1:89:2E:45:C0:56:B4:97:3C:D8:1C:CF:14:8A:40:50:D1:1A:EA:4A:C5:A6:5F:90:0A:42"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.sec.android.app.sbrowser.beta",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "C8:A2:E9:BC:CF:59:7C:2F:B6:DC:66:BE:E2:93:FC:13:F2:FC:47:EC:77:BC:6B:2B:0D:52:C1:1F:51:19:2A:B8"
},
{
"build": "release",
"cert_fingerprint_sha256": "34:DF:0E:7A:9F:1C:F1:89:2E:45:C0:56:B4:97:3C:D8:1C:CF:14:8A:40:50:D1:1A:EA:4A:C5:A6:5F:90:0A:42"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.google.android.gms",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "7C:E8:3C:1B:71:F3:D5:72:FE:D0:4C:8D:40:C5:CB:10:FF:75:E6:D8:7D:9D:F6:FB:D5:3F:04:68:C2:90:50:53"
},
{
"build": "release",
"cert_fingerprint_sha256": "D2:2C:C5:00:29:9F:B2:28:73:A0:1A:01:0D:E1:C8:2F:BE:4D:06:11:19:B9:48:14:DD:30:1D:AB:50:CB:76:78"
},
{
"build": "release",
"cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83"
},
{
"build": "release",
"cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.yandex.browser",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.yandex.browser.beta",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.yandex.browser.alpha",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.yandex.browser.corp",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.yandex.browser.canary",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "1D:A9:CB:AE:2D:CC:C6:A5:8D:6C:94:7B:E9:4C:DB:B7:33:D6:5D:A4:D1:77:0F:A1:4A:53:64:CB:4A:28:EB:49"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.yandex.browser.broteam",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "1D:A9:CB:AE:2D:CC:C6:A5:8D:6C:94:7B:E9:4C:DB:B7:33:D6:5D:A4:D1:77:0F:A1:4A:53:64:CB:4A:28:EB:49"
}
]
}
}
]
}

View file

@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2CredentialRequestOrNull
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
@ -37,16 +38,16 @@ private const val SPECIAL_CIRCUMSTANCE_KEY = "special-circumstance"
/**
* A view model that helps launch actions for the [MainActivity].
*/
@Suppress("LongParameterList")
@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class MainViewModel @Inject constructor(
autofillSelectionManager: AutofillSelectionManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
private val intentManager: IntentManager,
authRepository: AuthRepository,
settingsRepository: SettingsRepository,
vaultRepository: VaultRepository,
private val authRepository: AuthRepository,
private val savedStateHandle: SavedStateHandle,
) : BaseViewModel<MainState, MainEvent, MainAction>(
MainState(
@ -172,6 +173,7 @@ class MainViewModel @Inject constructor(
val shareData = intentManager.getShareDataFromIntent(intent)
val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut
val hasVaultShortcut = intent.isMyVaultShortcut
val fido2CredentialRequestData = intent.getFido2CredentialRequestOrNull()
when {
passwordlessRequestData != null -> {
specialCircumstanceManager.specialCircumstance =
@ -210,6 +212,20 @@ class MainViewModel @Inject constructor(
)
}
fido2CredentialRequestData != null -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.Fido2Save(
fido2CredentialRequest = fido2CredentialRequestData,
)
// Switch accounts if the selected user is not the active user.
if (authRepository.activeUserId != null &&
authRepository.activeUserId != fido2CredentialRequestData.userId
) {
authRepository.switchAccount(fido2CredentialRequestData.userId)
}
}
hasGeneratorShortcut -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.GeneratorShortcut

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.api
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
import retrofit2.http.GET
import retrofit2.http.Url
/**
* Defines calls to an RP digital asset link file.
*/
interface DigitalAssetLinkApi {
/**
* Attempts to download the asset links file from the RP.
*/
@GET
suspend fun getDigitalAssetLinks(
@Url url: String,
): Result<List<DigitalAssetLinkResponseJson>>
}

View file

@ -0,0 +1,33 @@
package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.di
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkServiceImpl
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.create
import javax.inject.Singleton
/**
* Provides network dependencies in the fido2 package.
*/
@Module
@InstallIn(SingletonComponent::class)
object Fido2NetworkModule {
@Provides
@Singleton
fun provideDigitalAssetLinkService(
retrofits: Retrofits,
): DigitalAssetLinkService =
DigitalAssetLinkServiceImpl(
digitalAssetLinkApi = retrofits
.staticRetrofitBuilder
// This URL will be overridden dynamically.
.baseUrl("https://www.bitwarden.com")
.build()
.create(),
)
}

View file

@ -0,0 +1,32 @@
package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models a response from an RP digital asset link request.
*/
@Serializable
data class DigitalAssetLinkResponseJson(
@SerialName("relation")
val relation: List<String>,
@SerialName("target")
val target: Target,
) {
/**
* Represents targets for an asset link statement.
*/
@Serializable
data class Target(
@SerialName("namespace")
val namespace: String,
@SerialName("package_name")
val packageName: String?,
@SerialName("sha256_cert_fingerprints")
val sha256CertFingerprints: List<String>?,
)
}

View file

@ -0,0 +1,113 @@
package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models a FIDO 2 credential creation request options received from a Relying Party (RP).
*/
@Serializable
data class PublicKeyCredentialCreationOptions(
@SerialName("authenticatorSelection")
val authenticatorSelection: AuthenticatorSelectionCriteria,
@SerialName("challenge")
val challenge: String,
@SerialName("excludedCredentials")
val excludeCredentials: List<PublicKeyCredentialDescriptor> = emptyList(),
@SerialName("pubKeyCredParams")
val pubKeyCredParams: List<PublicKeyCredentialParameters>,
@SerialName("rp")
val relyingParty: PublicKeyCredentialRpEntity,
@SerialName("user")
val user: PublicKeyCredentialUserEntity,
) {
/**
* Represents criteria that must be respected when selecting a credential.
*/
@Serializable
data class AuthenticatorSelectionCriteria(
@SerialName("authenticatorAttachment")
val authenticatorAttachment: AuthenticatorAttachment? = null,
@SerialName("residentKey")
val residentKey: ResidentKeyRequirement? = null,
) {
/**
* Enum class representing the types of attachments associated with selection criteria.
*/
@Serializable
enum class AuthenticatorAttachment {
@SerialName("platform")
PLATFORM,
@SerialName("cross_platform")
CROSS_PLATFORM,
}
/**
* Enum class indicating the type of authentication expected by the selection criteria.
*/
@Serializable
enum class ResidentKeyRequirement {
/**
* User verification is preferred during selection, if supported.
*/
@SerialName("preferred")
PREFERRED,
/**
* User verification is required during selection.
*/
@SerialName("required")
REQUIRED,
}
}
/**
* Represents details about a credential provided in the creation options.
*/
@Serializable
data class PublicKeyCredentialDescriptor(
@SerialName("type")
val type: String,
@SerialName("id")
val id: String,
@SerialName("transports")
val transports: List<String>,
)
/**
* Represents parameters for a credential in the creation options.
*/
@Serializable
data class PublicKeyCredentialParameters(
@SerialName("type")
val type: String,
@SerialName("alg")
val alg: Long,
)
/**
* Represents the RP associated with the credential options.
*/
@Serializable
data class PublicKeyCredentialRpEntity(
@SerialName("name")
val name: String,
@SerialName("id")
val id: String,
)
/**
* Represents the user associated with teh credential options.
*/
@Serializable
data class PublicKeyCredentialUserEntity(
@SerialName("name")
val name: String,
@SerialName("id")
val id: String,
@SerialName("displayName")
val displayName: String,
)
}

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
/**
* Provides an API for querying digital asset links.
*/
interface DigitalAssetLinkService {
/**
* Attempt to retrieve the asset links file from the provided [relyingParty].
*/
suspend fun getDigitalAssetLinkForRp(
scheme: String = "https://",
relyingParty: String,
): Result<List<DigitalAssetLinkResponseJson>>
}

View file

@ -0,0 +1,21 @@
package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.api.DigitalAssetLinkApi
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
/**
* Primary implementation of [DigitalAssetLinkService].
*/
class DigitalAssetLinkServiceImpl(
private val digitalAssetLinkApi: DigitalAssetLinkApi,
) : DigitalAssetLinkService {
override suspend fun getDigitalAssetLinkForRp(
scheme: String,
relyingParty: String,
): Result<List<DigitalAssetLinkResponseJson>> =
digitalAssetLinkApi
.getDigitalAssetLinks(
url = "$scheme$relyingParty/.well-known/assetlinks.json",
)
}

View file

@ -4,9 +4,13 @@ import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManagerImpl
import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessor
import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessorImpl
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import dagger.Module
@ -14,6 +18,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import javax.inject.Singleton
/**
@ -25,8 +30,8 @@ import javax.inject.Singleton
object Fido2ProviderModule {
@RequiresApi(Build.VERSION_CODES.S)
@Singleton
@Provides
@Singleton
fun provideCredentialProviderProcessor(
@ApplicationContext context: Context,
authRepository: AuthRepository,
@ -39,4 +44,17 @@ object Fido2ProviderModule {
intentManager,
dispatcherManager,
)
@Provides
@Singleton
fun provideFido2CredentialManager(
assetManager: AssetManager,
digitalAssetLinkService: DigitalAssetLinkService,
json: Json,
): Fido2CredentialManager =
Fido2CredentialManagerImpl(
assetManager = assetManager,
digitalAssetLinkService = digitalAssetLinkService,
json = json,
)
}

View file

@ -0,0 +1,28 @@
package com.x8bit.bitwarden.data.autofill.fido2.manager
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
/**
* Responsible for managing FIDO 2 credential creation and authentication.
*/
interface Fido2CredentialManager {
/**
* Attempt to validate the RP and origin of the provided [fido2CredentialRequest].
*/
suspend fun validateOrigin(
fido2CredentialRequest: Fido2CredentialRequest,
): Fido2ValidateOriginResult
/**
* Attempt to create a FIDO2 credential from the given [credentialRequest] and associate it to
* the given [cipherView].
*/
fun createCredentialForCipher(
credentialRequest: Fido2CredentialRequest,
cipherView: CipherView,
): Fido2CreateCredentialResult
}

View file

@ -0,0 +1,149 @@
package com.x8bit.bitwarden.data.autofill.fido2.manager
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.provider.CallingAppInfo
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.PublicKeyCredentialCreationOptions
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.platform.util.getCallingAppApkFingerprint
import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
private const val ALLOW_LIST_FILE_NAME = "fido2_privileged_allow_list.json"
/**
* Primary implementation of [Fido2CredentialManager].
*/
class Fido2CredentialManagerImpl(
private val assetManager: AssetManager,
private val digitalAssetLinkService: DigitalAssetLinkService,
private val json: Json,
) : Fido2CredentialManager {
override suspend fun validateOrigin(
fido2CredentialRequest: Fido2CredentialRequest,
): Fido2ValidateOriginResult {
val callingAppInfo = fido2CredentialRequest.callingAppInfo
return if (callingAppInfo.isOriginPopulated()) {
validatePrivilegedAppOrigin(callingAppInfo)
} else {
validateCallingApplicationAssetLinks(fido2CredentialRequest)
}
}
@Suppress("ReturnCount")
private suspend fun validateCallingApplicationAssetLinks(
fido2CredentialRequest: Fido2CredentialRequest,
): Fido2ValidateOriginResult {
val callingAppInfo = fido2CredentialRequest.callingAppInfo
return fido2CredentialRequest
.requestJson
.getRpId(json)
.flatMap { rpId ->
digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = rpId)
}
.onFailure {
return Fido2ValidateOriginResult.Error.AssetLinkNotFound
}
.map { statements ->
statements
.filterMatchingAppStatementsOrNull(
rpPackageName = callingAppInfo.packageName,
)
?: return Fido2ValidateOriginResult.Error.ApplicationNotFound
}
.map { matchingStatements ->
matchingStatements
.filterMatchingAppSignaturesOrNull(
signature = callingAppInfo.getCallingAppApkFingerprint(),
)
?: return Fido2ValidateOriginResult.Error.ApplicationNotVerified
}
.fold(
onSuccess = {
Fido2ValidateOriginResult.Success
},
onFailure = {
Fido2ValidateOriginResult.Error.Unknown
},
)
}
private suspend fun validatePrivilegedAppOrigin(
callingAppInfo: CallingAppInfo,
): Fido2ValidateOriginResult =
assetManager
.readAsset(ALLOW_LIST_FILE_NAME)
.map { allowList ->
callingAppInfo.validatePrivilegedApp(
allowList = allowList,
)
}
.fold(
onSuccess = { it },
onFailure = { Fido2ValidateOriginResult.Error.Unknown },
)
override fun createCredentialForCipher(
credentialRequest: Fido2CredentialRequest,
cipherView: CipherView,
): Fido2CreateCredentialResult {
// TODO [PM-8137]: Create and save passkey to cipher.
return Fido2CreateCredentialResult.Error(CreateCredentialUnknownException())
}
/**
* Returns statements targeting the calling Android application, or null.
*/
private fun List<DigitalAssetLinkResponseJson>.filterMatchingAppStatementsOrNull(
rpPackageName: String,
): List<DigitalAssetLinkResponseJson>? =
filter { statement ->
val target = statement.target
target.namespace == "android_app" &&
target.packageName == rpPackageName &&
statement.relation.containsAll(
listOf(
"delegate_permission/common.get_login_creds",
"delegate_permission/common.handle_all_urls",
),
)
}
.takeUnless { it.isEmpty() }
/**
* Returns statements that match the given [signature], or null.
*/
private fun List<DigitalAssetLinkResponseJson>.filterMatchingAppSignaturesOrNull(
signature: String,
): List<DigitalAssetLinkResponseJson>? =
filter { statement ->
statement.target.sha256CertFingerprints
?.contains(signature)
?: false
}
.takeUnless { it.isEmpty() }
private fun String.getRpId(json: Json): Result<String> {
return try {
json
.decodeFromString<PublicKeyCredentialCreationOptions>(this)
.relyingParty
.id
.asSuccess()
} catch (e: SerializationException) {
e.asFailure()
} catch (e: IllegalArgumentException) {
e.asFailure()
}
}
}

View file

@ -0,0 +1,23 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import androidx.credentials.exceptions.CreateCredentialException
/**
* Models the data returned from creating a FIDO 2 credential.
*/
sealed class Fido2CreateCredentialResult {
/**
* Models a successful response for creating a credential.
*/
data class Success(
val registrationResponse: String,
) : Fido2CreateCredentialResult()
/**
* Models an error response for creating a credential.
*/
data class Error(
val exception: CreateCredentialException,
) : Fido2CreateCredentialResult()
}

View file

@ -0,0 +1,30 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import android.content.pm.SigningInfo
import android.os.Parcelable
import androidx.credentials.provider.CallingAppInfo
import kotlinx.parcelize.Parcelize
/**
* Represents raw data from the a user deciding to create a passkey in their vault via the
* credential manager framework.
*
* @property userId The user under which the passkey should be saved.
* @property requestJson JSON payload containing the RP request.
* @property callingAppInfo Information about the application that initiated the request.
*/
@Parcelize
data class Fido2CredentialRequest(
val userId: String,
val requestJson: String,
val packageName: String,
val signingInfo: SigningInfo,
val origin: String?,
) : Parcelable {
val callingAppInfo: CallingAppInfo
get() = CallingAppInfo(
packageName = packageName,
signingInfo = signingInfo,
origin = origin,
)
}

View file

@ -0,0 +1,55 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
/**
* Models the result of validating the origin of a FIDO2 request.
*/
sealed class Fido2ValidateOriginResult {
/**
* Represents a successful origin validation.
*/
data object Success : Fido2ValidateOriginResult()
/**
* Represents a validation error.
*/
sealed class Error : Fido2ValidateOriginResult() {
/**
* Indicates the digital asset links file could not be located.
*/
data object AssetLinkNotFound : Error()
/**
* Indicates the application package name was not found in the digital asset links file.
*/
data object ApplicationNotFound : Error()
/**
* Indicates the application fingerprint was not found the digital asset links file.
*/
data object ApplicationNotVerified : Error()
/**
* Indicates the calling application is privileged but its package name is not found within
* the privileged app allow list.
*/
data object PrivilegedAppNotAllowed : Error()
/**
* Indicates the calling app is privileged but but no matching signing certificate signature
* is present in the allow list.
*/
data object PrivilegedAppSignatureNotFound : Error()
/**
* Indicates passkeys are not supported for the requesting application.
*/
data object PasskeyNotSupportedForApp : Error()
/**
* Indicates an unknown error was encountered while validating the origin.
*/
data object Unknown : Error()
}
}

View file

@ -0,0 +1,38 @@
package com.x8bit.bitwarden.data.autofill.fido2.util
import android.content.Intent
import android.os.Build
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.provider.PendingIntentHandler
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_USER_ID
/**
* Checks if this [Intent] contains a [Fido2CredentialRequest] related to an ongoing FIDO 2
* credential creation process.
*/
@Suppress("ReturnCount")
@OmitFromCoverage
fun Intent.getFido2CredentialRequestOrNull(): Fido2CredentialRequest? {
if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) return null
val systemRequest = PendingIntentHandler.retrieveProviderCreateCredentialRequest(this)
?: return null
val createPublicKeyRequest =
systemRequest.callingRequest as? CreatePublicKeyCredentialRequest
?: return null
val userId = getStringExtra(EXTRA_KEY_USER_ID)
?: return null
return Fido2CredentialRequest(
userId = userId,
requestJson = createPublicKeyRequest.requestJson,
packageName = systemRequest.callingAppInfo.packageName,
signingInfo = systemRequest.callingAppInfo.signingInfo,
origin = systemRequest.callingAppInfo.origin,
)
}

View file

@ -0,0 +1,13 @@
package com.x8bit.bitwarden.data.platform.manager
/**
* Manages reading assets.
*/
interface AssetManager {
/**
* Read [fileName] from the assets folder. A successful result will contain the contents as a
* String.
*/
suspend fun readAsset(fileName: String): Result<String>
}

View file

@ -0,0 +1,26 @@
package com.x8bit.bitwarden.data.platform.manager
import android.content.Context
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import kotlinx.coroutines.withContext
/**
* Primary implementation of [AssetManager].
*/
@OmitFromCoverage
class AssetManagerImpl(
private val context: Context,
private val dispatcherManager: DispatcherManager,
) : AssetManager {
override suspend fun readAsset(fileName: String): Result<String> = runCatching {
withContext(dispatcherManager.io) {
context
.assets
.open(fileName)
.bufferedReader()
.use { it.readText() }
}
}
}

View file

@ -13,6 +13,8 @@ import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlI
import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManagerImpl
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.manager.AssetManagerImpl
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.CrashLogsManager
@ -177,4 +179,14 @@ object PlatformManagerModule {
settingsRepository = settingsRepository,
legacyAppCenterMigrator = legacyAppCenterMigrator,
)
@Provides
@Singleton
fun provideAssetManager(
@ApplicationContext context: Context,
dispatcherManager: DispatcherManager,
): AssetManager = AssetManagerImpl(
context = context,
dispatcherManager = dispatcherManager,
)
}

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.platform.manager.model
import android.os.Parcelable
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
@ -47,6 +48,15 @@ sealed class SpecialCircumstance : Parcelable {
val shouldFinishWhenComplete: Boolean,
) : SpecialCircumstance()
/**
* The app was launched via the credential manager framework in order to allow the user to
* manually save a passkey to their vault.
*/
@Parcelize
data class Fido2Save(
val fido2CredentialRequest: Fido2CredentialRequest,
) : SpecialCircumstance()
/**
* The app was launched via deeplink to the generator.
*/

View file

@ -15,6 +15,7 @@ fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? =
is SpecialCircumstance.ShareNewSend -> null
SpecialCircumstance.GeneratorShortcut -> null
SpecialCircumstance.VaultShortcut -> null
is SpecialCircumstance.Fido2Save -> null
}
/**
@ -28,4 +29,5 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData?
is SpecialCircumstance.ShareNewSend -> null
SpecialCircumstance.GeneratorShortcut -> null
SpecialCircumstance.VaultShortcut -> null
is SpecialCircumstance.Fido2Save -> null
}

View file

@ -0,0 +1,13 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.data.platform.util
import android.os.Build
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
* Returns true if the current OS build version is below the provided [version].
*
* @see Build.VERSION_CODES
*/
fun isBuildVersionBelow(version: Int): Boolean = version > Build.VERSION.SDK_INT

View file

@ -0,0 +1,60 @@
package com.x8bit.bitwarden.data.platform.util
import androidx.credentials.provider.CallingAppInfo
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull
import java.security.MessageDigest
/**
* Returns the name of the RP. If this [CallingAppInfo] is a privileged app the RP host name will be
* returned. If this [CallingAppInfo] is a native RP application the package name will be returned.
* Otherwise, `null` is returned.
*/
fun CallingAppInfo.getFido2RpOrNull(): String? {
return if (isOriginPopulated()) {
origin?.toHostOrPathOrNull()
} else {
packageName
}
}
/**
* Returns the signing certificate hash formatted as a hex string.
*/
@OptIn(ExperimentalStdlibApi::class)
fun CallingAppInfo.getCallingAppApkFingerprint(): String {
val cert = signingInfo.apkContentsSigners[0].toByteArray()
val md = MessageDigest.getInstance("SHA-256")
val certHash = md.digest(cert)
return certHash
.joinToString(":") { b ->
b.toHexString(HexFormat.UpperCase)
}
}
/**
* Returns true if this [CallingAppInfo] is present in the privileged app [allowList]. Otherwise,
* returns false.
*/
fun CallingAppInfo.validatePrivilegedApp(allowList: String): Fido2ValidateOriginResult {
if (!allowList.contains("\"package_name\": \"$packageName\"")) {
return Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed
}
return try {
if (getOrigin(allowList) != null) {
Fido2ValidateOriginResult.Success
} else {
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp
}
} catch (e: IllegalStateException) {
// We know the package name is in the allow list so we can infer that this exception is
// thrown because no matching signature is found.
Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound
} catch (e: IllegalArgumentException) {
// The allow list is not formatted correctly so we notify the user passkeys are not
// supported for this application
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp
}
}

View file

@ -0,0 +1,14 @@
package com.x8bit.bitwarden.ui.autofill.fido2.manager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
/**
* A manager for completing the FIDO 2 creation process.
*/
interface Fido2CompletionManager {
/**
* Completes the FIDO 2 creation process with the provided [result].
*/
fun completeFido2Create(result: Fido2CreateCredentialResult)
}

View file

@ -0,0 +1,47 @@
package com.x8bit.bitwarden.ui.autofill.fido2.manager
import android.app.Activity
import android.content.Intent
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.credentials.CreatePublicKeyCredentialResponse
import androidx.credentials.provider.PendingIntentHandler
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
* Primary implementation of [Fido2CompletionManager].
*/
@OmitFromCoverage
class Fido2CompletionManagerImpl(
private val activity: Activity,
) : Fido2CompletionManager {
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
override fun completeFido2Create(result: Fido2CreateCredentialResult) {
activity.also {
val intent = Intent()
when (result) {
is Fido2CreateCredentialResult.Error -> {
PendingIntentHandler
.setCreateCredentialException(
intent = intent,
exception = result.exception,
)
}
is Fido2CreateCredentialResult.Success -> {
PendingIntentHandler
.setCreateCredentialResponse(
intent = intent,
response = CreatePublicKeyCredentialResponse(
registrationResponseJson = result.registrationResponse,
),
)
}
}
it.setResult(Activity.RESULT_OK, intent)
it.finish()
}
}
}

View file

@ -25,6 +25,11 @@ import kotlin.math.floor
*/
const val ZERO_WIDTH_CHARACTER: String = "\u200B"
/**
* URI scheme for a native Android application.
*/
private const val ANDROID_APP_URI_SCHEME: String = "androidapp://"
/**
* Returns the original [String] only if:
*
@ -68,6 +73,13 @@ fun String.toHostOrPathOrNull(): String? {
return uri.host ?: uri.path
}
/**
* Returns the original [String] prefixed with `androidapp://` if it doesn't already contain.
*/
fun String.toAndroidAppUriString(): String {
return if (this.startsWith(ANDROID_APP_URI_SCHEME)) this else "$ANDROID_APP_URI_SCHEME$this"
}
/**
* Returns the original [String] only if:
*

View file

@ -9,6 +9,8 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.platform.LocalContext
import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager
import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManagerImpl
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManagerImpl
@ -33,6 +35,7 @@ fun LocalManagerProvider(content: @Composable () -> Unit) {
LocalExitManager provides ExitManagerImpl(activity),
LocalBiometricsManager provides BiometricsManagerImpl(activity),
LocalNfcManager provides NfcManagerImpl(activity),
LocalFido2CompletionManager provides Fido2CompletionManagerImpl(activity),
) {
content()
}
@ -72,3 +75,8 @@ val LocalPermissionsManager: ProvidableCompositionLocal<PermissionsManager> = co
val LocalNfcManager: ProvidableCompositionLocal<NfcManager> = compositionLocalOf {
error("CompositionLocal NfcManager not present")
}
val LocalFido2CompletionManager: ProvidableCompositionLocal<Fido2CompletionManager> =
compositionLocalOf {
error("CompositionLocal Fido2CompletionManager not present")
}

View file

@ -46,6 +46,7 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.navigateToVaultAddEdit
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.navigateToVaultItemListingAsRoot
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import java.util.concurrent.atomic.AtomicReference
@ -105,6 +106,7 @@ fun RootNavScreen(
is RootNavState.VaultUnlockedForAutofillSelection,
is RootNavState.VaultUnlockedForNewSend,
is RootNavState.VaultUnlockedForAuthRequest,
is RootNavState.VaultUnlockedForFido2Save,
-> VAULT_UNLOCKED_GRAPH_ROUTE
}
val currentRoute = navController.currentDestination?.rootLevelRoute()
@ -182,6 +184,14 @@ fun RootNavScreen(
navOptions = rootNavOptions,
)
}
is RootNavState.VaultUnlockedForFido2Save -> {
navController.navigateToVaultUnlockedGraph(rootNavOptions)
navController.navigateToVaultItemListingAsRoot(
vaultItemListingType = VaultItemListingType.Login,
navOptions = rootNavOptions,
)
}
}
}
}

View file

@ -4,6 +4,7 @@ import android.os.Parcelable
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
@ -93,6 +94,13 @@ class RootNavViewModel @Inject constructor(
RootNavState.VaultUnlockedForAuthRequest
}
is SpecialCircumstance.Fido2Save -> {
RootNavState.VaultUnlockedForFido2Save(
activeUserId = userState.activeUserId,
fido2CredentialRequest = specialCircumstance.fido2CredentialRequest,
)
}
SpecialCircumstance.GeneratorShortcut,
SpecialCircumstance.VaultShortcut,
null,
@ -172,6 +180,20 @@ sealed class RootNavState : Parcelable {
val type: AutofillSelectionData.Type,
) : RootNavState()
/**
* App should show an add item screen for a user to complete the saving of data collected by
* the fido2 credential manager framework
*
* @param activeUserId ID of the active user. Indirectly used to notify [RootNavViewModel] the
* active user has changed.
* @param fido2CredentialRequest System request containing FIDO credential data.
*/
@Parcelize
data class VaultUnlockedForFido2Save(
val activeUserId: String,
val fido2CredentialRequest: Fido2CredentialRequest,
) : RootNavState()
/**
* App should show the new send screen for an unlocked user.
*/

View file

@ -49,7 +49,7 @@ private const val TEMP_CAMERA_IMAGE_DIR: String = "camera_temp"
*
* @see IntentManager.createFido2CreationPendingIntent
*/
private const val EXTRA_KEY_USER_ID: String = "EXTRA_KEY_USER_ID"
const val EXTRA_KEY_USER_ID: String = "user_id"
/**
* The default implementation of the [IntentManager] for simplifying the handling of Android

View file

@ -27,6 +27,7 @@ import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountActionItem
import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountSwitcher
@ -43,6 +44,7 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalFido2CompletionManager
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
@ -69,6 +71,7 @@ fun VaultItemListingScreen(
onNavigateToEditSendItem: (sendId: String) -> Unit,
onNavigateToSearch: (searchType: SearchType) -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
fido2CompletionManager: Fido2CompletionManager = LocalFido2CompletionManager.current,
viewModel: VaultItemListingViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@ -130,6 +133,10 @@ fun VaultItemListingScreen(
is VaultItemListingEvent.NavigateToCollectionItem -> {
onNavigateToVaultItemListing(VaultItemListingType.Collection(event.collectionId))
}
is VaultItemListingEvent.CompleteFido2Create -> {
fido2CompletionManager.completeFido2Create(event.result)
}
}
}
@ -138,6 +145,13 @@ fun VaultItemListingScreen(
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(VaultItemListingsAction.DismissDialogClick) }
},
onDismissFido2ErrorDialog = remember(viewModel) {
{
viewModel.trySendAction(
VaultItemListingsAction.DismissFido2CreationErrorDialogClick,
)
}
},
)
VaultItemListingScaffold(
@ -153,6 +167,7 @@ fun VaultItemListingScreen(
private fun VaultItemListingDialogs(
dialogState: VaultItemListingState.DialogState?,
onDismissRequest: () -> Unit,
onDismissFido2ErrorDialog: () -> Unit,
) {
when (dialogState) {
is VaultItemListingState.DialogState.Error -> BitwardenBasicDialog(
@ -167,6 +182,14 @@ private fun VaultItemListingDialogs(
visibilityState = LoadingDialogState.Shown(dialogState.message),
)
is VaultItemListingState.DialogState.Fido2CreationFail -> BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = dialogState.title,
message = dialogState.message,
),
onDismissRequest = onDismissFido2ErrorDialog,
)
null -> Unit
}
}

View file

@ -1,11 +1,16 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting
import android.os.Parcelable
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
@ -19,6 +24,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl
import com.x8bit.bitwarden.data.platform.repository.util.map
import com.x8bit.bitwarden.data.platform.util.getFido2RpOrNull
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
@ -29,6 +35,7 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.platform.base.util.toAndroidAppUriString
import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.platform.components.model.IconData
@ -61,7 +68,7 @@ import javax.inject.Inject
* and launches [VaultItemListingEvent] for the [VaultItemListingScreen].
*/
@HiltViewModel
@Suppress("MagicNumber", "TooManyFunctions", "LongParameterList")
@Suppress("MagicNumber", "TooManyFunctions", "LongParameterList", "LargeClass")
class VaultItemListingViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val clock: Clock,
@ -74,13 +81,20 @@ class VaultItemListingViewModel @Inject constructor(
private val cipherMatchingManager: CipherMatchingManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val policyManager: PolicyManager,
private val fido2CredentialManager: Fido2CredentialManager,
) : BaseViewModel<VaultItemListingState, VaultItemListingEvent, VaultItemListingsAction>(
initialState = run {
val userState = requireNotNull(authRepository.userStateFlow.value)
val activeAccountSummary = userState.toActiveAccountSummary()
val accountSummaries = userState.toAccountSummaries()
val specialCircumstance =
specialCircumstanceManager.specialCircumstance as? SpecialCircumstance.AutofillSelection
val specialCircumstance = specialCircumstanceManager.specialCircumstance
val autofillSelectionData = specialCircumstance as? SpecialCircumstance.AutofillSelection
val fido2CreationData = specialCircumstance as? SpecialCircumstance.Fido2Save
val shouldFinishOnComplete = autofillSelectionData
?.shouldFinishWhenComplete
?: (fido2CreationData != null)
val dialogState = fido2CreationData
?.let { VaultItemListingState.DialogState.Loading(R.string.loading.asText()) }
VaultItemListingState(
itemListingType = VaultItemListingArgs(savedStateHandle = savedStateHandle)
.vaultItemListingType
@ -93,13 +107,14 @@ class VaultItemListingViewModel @Inject constructor(
baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value,
dialogState = null,
dialogState = dialogState,
policyDisablesSend = policyManager
.getActivePolicies(type = PolicyTypeJson.DISABLE_SEND)
.any(),
autofillSelectionData = specialCircumstance?.autofillSelectionData,
shouldFinishOnComplete = specialCircumstance?.shouldFinishWhenComplete ?: false,
autofillSelectionData = autofillSelectionData?.autofillSelectionData,
shouldFinishOnComplete = shouldFinishOnComplete,
hasMasterPassword = userState.activeAccount.hasMasterPassword,
fido2CredentialRequest = fido2CreationData?.fido2CredentialRequest,
)
},
) {
@ -116,16 +131,18 @@ class VaultItemListingViewModel @Inject constructor(
.onEach { sendAction(VaultItemListingsAction.Internal.IconLoadingSettingReceive(it)) }
.launchIn(viewModelScope)
vaultRepository
.vaultDataStateFlow
.onEach {
sendAction(
VaultItemListingsAction.Internal.VaultDataReceive(
it.filterForAutofillIfNecessary(),
),
)
}
.launchIn(viewModelScope)
viewModelScope.launch {
state
.fido2CredentialRequest
?.let { request ->
sendAction(
VaultItemListingsAction.Internal.ValidateFido2OriginResultReceive(
result = fido2CredentialManager.validateOrigin(request),
),
)
}
?: observeVaultData()
}
policyManager
.getActivePoliciesFlow(type = PolicyTypeJson.DISABLE_SEND)
@ -134,12 +151,30 @@ class VaultItemListingViewModel @Inject constructor(
.launchIn(viewModelScope)
}
private fun observeVaultData() {
vaultRepository
.vaultDataStateFlow
.map {
VaultItemListingsAction.Internal.VaultDataReceive(
it
.filterForAutofillIfNecessary()
.filterForFido2CreationIfNecessary(),
)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: VaultItemListingsAction) {
when (action) {
is VaultItemListingsAction.LockAccountClick -> handleLockAccountClick(action)
is VaultItemListingsAction.LogoutAccountClick -> handleLogoutAccountClick(action)
is VaultItemListingsAction.SwitchAccountClick -> handleSwitchAccountClick(action)
is VaultItemListingsAction.DismissDialogClick -> handleDismissDialogClick()
is VaultItemListingsAction.DismissFido2CreationErrorDialogClick -> {
handleDismissFido2ErrorDialogClick()
}
is VaultItemListingsAction.BackClick -> handleBackClick()
is VaultItemListingsAction.FolderClick -> handleFolderClick(action)
is VaultItemListingsAction.CollectionClick -> handleCollectionClick(action)
@ -249,6 +284,7 @@ class VaultItemListingViewModel @Inject constructor(
sendEvent(VaultItemListingEvent.NavigateToSendItem(id = action.sendId))
}
@Suppress("ReturnCount")
private fun handleItemClick(action: VaultItemListingsAction.ItemClick) {
if (state.isAutofill) {
val cipherView = getCipherViewOrNull(action.id) ?: return
@ -256,6 +292,16 @@ class VaultItemListingViewModel @Inject constructor(
return
}
if (state.isFido2Creation) {
val cipherView = getCipherViewOrNull(action.id) ?: return
val credentialRequest = state.fido2CredentialRequest ?: return
fido2CredentialManager.createCredentialForCipher(
credentialRequest = credentialRequest,
cipherView = cipherView,
)
return
}
val event = when (state.itemListingType) {
is VaultItemListingState.ItemListingType.Vault -> {
VaultItemListingEvent.NavigateToVaultItem(id = action.id)
@ -337,6 +383,16 @@ class VaultItemListingViewModel @Inject constructor(
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleDismissFido2ErrorDialogClick() {
sendEvent(
VaultItemListingEvent.CompleteFido2Create(
result = Fido2CreateCredentialResult.Error(
exception = CreateCredentialUnknownException(),
),
),
)
}
private fun handleBackClick() {
sendEvent(
event = VaultItemListingEvent.NavigateBack,
@ -456,6 +512,10 @@ class VaultItemListingViewModel @Inject constructor(
is VaultItemListingsAction.Internal.PolicyUpdateReceive -> {
handlePolicyUpdateReceive(action)
}
is VaultItemListingsAction.Internal.ValidateFido2OriginResultReceive -> {
handleValidateFido2OriginResultReceive(action)
}
}
}
@ -671,6 +731,64 @@ class VaultItemListingViewModel @Inject constructor(
}
}
private fun handleValidateFido2OriginResultReceive(
action: VaultItemListingsAction.Internal.ValidateFido2OriginResultReceive,
) {
when (val result = action.result) {
is Fido2ValidateOriginResult.Error -> {
handleFido2OriginValidationFail(result)
}
Fido2ValidateOriginResult.Success -> {
handleFido2OriginValidationSuccess()
}
}
}
private fun handleFido2OriginValidationFail(error: Fido2ValidateOriginResult.Error) {
val messageResId = when (error) {
Fido2ValidateOriginResult.Error.ApplicationNotFound -> {
R.string.passkey_operation_failed_because_app_not_found_in_asset_links
}
Fido2ValidateOriginResult.Error.ApplicationNotVerified -> {
R.string.passkey_operation_failed_because_app_could_not_be_verified
}
Fido2ValidateOriginResult.Error.AssetLinkNotFound -> {
R.string.passkey_operation_failed_because_of_missing_asset_links
}
Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed -> {
R.string.passkey_operation_failed_because_browser_is_not_privileged
}
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp -> {
R.string.passkeys_not_supported_for_this_app
}
Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound -> {
R.string.passkey_operation_failed_because_browser_signature_does_not_match
}
Fido2ValidateOriginResult.Error.Unknown -> {
R.string.generic_error_message
}
}
mutableStateFlow.update {
it.copy(
dialogState = VaultItemListingState.DialogState.Fido2CreationFail(
title = R.string.an_error_has_occurred.asText(),
message = messageResId.asText(),
),
)
}
}
private fun handleFido2OriginValidationSuccess() {
observeVaultData()
}
private fun updateStateWithVaultData(vaultData: VaultData, clearDialogState: Boolean) {
mutableStateFlow.update { currentState ->
currentState.copy(
@ -691,6 +809,7 @@ class VaultItemListingViewModel @Inject constructor(
baseIconUrl = state.baseIconUrl,
isIconLoadingDisabled = state.isIconLoadingDisabled,
autofillSelectionData = state.autofillSelectionData,
fido2CreationData = state.fido2CredentialRequest,
)
}
@ -736,6 +855,26 @@ class VaultItemListingViewModel @Inject constructor(
)
}
}
/**
* Takes the given vault data and filters it for fido2 credential creation if necessary.
*/
@Suppress("MaxLineLength")
private suspend fun DataState<VaultData>.filterForFido2CreationIfNecessary(): DataState<VaultData> {
val request = state.fido2CredentialRequest ?: return this
return this.map { vaultData ->
val matchUri = request.origin
?: request.packageName
.toAndroidAppUriString()
vaultData.copy(
cipherViewList = cipherMatchingManager.filterCiphersForMatches(
ciphers = vaultData.cipherViewList,
matchUri = matchUri,
),
)
}
}
}
/**
@ -755,6 +894,7 @@ data class VaultItemListingState(
// Internal
private val isPullToRefreshSettingEnabled: Boolean,
val autofillSelectionData: AutofillSelectionData? = null,
val fido2CredentialRequest: Fido2CredentialRequest? = null,
val shouldFinishOnComplete: Boolean = false,
val hasMasterPassword: Boolean,
) {
@ -764,6 +904,12 @@ data class VaultItemListingState(
val isAutofill: Boolean
get() = autofillSelectionData != null
/**
* Whether or not this represents a listing screen for FIDO2 creation.
*/
val isFido2Creation: Boolean
get() = fido2CredentialRequest != null
/**
* A displayable title for the AppBar.
*/
@ -772,6 +918,10 @@ data class VaultItemListingState(
?.uri
?.toHostOrPathOrNull()
?.let { R.string.items_for_uri.asText(it) }
?: fido2CredentialRequest
?.callingAppInfo
?.getFido2RpOrNull()
?.let { R.string.items_for_uri.asText(it) }
?: itemListingType.titleText
/**
@ -783,17 +933,17 @@ data class VaultItemListingState(
/**
* Whether or not the account switcher should be shown.
*/
val shouldShowAccountSwitcher: Boolean get() = isAutofill
val shouldShowAccountSwitcher: Boolean get() = isAutofill || isFido2Creation
/**
* Whether or not the navigation icon should be shown.
*/
val shouldShowNavigationIcon: Boolean get() = !isAutofill
val shouldShowNavigationIcon: Boolean get() = !isAutofill && !isFido2Creation
/**
* Whether or not the overflow menu should be shown.
*/
val shouldShowOverflowMenu: Boolean get() = !isAutofill
val shouldShowOverflowMenu: Boolean get() = !isAutofill && !isFido2Creation
/**
* Represents the current state of any dialogs on the screen.
@ -809,6 +959,16 @@ data class VaultItemListingState(
val message: Text,
) : DialogState()
/**
* Represents a dialog indicating that the FIDO 2 credential creation flow was not
* successful.
*/
@Parcelize
data class Fido2CreationFail(
val title: Text,
val message: Text,
) : DialogState()
/**
* Represents a loading dialog with the given [message].
*/
@ -888,6 +1048,7 @@ data class VaultItemListingState(
* @property overflowOptions list of options for the item's overflow menu.
* @property optionsTestTag The test tag associated with the [overflowOptions].
* @property isAutofill whether or not this screen is part of an autofill flow.
* @property isFido2Creation whether or not this screen is part of fido2 creation flow.
* @property shouldShowMasterPasswordReprompt whether or not a master password reprompt is
* required for various secure actions.
*/
@ -903,6 +1064,7 @@ data class VaultItemListingState(
val overflowOptions: List<ListingItemOverflowAction>,
val optionsTestTag: String,
val isAutofill: Boolean,
val isFido2Creation: Boolean,
val shouldShowMasterPasswordReprompt: Boolean,
)
@ -1131,6 +1293,15 @@ sealed class VaultItemListingEvent {
* @property text the text to display.
*/
data class ShowToast(val text: Text) : VaultItemListingEvent()
/**
* Complete the current FIDO 2 credential creation process.
*
* @property result the result of FIDO 2 credential creation.
*/
data class CompleteFido2Create(
val result: Fido2CreateCredentialResult,
) : VaultItemListingEvent()
}
/**
@ -1165,6 +1336,11 @@ sealed class VaultItemListingsAction {
*/
data object DismissDialogClick : VaultItemListingsAction()
/**
* Click to dismiss the FIDO 2 creation error dialog.
*/
data object DismissFido2CreationErrorDialogClick : VaultItemListingsAction()
/**
* Click the refresh button.
*/
@ -1293,6 +1469,14 @@ sealed class VaultItemListingsAction {
data class PolicyUpdateReceive(
val policyDisablesSend: Boolean,
) : Internal()
/**
* Indicates that a result for validating the relying party's origin during a FIDO 2
* request.
*/
data class ValidateFido2OriginResultReceive(
val result: Fido2ValidateOriginResult,
) : Internal()
}
}

View file

@ -11,6 +11,7 @@ import com.bitwarden.core.FolderView
import com.bitwarden.core.SendType
import com.bitwarden.core.SendView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.util.subtitle
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
@ -99,6 +100,7 @@ fun VaultData.toViewState(
baseIconUrl: String,
isIconLoadingDisabled: Boolean,
autofillSelectionData: AutofillSelectionData?,
fido2CreationData: Fido2CredentialRequest?,
): VaultItemListingState.ViewState {
val filteredCipherViewList = cipherViewList
.filter { cipherView ->
@ -126,6 +128,7 @@ fun VaultData.toViewState(
hasMasterPassword = hasMasterPassword,
isIconLoadingDisabled = isIconLoadingDisabled,
isAutofill = autofillSelectionData != null,
isFido2Creation = fido2CreationData != null,
),
displayFolderList = folderList.map { folderView ->
VaultItemListingState.FolderDisplayItem(
@ -251,6 +254,7 @@ private fun List<CipherView>.toDisplayItemList(
hasMasterPassword: Boolean,
isIconLoadingDisabled: Boolean,
isAutofill: Boolean,
isFido2Creation: Boolean,
): List<VaultItemListingState.DisplayItem> =
this.map {
it.toDisplayItem(
@ -258,6 +262,7 @@ private fun List<CipherView>.toDisplayItemList(
hasMasterPassword = hasMasterPassword,
isIconLoadingDisabled = isIconLoadingDisabled,
isAutofill = isAutofill,
isFido2Creation = isFido2Creation,
)
}
@ -277,6 +282,7 @@ private fun CipherView.toDisplayItem(
hasMasterPassword: Boolean,
isIconLoadingDisabled: Boolean,
isAutofill: Boolean,
isFido2Creation: Boolean,
): VaultItemListingState.DisplayItem =
VaultItemListingState.DisplayItem(
id = id.orEmpty(),
@ -293,6 +299,7 @@ private fun CipherView.toDisplayItem(
overflowOptions = toOverflowActions(hasMasterPassword = hasMasterPassword),
optionsTestTag = "CipherOptionsButton",
isAutofill = isAutofill,
isFido2Creation = isFido2Creation,
shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD,
)
@ -344,6 +351,7 @@ private fun SendView.toDisplayItem(
optionsTestTag = "SendOptionsButton",
isAutofill = false,
shouldShowMasterPasswordReprompt = false,
isFido2Creation = false,
)
@get:DrawableRes

View file

@ -1,12 +1,18 @@
package com.x8bit.bitwarden
import android.content.Intent
import android.content.pm.SigningInfo
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2CredentialRequestOrNull
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManagerImpl
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
@ -18,6 +24,7 @@ import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManage
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
@ -26,6 +33,7 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppThem
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@ -45,16 +53,19 @@ class MainViewModelTest : BaseViewModelTest() {
private val autofillSelectionManager: AutofillSelectionManager = AutofillSelectionManagerImpl()
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val authRepository = mockk<AuthRepository> {
every { userStateFlow } returns mutableUserStateFlow
}
private val mutableAppThemeFlow = MutableStateFlow(AppTheme.DEFAULT)
private val mutableScreenCaptureAllowedFlow = MutableStateFlow(true)
private val fido2CredentialManager = mockk<Fido2CredentialManager>()
private val settingsRepository = mockk<SettingsRepository> {
every { appTheme } returns AppTheme.DEFAULT
every { appThemeStateFlow } returns mutableAppThemeFlow
every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedFlow
}
private val authRepository = mockk<AuthRepository> {
every { activeUserId } returns DEFAULT_USER_STATE.activeUserId
every { userStateFlow } returns mutableUserStateFlow
every { switchAccount(any()) } returns SwitchAccountResult.NoChange
}
private val mutableVaultStateEventFlow = bufferedMutableSharedFlow<VaultStateEvent>()
private val vaultRepository = mockk<VaultRepository> {
every { vaultStateEventFlow } returns mutableVaultStateEventFlow
@ -74,6 +85,7 @@ class MainViewModelTest : BaseViewModelTest() {
Intent::getPasswordlessRequestDataIntentOrNull,
Intent::getAutofillSaveItemOrNull,
Intent::getAutofillSelectionDataOrNull,
Intent::getFido2CredentialRequestOrNull,
)
mockkStatic(
Intent::isMyVaultShortcut,
@ -349,6 +361,111 @@ class MainViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveFirstIntent with fido2 request data should set the special circumstance to Fido2Save`() {
val viewModel = createViewModel()
val fido2CredentialRequest = Fido2CredentialRequest(
userId = DEFAULT_USER_STATE.activeUserId,
requestJson = """{"mockRequestJson":1}""",
packageName = "com.x8bit.bitwarden",
signingInfo = SigningInfo(),
origin = "mockOrigin",
)
val mockIntent = mockk<Intent> {
every { getFido2CredentialRequestOrNull() } returns fido2CredentialRequest
every { getPasswordlessRequestDataIntentOrNull() } returns null
every { getAutofillSelectionDataOrNull() } returns null
every { getAutofillSaveItemOrNull() } returns null
every { isMyVaultShortcut } returns false
every { isPasswordGeneratorShortcut } returns false
}
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
coEvery {
fido2CredentialManager.validateOrigin(any())
} returns Fido2ValidateOriginResult.Success
viewModel.trySendAction(
MainAction.ReceiveFirstIntent(
intent = mockIntent,
),
)
assertEquals(
SpecialCircumstance.Fido2Save(
fido2CredentialRequest = fido2CredentialRequest,
),
specialCircumstanceManager.specialCircumstance,
)
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveFirstIntent with fido2 request data should switch users if active user is not selected`() {
mutableUserStateFlow.value = DEFAULT_USER_STATE
val viewModel = createViewModel()
val fido2CredentialRequest = Fido2CredentialRequest(
userId = "selectedUserId",
requestJson = """{"mockRequestJson":1}""",
packageName = "com.x8bit.bitwarden",
signingInfo = SigningInfo(),
origin = "mockOrigin",
)
val mockIntent = mockk<Intent> {
every { getFido2CredentialRequestOrNull() } returns fido2CredentialRequest
every { getPasswordlessRequestDataIntentOrNull() } returns null
every { getAutofillSelectionDataOrNull() } returns null
every { getAutofillSaveItemOrNull() } returns null
every { isMyVaultShortcut } returns false
every { isPasswordGeneratorShortcut } returns false
}
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
coEvery {
fido2CredentialManager.validateOrigin(any())
} returns Fido2ValidateOriginResult.Success
viewModel.trySendAction(
MainAction.ReceiveFirstIntent(
intent = mockIntent,
),
)
verify(exactly = 1) { authRepository.switchAccount(fido2CredentialRequest.userId) }
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveFirstIntent with fido2 request data should not switch users if active user is selected`() {
val viewModel = createViewModel()
val fido2CredentialRequest = Fido2CredentialRequest(
userId = DEFAULT_USER_STATE.activeUserId,
requestJson = """{"mockRequestJson":1}""",
packageName = "com.x8bit.bitwarden",
signingInfo = SigningInfo(),
origin = "mockOrigin",
)
val mockIntent = mockk<Intent> {
every { getFido2CredentialRequestOrNull() } returns fido2CredentialRequest
every { getPasswordlessRequestDataIntentOrNull() } returns null
every { getAutofillSelectionDataOrNull() } returns null
every { getAutofillSaveItemOrNull() } returns null
every { isMyVaultShortcut } returns false
every { isPasswordGeneratorShortcut } returns false
}
every { intentManager.getShareDataFromIntent(mockIntent) } returns null
coEvery {
fido2CredentialManager.validateOrigin(any())
} returns Fido2ValidateOriginResult.Success
viewModel.trySendAction(
MainAction.ReceiveFirstIntent(
intent = mockIntent,
),
)
verify(exactly = 0) { authRepository.switchAccount(fido2CredentialRequest.userId) }
}
@Suppress("MaxLineLength")
@Test
fun `on ReceiveNewIntent with share data should set the special circumstance to ShareNewSend`() {
@ -532,10 +649,10 @@ class MainViewModelTest : BaseViewModelTest() {
autofillSelectionManager = autofillSelectionManager,
specialCircumstanceManager = specialCircumstanceManager,
garbageCollectionManager = garbageCollectionManager,
authRepository = authRepository,
intentManager = intentManager,
settingsRepository = settingsRepository,
vaultRepository = vaultRepository,
intentManager = intentManager,
authRepository = authRepository,
savedStateHandle = savedStateHandle.apply {
set(SPECIAL_CIRCUMSTANCE_KEY, initialSpecialCircumstance)
},
@ -543,3 +660,23 @@ class MainViewModelTest : BaseViewModelTest() {
}
private const val SPECIAL_CIRCUMSTANCE_KEY: String = "special-circumstance"
private val DEFAULT_ACCOUNT = UserState.Account(
userId = "activeUserId",
name = "Active User",
email = "active@bitwarden.com",
environment = Environment.Us,
avatarColorHex = "#aa00aa",
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = null,
)
private val DEFAULT_USER_STATE = UserState(
activeUserId = "activeUserId",
accounts = listOf(DEFAULT_ACCOUNT),
)

View file

@ -0,0 +1,67 @@
package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.api.DigitalAssetLinkApi
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import retrofit2.create
class DigitalAssetLinkServiceTest : BaseServiceTest() {
private val digitalAssetLinkApi: DigitalAssetLinkApi = retrofit.create()
private val digitalAssetLinkService: DigitalAssetLinkService = DigitalAssetLinkServiceImpl(
digitalAssetLinkApi = digitalAssetLinkApi,
)
@Test
fun `getDigitalAssetLinkForRp should return the correct response`() = runTest {
server.enqueue(MockResponse().setBody(GET_DIGITAL_ASSET_LINK_SUCCESS_JSON))
val result = digitalAssetLinkService.getDigitalAssetLinkForRp(
scheme = url.scheme,
relyingParty = url.host,
)
assertEquals(
createDigitalAssetLinkResponse(),
result.getOrThrow(),
)
}
}
@Suppress("MaxLineLength")
private fun createDigitalAssetLinkResponse() = listOf(
DigitalAssetLinkResponseJson(
relation = listOf(
"delegate_permission/common.get_login_creds",
"delegate_permission/common.handle_all_urls",
),
target = DigitalAssetLinkResponseJson.Target(
namespace = "android_app",
packageName = "com.mock.package",
sha256CertFingerprints = listOf(
"00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13:14:15:16:17:18:19:1A:1B:1C:1D:1E:1F",
),
),
),
)
private const val GET_DIGITAL_ASSET_LINK_SUCCESS_JSON = """
[
{
"relation": [
"delegate_permission/common.get_login_creds",
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "com.mock.package",
"sha256_cert_fingerprints": [
"00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13:14:15:16:17:18:19:1A:1B:1C:1D:1E:1F"
]
}
}
]
"""

View file

@ -0,0 +1,331 @@
package com.x8bit.bitwarden.data.autofill.fido2.manager
import android.content.pm.Signature
import android.content.pm.SigningInfo
import androidx.credentials.provider.CallingAppInfo
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.PublicKeyCredentialCreationOptions
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.security.MessageDigest
class Fido2CredentialManagerTest {
private lateinit var fido2CredentialManager: Fido2CredentialManager
private val assetManager: AssetManager = mockk {
coEvery { readAsset(any()) } returns DEFAULT_ALLOW_LIST.asSuccess()
}
private val digitalAssetLinkService = mockk<DigitalAssetLinkService> {
coEvery {
getDigitalAssetLinkForRp(relyingParty = any())
} returns DEFAULT_STATEMENT_LIST.asSuccess()
}
private val mockCreateOptions = mockk<PublicKeyCredentialCreationOptions> {
every {
relyingParty
} returns PublicKeyCredentialCreationOptions.PublicKeyCredentialRpEntity(
name = "mockRpName",
id = "www.bitwarden.com",
)
}
private val json = mockk<Json> {
every {
decodeFromString<PublicKeyCredentialCreationOptions>(any())
} returns mockCreateOptions
}
private val mockPrivilegedCallingAppInfo = mockk<CallingAppInfo> {
every { packageName } returns "com.x8bit.bitwarden"
every { isOriginPopulated() } returns true
every { getOrigin(any()) } returns "com.x8bit.bitwarden"
}
private val mockPrivilegedAppRequest = mockk<Fido2CredentialRequest> {
every { callingAppInfo } returns mockPrivilegedCallingAppInfo
}
private val mockSigningInfo = mockk<SigningInfo> {
every { apkContentsSigners } returns arrayOf(Signature("0987654321ABCDEF"))
}
private val mockUnprivilegedCallingAppInfo = CallingAppInfo(
packageName = "com.x8bit.bitwarden",
signingInfo = mockSigningInfo,
origin = null,
)
private val mockUnprivilegedAppRequest = mockk<Fido2CredentialRequest> {
every { callingAppInfo } returns mockUnprivilegedCallingAppInfo
every { requestJson } returns "{}"
}
private val mockMessageDigest = mockk<MessageDigest> {
every { digest(any()) } returns "0987654321ABCDEF".toByteArray()
}
@BeforeEach
fun setUp() {
mockkStatic(MessageDigest::class)
every { MessageDigest.getInstance(any()) } returns mockMessageDigest
fido2CredentialManager = Fido2CredentialManagerImpl(
assetManager = assetManager,
digitalAssetLinkService = digitalAssetLinkService,
json = json,
)
}
@AfterEach
fun tearDown() {
unmockkStatic(MessageDigest::class)
}
@Test
fun `validateOrigin should load allow list when origin is populated`() =
runTest {
fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest)
coVerify(exactly = 1) {
assetManager.readAsset(
fileName = "fido2_privileged_allow_list.json",
)
}
}
@Test
fun `validateOrigin should return Success when privileged app is allowed`() =
runTest {
assertEquals(
Fido2ValidateOriginResult.Success,
fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest),
)
}
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should return PrivilegedAppSignatureNotFound when privileged app signature is not found in allow list`() =
runTest {
every { mockPrivilegedCallingAppInfo.getOrigin(any()) } throws IllegalStateException()
assertEquals(
Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound,
fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest),
)
}
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should return PrivilegedAppNotAllowed when privileged app package name is not found in allow list`() =
runTest {
coEvery { assetManager.readAsset(any()) } returns MISSING_PACKAGE_ALLOW_LIST.asSuccess()
assertEquals(
Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed,
fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest),
)
}
@Test
fun `validateOrigin should return error when allow list is unreadable`() = runTest {
coEvery { assetManager.readAsset(any()) } returns IllegalStateException().asFailure()
assertEquals(
Fido2ValidateOriginResult.Error.Unknown,
fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest),
)
}
@Test
fun `validateOrigin should return PasskeyNotSupportedForApp when allow list is invalid`() =
runTest {
every {
mockPrivilegedCallingAppInfo.getOrigin(any())
} throws IllegalArgumentException()
assertEquals(
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp,
fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest),
)
}
@Test
fun `validateOrigin should return success when asset links contains matching statement`() =
runTest {
assertEquals(
Fido2ValidateOriginResult.Success,
fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest),
)
}
@Test
fun `validateOrigin should return error when request cannot be decoded`() = runTest {
every {
json.decodeFromString<PublicKeyCredentialCreationOptions>(any())
} throws SerializationException()
assertEquals(
Fido2ValidateOriginResult.Error.AssetLinkNotFound,
fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest),
)
}
@Test
fun `validateOrigin should return error when request cannot be cast to object type`() =
runTest {
every {
json.decodeFromString<PublicKeyCredentialCreationOptions>(any())
} throws IllegalArgumentException()
assertEquals(
Fido2ValidateOriginResult.Error.AssetLinkNotFound,
fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest),
)
}
@Test
fun `validateOrigin should return error when asset links are unavailable`() = runTest {
coEvery {
digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = any())
} returns Throwable().asFailure()
assertEquals(
fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest),
Fido2ValidateOriginResult.Error.AssetLinkNotFound,
)
}
@Test
fun `validateOrigin should return error when asset links does not contain package name`() =
runTest {
every { mockUnprivilegedAppRequest.callingAppInfo } returns CallingAppInfo(
packageName = "its.a.trap",
signingInfo = mockSigningInfo,
origin = null,
)
assertEquals(
Fido2ValidateOriginResult.Error.ApplicationNotFound,
fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest),
)
}
@Suppress("MaxLineLength")
@Test
fun `validateOrigin should return error when asset links does not contain android app namespace`() =
runTest {
coEvery {
digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = any())
} returns listOf(
DEFAULT_STATEMENT.copy(
target = DEFAULT_STATEMENT.target.copy(
namespace = "its_a_trap",
),
),
)
.asSuccess()
assertEquals(
Fido2ValidateOriginResult.Error.ApplicationNotFound,
fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest),
)
}
@Test
fun `validateOrigin should return error when asset links certificate hash no match`() =
runTest {
every {
mockMessageDigest.digest(any())
} returns "ITSATRAP".toByteArray()
assertEquals(
Fido2ValidateOriginResult.Error.ApplicationNotVerified,
fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest),
)
}
@Test
fun `createCredentialForCipher should return error while not implemented`() {
val result = fido2CredentialManager.createCredentialForCipher(
credentialRequest = mockk(),
cipherView = mockk(),
)
assertTrue(
result is Fido2CreateCredentialResult.Error,
)
}
}
@Suppress("MaxLineLength")
private const val DEFAULT_CERT_FINGERPRINT =
"30:39:38:37:36:35:34:33:32:31:41:42:43:44:45:46"
private val DEFAULT_STATEMENT = DigitalAssetLinkResponseJson(
relation = listOf(
"delegate_permission/common.get_login_creds",
"delegate_permission/common.handle_all_urls",
),
target = DigitalAssetLinkResponseJson.Target(
namespace = "android_app",
packageName = "com.x8bit.bitwarden",
sha256CertFingerprints = listOf(
DEFAULT_CERT_FINGERPRINT,
),
),
)
private val DEFAULT_STATEMENT_LIST = listOf(DEFAULT_STATEMENT)
private const val DEFAULT_ALLOW_LIST = """
{
"apps": [
{
"type": "android",
"info": {
"package_name": "com.x8bit.bitwarden",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83"
},
{
"build": "userdebug",
"cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"
}
]
}
}
]
}
"""
private const val MISSING_PACKAGE_ALLOW_LIST = """
{
"apps": [
{
"type": "android",
"info": {
"package_name": "com.android.chrome",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83"
},
{
"build": "userdebug",
"cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"
}
]
}
}
]
}
"""

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.base
import com.x8bit.bitwarden.data.platform.datasource.network.core.ResultCallAdapterFactory
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
@ -17,10 +18,12 @@ abstract class BaseServiceTest {
protected val server = MockWebServer().apply { start() }
protected val url: HttpUrl = server.url("/")
protected val urlPrefix: String get() = "http://${server.hostName}:${server.port}"
protected val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(server.url("/").toString())
.baseUrl(url.toString())
.addCallAdapterFactory(ResultCallAdapterFactory())
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()

View file

@ -0,0 +1,185 @@
package com.x8bit.bitwarden.data.platform.util
import android.content.pm.Signature
import android.content.pm.SigningInfo
import android.util.Base64
import androidx.credentials.provider.CallingAppInfo
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.security.MessageDigest
class CallingAppInfoExtensionsTest {
@BeforeEach
fun setUp() {
mockkStatic(MessageDigest::class)
mockkStatic(Base64::class)
}
@AfterEach
fun tearDown() {
unmockkStatic(MessageDigest::class)
unmockkStatic(Base64::class)
}
@Test
fun `getFido2RpOrNull should return null when origin is populated with invalid URI`() {
val mockCallingAppInfo = mockk<CallingAppInfo> {
every { isOriginPopulated() } returns true
every { origin } returns "invalidUri9685%^$^&(*"
}
assertNull(mockCallingAppInfo.getFido2RpOrNull())
}
@Test
fun `getFido2RpOrNull should return origin when origin is populated`() {
val mockCallingAppInfo = mockk<CallingAppInfo> {
every { isOriginPopulated() } returns true
every { origin } returns "mockUri"
}
assertEquals("mockUri", mockCallingAppInfo.getFido2RpOrNull())
}
@Test
fun `getFido2RpOrNull should return null when origin is null`() {
val mockCallingAppInfo = mockk<CallingAppInfo> {
every { isOriginPopulated() } returns true
every { origin } returns null
}
assertNull(mockCallingAppInfo.getFido2RpOrNull())
}
@Test
fun `getFido2RpOrNull should return package name when origin is not populated`() {
val mockCallingAppInfo = mockk<CallingAppInfo> {
every { isOriginPopulated() } returns false
every { packageName } returns "mockPackageName"
}
assertEquals("mockPackageName", mockCallingAppInfo.getFido2RpOrNull())
}
@Test
fun `getCallingAppApkFingerprint should return key hash`() {
val mockMessageDigest = mockk<MessageDigest> {
every { digest(any()) } returns DEFAULT_SIGNATURE.toByteArray()
}
every { MessageDigest.getInstance(any()) } returns mockMessageDigest
every { Base64.encodeToString(any(), any()) } returns DEFAULT_SIGNATURE
val mockSigningInfo = mockk<SigningInfo> {
every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_SIGNATURE))
}
val appInfo = mockk<CallingAppInfo> {
every { packageName } returns "packageName"
every { signingInfo } returns mockSigningInfo
every { origin } returns null
}
assertEquals(
DEFAULT_SIGNATURE_HASH,
appInfo.getCallingAppApkFingerprint(),
)
}
@Test
fun `validatePrivilegedApp should return Success when privileged app is allowed`() {
val mockAppInfo = mockk<CallingAppInfo> {
every { getOrigin(any()) } returns "origin"
every { packageName } returns "com.x8bit.bitwarden"
}
assertEquals(
Fido2ValidateOriginResult.Success,
mockAppInfo.validatePrivilegedApp(
allowList = DEFAULT_ALLOW_LIST,
),
)
}
@Suppress("MaxLineLength")
@Test
fun `validatePrivilegedApp should return PasskeyNotSupportedForApp when allow list is invalid`() {
val appInfo = mockk<CallingAppInfo> {
every { packageName } returns "com.x8bit.bitwarden"
every { getOrigin(any()) } throws IllegalArgumentException()
}
assertEquals(
Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp,
appInfo.validatePrivilegedApp(
allowList = INVALID_ALLOW_LIST,
),
)
}
@Suppress("MaxLineLength")
@Test
fun `validatePrivilegedApp should return PrivilegedAppNotAllowed when calling app is not present in allow list`() {
val appInfo = mockk<CallingAppInfo> {
every { packageName } returns "packageName"
every { origin } returns "origin"
}
assertEquals(
Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed,
appInfo.validatePrivilegedApp(
allowList = DEFAULT_ALLOW_LIST,
),
)
}
}
private const val DEFAULT_SIGNATURE = "0987654321ABCDEF"
private const val DEFAULT_SIGNATURE_HASH = "30:39:38:37:36:35:34:33:32:31:41:42:43:44:45:46"
private const val DEFAULT_ALLOW_LIST = """
{
"apps": [
{
"type": "android",
"info": {
"package_name": "com.x8bit.bitwarden",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83"
},
{
"build": "userdebug",
"cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"
}
]
}
}
]
}
"""
private const val INVALID_ALLOW_LIST = """
"apps": [
{
"type": "android",
"info": {
"package_name": "com.x8bit.bitwarden",
"signatures": [
{
"cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83"
},
{
"cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00"
}
]
}
}
]
}
"""

View file

@ -1,7 +1,9 @@
package com.x8bit.bitwarden.ui.platform.feature.rootnav
import android.content.pm.SigningInfo
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
@ -404,6 +406,50 @@ class RootNavViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `when the active user has an unlocked vault but there is a Fido2Save special circumstance the nav state should be VaultUnlockedForFido2Save`() {
val fido2CredentialRequest = Fido2CredentialRequest(
userId = "activeUserId",
requestJson = "{}",
packageName = "com.x8bit.bitwarden",
signingInfo = SigningInfo(),
origin = "mockOrigin",
)
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.Fido2Save(fido2CredentialRequest)
mutableUserStateFlow.tryEmit(
UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "name",
email = "email",
avatarColorHex = "avatarHexColor",
environment = Environment.Us,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = null,
),
),
),
)
val viewModel = createViewModel()
assertEquals(
RootNavState.VaultUnlockedForFido2Save(
activeUserId = "activeUserId",
fido2CredentialRequest = fido2CredentialRequest,
),
viewModel.stateFlow.value,
)
}
@Test
fun `when the active user has a locked vault the nav state should be VaultLocked`() {
mutableUserStateFlow.tryEmit(

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.core.net.toUri
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
@ -80,6 +81,9 @@ class VaultItemListingScreenTest : BaseComposeTest() {
every { shareText(any()) } just runs
every { launchUri(any()) } just runs
}
private val fido2CompletionManager: Fido2CompletionManager = mockk {
every { completeFido2Create(any()) } just runs
}
private val mutableEventFlow = bufferedMutableSharedFlow<VaultItemListingEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<VaultItemListingViewModel>(relaxed = true) {
@ -95,6 +99,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
VaultItemListingScreen(
viewModel = viewModel,
intentManager = intentManager,
fido2CompletionManager = fido2CompletionManager,
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToVaultItem = { onNavigateToVaultItemId = it },
onNavigateToVaultAddItemScreen = { onNavigateToVaultAddItemScreenCalled = true },
@ -1555,6 +1560,7 @@ private fun createDisplayItem(number: Int): VaultItemListingState.DisplayItem =
),
optionsTestTag = "SendOptionsButton",
isAutofill = false,
isFido2Creation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = null,
)
@ -1576,6 +1582,7 @@ private fun createCipherDisplayItem(number: Int): VaultItemListingState.DisplayI
),
optionsTestTag = "CipherOptionsButton",
isAutofill = false,
isFido2Creation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = null,
)

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting
import android.content.pm.SigningInfo
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
@ -9,6 +10,9 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManagerImpl
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
@ -45,7 +49,9 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import io.mockk.Ordering
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@ -120,6 +126,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
every { getActivePolicies(type = PolicyTypeJson.DISABLE_SEND) } returns emptyList()
every { getActivePoliciesFlow(type = PolicyTypeJson.DISABLE_SEND) } returns emptyFlow()
}
private val fido2CredentialManager: Fido2CredentialManager = mockk {
coEvery { validateOrigin(any()) } returns Fido2ValidateOriginResult.Success
}
private val initialState = createVaultItemListingState()
private val initialSavedStateHandle = createSavedStateHandleWithVaultItemListingType(
@ -136,6 +145,35 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `initial dialog state should be correct when fido2Request is present`() = runTest {
val fido2CredentialRequest = Fido2CredentialRequest(
"mockUserId",
"{}",
"com.x8bit.bitwarden",
SigningInfo(),
origin = null,
)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CredentialRequest = fido2CredentialRequest,
)
val viewModel = createVaultItemListingViewModel()
viewModel.stateFlow.test {
assertEquals(
initialState.copy(
fido2CredentialRequest = fido2CredentialRequest,
dialogState = VaultItemListingState.DialogState.Loading(
message = R.string.loading.asText(),
),
shouldFinishOnComplete = true,
),
awaitItem(),
)
}
}
@Test
fun `on LockAccountClick should call lockVault for the given account`() {
val accountUserId = "userId"
@ -933,6 +971,65 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `vaultDataStateFlow Loaded with items and fido2 filtering should update ViewState to Content with filtered data`() =
runTest {
setupMockUri()
coEvery {
fido2CredentialManager.validateOrigin(any())
} returns Fido2ValidateOriginResult.Success
val cipherView1 = createMockCipherView(number = 1)
val cipherView2 = createMockCipherView(number = 2)
mockFilteredCiphers = listOf(cipherView1)
val fido2CredentialRequest = Fido2CredentialRequest(
userId = "activeUserId",
requestJson = "{}",
packageName = "com.x8bit.bitwarden",
signingInfo = SigningInfo(),
origin = "mockOrigin",
)
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.Fido2Save(
fido2CredentialRequest = fido2CredentialRequest,
)
val dataState = DataState.Loaded(
data = VaultData(
cipherViewList = listOf(cipherView1, cipherView2),
folderViewList = listOf(createMockFolderView(number = 1)),
collectionViewList = listOf(createMockCollectionView(number = 1)),
sendViewList = listOf(createMockSendView(number = 1)),
),
)
val viewModel = createVaultItemListingViewModel()
mutableVaultDataStateFlow.value = dataState
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Content(
displayCollectionList = emptyList(),
displayItemList = listOf(
createMockDisplayItemForCipher(number = 1)
.copy(isFido2Creation = true),
),
displayFolderList = emptyList(),
),
)
.copy(
fido2CredentialRequest = fido2CredentialRequest,
shouldFinishOnComplete = true,
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow Loaded with empty items should update ViewState to NoItems`() =
runTest {
@ -1376,6 +1473,242 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `Fido2Request should be evaluated before observing vault data`() {
val fido2CredentialRequest = Fido2CredentialRequest(
"mockUserId",
"{}",
"com.x8bit.bitwarden",
SigningInfo(),
origin = "com.x8bit.bitwarden",
)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CredentialRequest,
)
createVaultItemListingViewModel()
coVerify(ordering = Ordering.ORDERED) {
fido2CredentialManager.validateOrigin(fido2CredentialRequest)
vaultRepository.vaultDataStateFlow
}
}
@Test
fun `Fido2ValidateOriginResult should update dialog state on Unknown error`() = runTest {
val fido2CredentialRequest = Fido2CredentialRequest(
userId = "mockUserId",
requestJson = "{}",
packageName = "com.x8bit.bitwarden",
signingInfo = SigningInfo(),
origin = null,
)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CredentialRequest = fido2CredentialRequest,
)
coEvery {
fido2CredentialManager.validateOrigin(fido2CredentialRequest)
} returns Fido2ValidateOriginResult.Error.Unknown
val viewModel = createVaultItemListingViewModel()
assertEquals(
VaultItemListingState.DialogState.Fido2CreationFail(
R.string.an_error_has_occurred.asText(),
R.string.generic_error_message.asText(),
),
viewModel.stateFlow.value.dialogState,
)
}
@Suppress("MaxLineLength")
@Test
fun `Fido2ValidateOriginResult should update dialog state on PrivilegedAppNotAllowed error`() =
runTest {
val fido2CredentialRequest = Fido2CredentialRequest(
userId = "mockUserId",
requestJson = "{}",
packageName = "com.x8bit.bitwarden",
signingInfo = SigningInfo(),
origin = null,
)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CredentialRequest = fido2CredentialRequest,
)
coEvery {
fido2CredentialManager.validateOrigin(fido2CredentialRequest)
} returns Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed
val viewModel = createVaultItemListingViewModel()
assertEquals(
VaultItemListingState.DialogState.Fido2CreationFail(
R.string.an_error_has_occurred.asText(),
R.string.passkey_operation_failed_because_browser_is_not_privileged.asText(),
),
viewModel.stateFlow.value.dialogState,
)
}
@Suppress("MaxLineLength")
@Test
fun `Fido2ValidateOriginResult should update dialog state on PrivilegedAppSignatureNotFound error`() =
runTest {
val fido2CredentialRequest = Fido2CredentialRequest(
userId = "mockUserId",
requestJson = "{}",
packageName = "com.x8bit.bitwarden",
signingInfo = SigningInfo(),
origin = null,
)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CredentialRequest = fido2CredentialRequest,
)
coEvery {
fido2CredentialManager.validateOrigin(fido2CredentialRequest)
} returns Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound
val viewModel = createVaultItemListingViewModel()
assertEquals(
VaultItemListingState.DialogState.Fido2CreationFail(
R.string.an_error_has_occurred.asText(),
R.string.passkey_operation_failed_because_browser_signature_does_not_match.asText(),
),
viewModel.stateFlow.value.dialogState,
)
}
@Suppress("MaxLineLength")
@Test
fun `Fido2ValidateOriginResult should update dialog state on PasskeyNotSupportedForApp error`() =
runTest {
val fido2CredentialRequest = Fido2CredentialRequest(
userId = "mockUserId",
requestJson = "{}",
packageName = "com.x8bit.bitwarden",
signingInfo = SigningInfo(),
origin = null,
)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CredentialRequest = fido2CredentialRequest,
)
coEvery {
fido2CredentialManager.validateOrigin(fido2CredentialRequest)
} returns Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp
val viewModel = createVaultItemListingViewModel()
assertEquals(
VaultItemListingState.DialogState.Fido2CreationFail(
R.string.an_error_has_occurred.asText(),
R.string.passkeys_not_supported_for_this_app.asText(),
),
viewModel.stateFlow.value.dialogState,
)
}
@Suppress("MaxLineLength")
@Test
fun `Fido2ValidateOriginResult should update dialog state on ApplicationNotFound error`() =
runTest {
val fido2CredentialRequest = Fido2CredentialRequest(
userId = "mockUserId",
requestJson = "{}",
packageName = "com.x8bit.bitwarden",
signingInfo = SigningInfo(),
origin = null,
)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CredentialRequest = fido2CredentialRequest,
)
coEvery {
fido2CredentialManager.validateOrigin(fido2CredentialRequest)
} returns Fido2ValidateOriginResult.Error.ApplicationNotFound
val viewModel = createVaultItemListingViewModel()
assertEquals(
VaultItemListingState.DialogState.Fido2CreationFail(
R.string.an_error_has_occurred.asText(),
R.string.passkey_operation_failed_because_app_not_found_in_asset_links.asText(),
),
viewModel.stateFlow.value.dialogState,
)
}
@Suppress("MaxLineLength")
@Test
fun `Fido2ValidateOriginResult should update dialog state on AssetLinkNotFound error`() =
runTest {
val fido2CredentialRequest = Fido2CredentialRequest(
userId = "mockUserId",
requestJson = "{}",
packageName = "com.x8bit.bitwarden",
signingInfo = SigningInfo(),
origin = null,
)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CredentialRequest = fido2CredentialRequest,
)
coEvery {
fido2CredentialManager.validateOrigin(fido2CredentialRequest)
} returns Fido2ValidateOriginResult.Error.AssetLinkNotFound
val viewModel = createVaultItemListingViewModel()
assertEquals(
VaultItemListingState.DialogState.Fido2CreationFail(
R.string.an_error_has_occurred.asText(),
R.string.passkey_operation_failed_because_of_missing_asset_links.asText(),
),
viewModel.stateFlow.value.dialogState,
)
}
@Suppress("MaxLineLength")
@Test
fun `Fido2ValidateOriginResult should update dialog state on ApplicationNotVerified error`() =
runTest {
val fido2CredentialRequest = Fido2CredentialRequest(
userId = "mockUserId",
requestJson = "{}",
packageName = "com.x8bit.bitwarden",
signingInfo = SigningInfo(),
origin = null,
)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save(
fido2CredentialRequest = fido2CredentialRequest,
)
coEvery {
fido2CredentialManager.validateOrigin(fido2CredentialRequest)
} returns Fido2ValidateOriginResult.Error.ApplicationNotVerified
val viewModel = createVaultItemListingViewModel()
assertEquals(
VaultItemListingState.DialogState.Fido2CreationFail(
R.string.an_error_has_occurred.asText(),
R.string.passkey_operation_failed_because_app_could_not_be_verified.asText(),
),
viewModel.stateFlow.value.dialogState,
)
}
@Suppress("CyclomaticComplexMethod")
private fun createSavedStateHandleWithVaultItemListingType(
vaultItemListingType: VaultItemListingType,
@ -1433,6 +1766,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
cipherMatchingManager = cipherMatchingManager,
specialCircumstanceManager = specialCircumstanceManager,
policyManager = policyManager,
fido2CredentialManager = fido2CredentialManager,
)
@Suppress("MaxLineLength")

View file

@ -393,6 +393,7 @@ class VaultItemListingDataExtensionsTest {
isIconLoadingDisabled = false,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
autofillSelectionData = null,
fido2CreationData = null,
hasMasterPassword = true,
)
@ -467,6 +468,7 @@ class VaultItemListingDataExtensionsTest {
type = AutofillSelectionData.Type.LOGIN,
uri = null,
),
fido2CreationData = null,
hasMasterPassword = true,
)
@ -518,6 +520,7 @@ class VaultItemListingDataExtensionsTest {
isIconLoadingDisabled = false,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
autofillSelectionData = null,
fido2CreationData = null,
hasMasterPassword = true,
),
)
@ -536,6 +539,7 @@ class VaultItemListingDataExtensionsTest {
isIconLoadingDisabled = false,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
autofillSelectionData = null,
fido2CreationData = null,
hasMasterPassword = true,
),
)
@ -552,6 +556,7 @@ class VaultItemListingDataExtensionsTest {
isIconLoadingDisabled = false,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
autofillSelectionData = null,
fido2CreationData = null,
hasMasterPassword = true,
),
)
@ -571,6 +576,7 @@ class VaultItemListingDataExtensionsTest {
type = AutofillSelectionData.Type.LOGIN,
uri = "https://www.test.com",
),
fido2CreationData = null,
hasMasterPassword = true,
),
)
@ -710,6 +716,7 @@ class VaultItemListingDataExtensionsTest {
autofillSelectionData = null,
itemListingType = VaultItemListingState.ItemListingType.Vault.Folder("1"),
vaultFilterType = VaultFilterType.AllVaults,
fido2CreationData = null,
hasMasterPassword = true,
)
@ -750,6 +757,7 @@ class VaultItemListingDataExtensionsTest {
autofillSelectionData = null,
itemListingType = VaultItemListingState.ItemListingType.Vault.Collection("mockId-1"),
vaultFilterType = VaultFilterType.AllVaults,
fido2CreationData = null,
hasMasterPassword = true,
)

View file

@ -63,6 +63,7 @@ fun createMockDisplayItemForCipher(
),
optionsTestTag = "CipherOptionsButton",
isAutofill = false,
isFido2Creation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = "LoginCipherIcon",
)
@ -100,6 +101,7 @@ fun createMockDisplayItemForCipher(
),
optionsTestTag = "CipherOptionsButton",
isAutofill = false,
isFido2Creation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = "SecureNoteCipherIcon",
)
@ -142,6 +144,7 @@ fun createMockDisplayItemForCipher(
),
optionsTestTag = "CipherOptionsButton",
isAutofill = false,
isFido2Creation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = "CardCipherIcon",
)
@ -176,6 +179,7 @@ fun createMockDisplayItemForCipher(
),
optionsTestTag = "CipherOptionsButton",
isAutofill = false,
isFido2Creation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = "IdentityCipherIcon",
)
@ -224,6 +228,7 @@ fun createMockDisplayItemForSend(
),
optionsTestTag = "SendOptionsButton",
isAutofill = false,
isFido2Creation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = null,
)
@ -262,6 +267,7 @@ fun createMockDisplayItemForSend(
),
optionsTestTag = "SendOptionsButton",
isAutofill = false,
isFido2Creation = false,
shouldShowMasterPasswordReprompt = false,
iconTestTag = null,
)