|
1 | 1 | package com.x8bit.bitwarden.data.autofill.fido2.manager
|
2 | 2 |
|
| 3 | +import android.content.Context |
| 4 | +import android.os.Build |
| 5 | +import androidx.annotation.RequiresApi |
| 6 | +import androidx.biometric.BiometricManager |
| 7 | +import androidx.biometric.BiometricPrompt |
| 8 | +import androidx.core.graphics.drawable.IconCompat |
3 | 9 | import androidx.credentials.CreatePublicKeyCredentialRequest
|
4 | 10 | import androidx.credentials.GetPublicKeyCredentialOption
|
| 11 | +import androidx.credentials.exceptions.GetCredentialUnknownException |
| 12 | +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption |
| 13 | +import androidx.credentials.provider.BiometricPromptData |
5 | 14 | import androidx.credentials.provider.CallingAppInfo
|
| 15 | +import androidx.credentials.provider.CredentialEntry |
6 | 16 | import androidx.credentials.provider.ProviderGetCredentialRequest
|
| 17 | +import androidx.credentials.provider.PublicKeyCredentialEntry |
| 18 | +import com.bitwarden.core.annotation.OmitFromCoverage |
| 19 | +import com.bitwarden.core.data.repository.model.DataState |
| 20 | +import com.bitwarden.core.data.repository.util.takeUntilLoaded |
| 21 | +import com.bitwarden.core.data.util.asFailure |
| 22 | +import com.bitwarden.core.data.util.asSuccess |
| 23 | +import com.bitwarden.data.manager.DispatcherManager |
| 24 | +import com.bitwarden.data.repository.util.baseIconUrl |
7 | 25 | import com.bitwarden.fido.ClientData
|
| 26 | +import com.bitwarden.fido.Fido2CredentialAutofillView |
8 | 27 | import com.bitwarden.fido.Origin
|
9 | 28 | import com.bitwarden.fido.UnverifiedAssetLink
|
10 | 29 | import com.bitwarden.sdk.Fido2CredentialStore
|
11 | 30 | import com.bitwarden.vault.CipherView
|
| 31 | +import com.bumptech.glide.Glide |
| 32 | +import com.x8bit.bitwarden.R |
12 | 33 | import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
|
13 | 34 | import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
|
14 | 35 | import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
|
15 | 36 | import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
|
16 | 37 | import com.x8bit.bitwarden.data.autofill.fido2.model.UserVerificationRequirement
|
| 38 | +import com.x8bit.bitwarden.data.autofill.fido2.processor.GET_PASSKEY_INTENT |
| 39 | +import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials |
| 40 | +import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager |
| 41 | +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager |
| 42 | +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey |
| 43 | +import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository |
17 | 44 | import com.x8bit.bitwarden.data.platform.util.getAppOrigin
|
18 | 45 | import com.x8bit.bitwarden.data.platform.util.getAppSigningSignatureFingerprint
|
19 | 46 | import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString
|
| 47 | +import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow |
20 | 48 | import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
21 | 49 | import com.x8bit.bitwarden.data.vault.datasource.sdk.model.AuthenticateFido2CredentialRequest
|
22 | 50 | import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest
|
23 | 51 | import com.x8bit.bitwarden.data.vault.datasource.sdk.util.toAndroidAttestationResponse
|
24 | 52 | import com.x8bit.bitwarden.data.vault.datasource.sdk.util.toAndroidFido2PublicKeyCredential
|
| 53 | +import com.x8bit.bitwarden.data.vault.repository.VaultRepository |
| 54 | +import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAutofillViewResult |
25 | 55 | import com.x8bit.bitwarden.ui.platform.base.util.prefixHttpsIfNecessaryOrNull
|
| 56 | +import com.x8bit.bitwarden.ui.platform.components.model.IconData |
| 57 | +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager |
| 58 | +import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData |
| 59 | +import kotlinx.coroutines.CoroutineScope |
| 60 | +import kotlinx.coroutines.flow.fold |
| 61 | +import kotlinx.coroutines.withContext |
26 | 62 | import kotlinx.serialization.SerializationException
|
27 | 63 | import kotlinx.serialization.json.Json
|
28 | 64 | import timber.log.Timber
|
| 65 | +import java.util.concurrent.ExecutionException |
| 66 | +import javax.crypto.Cipher |
| 67 | +import kotlin.random.Random |
29 | 68 |
|
30 | 69 | /**
|
31 | 70 | * Primary implementation of [Fido2CredentialManager].
|
32 | 71 | */
|
33 |
| -@Suppress("TooManyFunctions") |
| 72 | +@Suppress("TooManyFunctions", "LongParameterList") |
34 | 73 | class Fido2CredentialManagerImpl(
|
| 74 | + private val context: Context, |
35 | 75 | private val vaultSdkSource: VaultSdkSource,
|
36 | 76 | private val fido2CredentialStore: Fido2CredentialStore,
|
| 77 | + private val intentManager: IntentManager, |
| 78 | + private val featureFlagManager: FeatureFlagManager, |
| 79 | + private val biometricsEncryptionManager: BiometricsEncryptionManager, |
37 | 80 | private val json: Json,
|
| 81 | + private val vaultRepository: VaultRepository, |
| 82 | + private val environmentRepository: EnvironmentRepository, |
| 83 | + dispatcherManager: DispatcherManager, |
38 | 84 | ) : Fido2CredentialManager,
|
39 | 85 | Fido2CredentialStore by fido2CredentialStore {
|
40 | 86 |
|
| 87 | + private val ioScope = CoroutineScope(dispatcherManager.io) |
| 88 | + |
41 | 89 | override var isUserVerified: Boolean = false
|
42 | 90 |
|
43 | 91 | override var authenticationAttempts: Int = 0
|
@@ -168,6 +216,162 @@ class Fido2CredentialManagerImpl(
|
168 | 216 | ?.userVerification
|
169 | 217 | ?: fallbackRequirement
|
170 | 218 |
|
| 219 | + override suspend fun getPublicKeyCredentialEntries( |
| 220 | + userId: String, |
| 221 | + option: BeginGetPublicKeyCredentialOption, |
| 222 | + ): Result<List<CredentialEntry>> = withContext(ioScope.coroutineContext) { |
| 223 | + val options = getPasskeyAssertionOptionsOrNull(option.requestJson) |
| 224 | + ?: return@withContext GetCredentialUnknownException("Invalid data.").asFailure() |
| 225 | + val relyingPartyId = options.relyingPartyId |
| 226 | + ?: return@withContext GetCredentialUnknownException("Invalid data.").asFailure() |
| 227 | + |
| 228 | + val cipherViews = vaultRepository |
| 229 | + .ciphersStateFlow |
| 230 | + .takeUntilLoaded() |
| 231 | + .fold(initial = emptyList<CipherView>()) { initial, dataState -> |
| 232 | + when (dataState) { |
| 233 | + is DataState.Loaded -> { |
| 234 | + dataState.data.filter { it.isActiveWithFido2Credentials } |
| 235 | + } |
| 236 | + |
| 237 | + else -> initial |
| 238 | + } |
| 239 | + } |
| 240 | + |
| 241 | + if (cipherViews.isEmpty()) { |
| 242 | + return@withContext emptyList<CredentialEntry>().asSuccess() |
| 243 | + } |
| 244 | + |
| 245 | + val decryptResult = vaultRepository |
| 246 | + .getDecryptedFido2CredentialAutofillViews(cipherViews) |
| 247 | + when (decryptResult) { |
| 248 | + is DecryptFido2CredentialAutofillViewResult.Error -> { |
| 249 | + GetCredentialUnknownException("Error decrypting credentials.") |
| 250 | + .asFailure() |
| 251 | + } |
| 252 | + |
| 253 | + is DecryptFido2CredentialAutofillViewResult.Success -> { |
| 254 | + val baseIconUrl = environmentRepository |
| 255 | + .environment |
| 256 | + .environmentUrlData |
| 257 | + .baseIconUrl |
| 258 | + val autofillViews = decryptResult.fido2CredentialAutofillViews |
| 259 | + .filter { it.rpId == relyingPartyId } |
| 260 | + cipherViews |
| 261 | + .filter { cipherView -> |
| 262 | + cipherView.id in autofillViews.map { autofillView -> autofillView.cipherId } |
| 263 | + } |
| 264 | + .associateWith { cipherView -> |
| 265 | + autofillViews.first { it.cipherId == cipherView.id } |
| 266 | + } |
| 267 | + .toPublicKeyCredentialEntryList( |
| 268 | + baseIconUrl = baseIconUrl, |
| 269 | + userId = userId, |
| 270 | + option = option, |
| 271 | + ) |
| 272 | + .asSuccess() |
| 273 | + } |
| 274 | + } |
| 275 | + } |
| 276 | + |
| 277 | + private fun Map<CipherView, Fido2CredentialAutofillView>.toPublicKeyCredentialEntryList( |
| 278 | + baseIconUrl: String, |
| 279 | + userId: String, |
| 280 | + option: BeginGetPublicKeyCredentialOption, |
| 281 | + ): List<PublicKeyCredentialEntry> = this.map { |
| 282 | + val autofillView = it.value |
| 283 | + val cipherView = it.key |
| 284 | + val loginIconData = cipherView.login |
| 285 | + ?.uris |
| 286 | + .toLoginIconData( |
| 287 | + // TODO: [PM-20176] Enable web icons in passkey credential entries |
| 288 | + // Leave web icons disabled until CredentialManager TransactionTooLargeExceptions |
| 289 | + // are addressed. See https://issuetracker.google.com/issues/355141766 for details. |
| 290 | + isIconLoadingDisabled = true, |
| 291 | + baseIconUrl = baseIconUrl, |
| 292 | + usePasskeyDefaultIcon = true, |
| 293 | + ) |
| 294 | + val iconCompat = when (loginIconData) { |
| 295 | + is IconData.Local -> { |
| 296 | + IconCompat.createWithResource(context, loginIconData.iconRes) |
| 297 | + } |
| 298 | + |
| 299 | + is IconData.Network -> { |
| 300 | + loginIconData.toIconCompat() |
| 301 | + } |
| 302 | + } |
| 303 | + |
| 304 | + val pkEntryBuilder = PublicKeyCredentialEntry |
| 305 | + .Builder( |
| 306 | + context = context, |
| 307 | + username = autofillView.userNameForUi |
| 308 | + ?: context.getString(R.string.no_username), |
| 309 | + pendingIntent = intentManager |
| 310 | + .createFido2GetCredentialPendingIntent( |
| 311 | + action = GET_PASSKEY_INTENT, |
| 312 | + userId = userId, |
| 313 | + credentialId = autofillView.credentialId.toString(), |
| 314 | + cipherId = autofillView.cipherId, |
| 315 | + isUserVerified = isUserVerified, |
| 316 | + requestCode = Random.nextInt(), |
| 317 | + ), |
| 318 | + beginGetPublicKeyCredentialOption = option, |
| 319 | + ) |
| 320 | + .setIcon(iconCompat.toIcon(context)) |
| 321 | + |
| 322 | + if (featureFlagManager |
| 323 | + .getFeatureFlag(FlagKey.SingleTapPasskeyAuthentication) |
| 324 | + ) { |
| 325 | + biometricsEncryptionManager |
| 326 | + .getOrCreateCipher(userId) |
| 327 | + ?.let { cipher -> |
| 328 | + pkEntryBuilder |
| 329 | + .setBiometricPromptDataIfSupported(cipher = cipher) |
| 330 | + } |
| 331 | + } |
| 332 | + |
| 333 | + pkEntryBuilder.build() |
| 334 | + } |
| 335 | + |
| 336 | + private fun PublicKeyCredentialEntry.Builder.setBiometricPromptDataIfSupported( |
| 337 | + cipher: Cipher, |
| 338 | + ): PublicKeyCredentialEntry.Builder = |
| 339 | + if (isBuildVersionBelow(Build.VERSION_CODES.VANILLA_ICE_CREAM)) { |
| 340 | + this |
| 341 | + } else { |
| 342 | + setBiometricPromptData( |
| 343 | + biometricPromptData = buildPromptDataWithCipher(cipher), |
| 344 | + ) |
| 345 | + } |
| 346 | + |
| 347 | + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) |
| 348 | + private fun buildPromptDataWithCipher( |
| 349 | + cipher: Cipher, |
| 350 | + ): BiometricPromptData = BiometricPromptData.Builder() |
| 351 | + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) |
| 352 | + .setCryptoObject(BiometricPrompt.CryptoObject(cipher)) |
| 353 | + .build() |
| 354 | + |
| 355 | + @OmitFromCoverage |
| 356 | + private fun IconData.Network.toIconCompat(): IconCompat = try { |
| 357 | + val futureTargetBitmap = Glide |
| 358 | + .with(context) |
| 359 | + .asBitmap() |
| 360 | + .load(this.uri) |
| 361 | + .placeholder(R.drawable.ic_bw_passkey) |
| 362 | + .submit() |
| 363 | + |
| 364 | + IconCompat.createWithBitmap(futureTargetBitmap.get()) |
| 365 | + } catch (_: ExecutionException) { |
| 366 | + null |
| 367 | + } catch (_: InterruptedException) { |
| 368 | + null |
| 369 | + } |
| 370 | + ?: IconCompat.createWithResource( |
| 371 | + context, |
| 372 | + this.fallbackIconRes, |
| 373 | + ) |
| 374 | + |
171 | 375 | private suspend fun registerFido2CredentialForUnprivilegedApp(
|
172 | 376 | userId: String,
|
173 | 377 | callingAppInfo: CallingAppInfo,
|
|
0 commit comments