feat(auth, user): Support for second factor FIDO2 when obtaining password scope.

This commit is contained in:
Mateusz Armatys
2024-06-13 17:51:55 +02:00
committed by MargeBot
parent 49efb7b352
commit bf5a84eb86
25 changed files with 407 additions and 97 deletions
@@ -157,6 +157,10 @@ public final class me/proton/core/auth/fido/domain/entity/Fido2RegisteredKey$Com
public final fun serializer ()Lkotlinx/serialization/KSerializer;
}
public final class me/proton/core/auth/fido/domain/ext/Fido2AuthenticationExtensionsClientInputsExtKt {
public static final fun toJson (Lme/proton/core/auth/fido/domain/entity/Fido2AuthenticationExtensionsClientInputs;)Lkotlinx/serialization/json/JsonObject;
}
public abstract interface class me/proton/core/auth/fido/domain/usecase/PerformTwoFaWithSecurityKey {
public abstract fun invoke (Ljava/lang/Object;Lme/proton/core/auth/fido/domain/entity/Fido2PublicKeyCredentialRequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun register (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;)V
+3 -2
View File
@@ -1,4 +1,4 @@
import studio.forface.easygradle.dsl.implementation
import studio.forface.easygradle.dsl.*
/*
* Copyright (c) 2024 Proton Technologies AG
@@ -27,6 +27,7 @@ publishOption.shouldBePublishedAsLib = true
dependencies {
implementation(
`serialization-core`
`serialization-core`,
`serialization-json`
)
}
@@ -16,14 +16,13 @@
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.data.api.fido2
package me.proton.core.auth.fido.domain.ext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import me.proton.core.auth.fido.domain.entity.Fido2AuthenticationExtensionsClientInputs
internal fun Fido2AuthenticationExtensionsClientInputs.toJson(): JsonObject? = JsonObject(
public fun Fido2AuthenticationExtensionsClientInputs.toJson(): JsonObject? = JsonObject(
buildMap {
if (appId != null) {
put("appid", JsonPrimitive(appId))
@@ -36,16 +35,3 @@ internal fun Fido2AuthenticationExtensionsClientInputs.toJson(): JsonObject? = J
}
}
).takeIf { it.isNotEmpty() }
internal fun PublicKeyCredentialRequestOptionsResponse.toFido2AuthenticationExtensionsClientInputs() =
Fido2AuthenticationExtensionsClientInputs(
appId = extensions?.get("appid")?.let { jsonElement ->
(jsonElement as? JsonPrimitive)?.takeIf { it.isString }?.content?.takeIf { it.isNotEmpty() }
},
thirdPartyPayment = extensions?.get("thirdPartyPayment")?.let { jsonElement ->
(jsonElement as? JsonPrimitive)?.booleanOrNull
},
uvm = extensions?.get("uvm")?.let { jsonElement ->
(jsonElement as? JsonPrimitive)?.booleanOrNull
},
)
+2 -2
View File
@@ -29,8 +29,8 @@ protonBuild {
}
protonCoverage {
branchCoveragePercentage.set(67)
lineCoveragePercentage.set(68)
branchCoveragePercentage.set(64)
lineCoveragePercentage.set(67)
}
publishOption.shouldBePublishedAsLib = true
@@ -20,6 +20,9 @@ package me.proton.core.auth.data.api.fido2
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import me.proton.core.auth.fido.domain.entity.Fido2AuthenticationExtensionsClientInputs
import me.proton.core.auth.fido.domain.entity.Fido2PublicKeyCredentialRequestOptions
/**
@@ -70,3 +73,17 @@ data class PublicKeyCredentialRequestOptionsResponse(
extensions = toFido2AuthenticationExtensionsClientInputs()
)
}
internal fun PublicKeyCredentialRequestOptionsResponse.toFido2AuthenticationExtensionsClientInputs() =
Fido2AuthenticationExtensionsClientInputs(
appId = extensions?.get("appid")?.let { jsonElement ->
(jsonElement as? JsonPrimitive)?.takeIf { it.isString }?.content?.takeIf { it.isNotEmpty() }
},
thirdPartyPayment = extensions?.get("thirdPartyPayment")?.let { jsonElement ->
(jsonElement as? JsonPrimitive)?.booleanOrNull
},
uvm = extensions?.get("uvm")?.let { jsonElement ->
(jsonElement as? JsonPrimitive)?.booleanOrNull
},
)
@@ -55,4 +55,28 @@ data class Fido2Request(
val signature: String, // base64
@SerialName("CredentialID")
val credentialID: UByteArray
)
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Fido2Request
if (authenticationOptions != other.authenticationOptions) return false
if (clientData != other.clientData) return false
if (authenticatorData != other.authenticatorData) return false
if (signature != other.signature) return false
if (credentialID != other.credentialID) return false
return true
}
override fun hashCode(): Int {
var result = authenticationOptions.hashCode()
result = 31 * result + clientData.hashCode()
result = 31 * result + authenticatorData.hashCode()
result = 31 * result + signature.hashCode()
result = 31 * result + credentialID.hashCode()
return result
}
}
@@ -24,7 +24,7 @@ import me.proton.core.auth.data.api.AuthenticationApi
import me.proton.core.auth.data.api.fido2.AuthenticationOptionsData
import me.proton.core.auth.data.api.fido2.PublicKeyCredentialDescriptorData
import me.proton.core.auth.data.api.fido2.PublicKeyCredentialRequestOptionsResponse
import me.proton.core.auth.data.api.fido2.toJson
import me.proton.core.auth.fido.domain.ext.toJson
import me.proton.core.auth.data.api.request.AuthInfoRequest
import me.proton.core.auth.data.api.request.EmailValidationRequest
import me.proton.core.auth.data.api.request.Fido2Request
@@ -3,11 +3,11 @@ package me.proton.core.auth.data.api.fido2
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import me.proton.core.auth.fido.domain.entity.Fido2AuthenticationExtensionsClientInputs
import me.proton.core.auth.fido.domain.ext.toJson
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
@OptIn(ExperimentalUnsignedTypes::class)
class Fido2AuthenticationExtensionsClientInputsExtTest {
@Test
fun `null client inputs to json`() {
@@ -39,34 +39,4 @@ class Fido2AuthenticationExtensionsClientInputsExtTest {
json
)
}
@Test
fun `request options response with null extensions to client inputs`() {
val result = PublicKeyCredentialRequestOptionsResponse(
challenge = ubyteArrayOf(1U, 2U, 3U),
extensions = null
).toFido2AuthenticationExtensionsClientInputs()
assertNull(result.appId)
assertNull(result.thirdPartyPayment)
assertNull(result.uvm)
}
@Test
fun `request options response with extensions to client inputs`() {
val result = PublicKeyCredentialRequestOptionsResponse(
challenge = ubyteArrayOf(1U, 2U, 3U),
extensions = JsonObject(
mapOf(
"appid" to JsonPrimitive("appId"),
"thirdPartyPayment" to JsonPrimitive(true),
"uvm" to JsonPrimitive(true)
)
)
).toFido2AuthenticationExtensionsClientInputs()
assertEquals("appId", result.appId)
assertEquals(true, result.thirdPartyPayment)
assertEquals(true, result.uvm)
}
}
@@ -0,0 +1,40 @@
package me.proton.core.auth.data.api.fido2
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
@OptIn(ExperimentalUnsignedTypes::class)
class PublicKeyCredentialRequestOptionsResponseKtTest {
@Test
fun `request options response with null extensions to client inputs`() {
val result = PublicKeyCredentialRequestOptionsResponse(
challenge = ubyteArrayOf(1U, 2U, 3U),
extensions = null
).toFido2AuthenticationExtensionsClientInputs()
assertNull(result.appId)
assertNull(result.thirdPartyPayment)
assertNull(result.uvm)
}
@Test
fun `request options response with extensions to client inputs`() {
val result = PublicKeyCredentialRequestOptionsResponse(
challenge = ubyteArrayOf(1U, 2U, 3U),
extensions = JsonObject(
mapOf(
"appid" to JsonPrimitive("appId"),
"thirdPartyPayment" to JsonPrimitive(true),
"uvm" to JsonPrimitive(true)
)
)
).toFido2AuthenticationExtensionsClientInputs()
assertEquals("appId", result.appId)
assertEquals(true, result.thirdPartyPayment)
assertEquals(true, result.uvm)
}
}
+1 -1
View File
@@ -574,7 +574,7 @@ public final class me/proton/core/auth/domain/usecase/scopes/ObtainLockedScope {
public final class me/proton/core/auth/domain/usecase/scopes/ObtainPasswordScope {
public fun <init> (Lme/proton/core/auth/domain/repository/AuthRepository;Lme/proton/core/user/domain/repository/UserRepository;Lme/proton/core/crypto/common/context/CryptoContext;)V
public final fun invoke (Lme/proton/core/domain/entity/UserId;Lme/proton/core/network/domain/session/SessionId;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun invoke (Lme/proton/core/domain/entity/UserId;Lme/proton/core/network/domain/session/SessionId;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lme/proton/core/user/domain/entity/SecondFactorFido;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public final class me/proton/core/auth/domain/usecase/scopes/RemoveSecurityScopes {
@@ -25,6 +25,7 @@ import me.proton.core.crypto.common.keystore.decrypt
import me.proton.core.crypto.common.keystore.use
import me.proton.core.domain.entity.UserId
import me.proton.core.network.domain.session.SessionId
import me.proton.core.user.domain.entity.SecondFactorFido
import me.proton.core.user.domain.repository.UserRepository
import javax.inject.Inject
@@ -38,7 +39,8 @@ class ObtainPasswordScope @Inject constructor(
sessionId: SessionId,
username: String,
password: EncryptedString,
twoFactorCode: String?
secondFactorCode: String?,
secondFactorFido: SecondFactorFido?
): Boolean {
val authInfo = authRepository.getAuthInfoSrp(
sessionId = sessionId,
@@ -57,7 +59,8 @@ class ObtainPasswordScope @Inject constructor(
userId,
clientProofs,
authInfo.srpSession,
twoFactorCode
secondFactorCode = secondFactorCode,
secondFactorFido = secondFactorFido
)
}
}
@@ -102,6 +102,7 @@ class ObtainPasswordScopeTest {
testUserId,
testSrpProofs,
authInfoResult.srpSession,
null,
null
)
} returns true
@@ -110,7 +111,7 @@ class ObtainPasswordScopeTest {
@Test
fun testUnlockingPasswordNo2FASuccess() = runTest {
val result = useCase.invoke(testUserId, testSessionId, testUsername, testPasswordEncrypted, null)
val result = useCase.invoke(testUserId, testSessionId, testUsername, testPasswordEncrypted, null, null)
coVerify { authRepository.getAuthInfoSrp(testSessionId, testUsername) }
assertTrue(result)
@@ -123,10 +124,11 @@ class ObtainPasswordScopeTest {
testUserId,
testSrpProofs,
authInfoResult.srpSession,
test2FACode
test2FACode,
null
)
} returns true
val result = useCase.invoke(testUserId, testSessionId, testUsername, testPasswordEncrypted, test2FACode)
val result = useCase.invoke(testUserId, testSessionId, testUsername, testPasswordEncrypted, test2FACode, null)
coVerify { authRepository.getAuthInfoSrp(testSessionId, testUsername) }
assertTrue(result)
@@ -139,10 +141,11 @@ class ObtainPasswordScopeTest {
testUserId,
testSrpProofs,
authInfoResult.srpSession,
null,
null
)
} returns false
val result = useCase.invoke(testUserId, testSessionId, testUsername, testPasswordEncrypted, null)
val result = useCase.invoke(testUserId, testSessionId, testUsername, testPasswordEncrypted, null, null)
coVerify { authRepository.getAuthInfoSrp(testSessionId, testUsername) }
assertFalse(result)
@@ -155,10 +158,11 @@ class ObtainPasswordScopeTest {
testUserId,
testSrpProofs,
authInfoResult.srpSession,
test2FACode
test2FACode,
null
)
} returns false
val result = useCase.invoke(testUserId, testSessionId, testUsername, testPasswordEncrypted, test2FACode)
val result = useCase.invoke(testUserId, testSessionId, testUsername, testPasswordEncrypted, test2FACode, null)
coVerify { authRepository.getAuthInfoSrp(testSessionId, testUsername) }
assertFalse(result)
@@ -171,6 +175,7 @@ class ObtainPasswordScopeTest {
testUserId,
testSrpProofs,
authInfoResult.srpSession,
null,
null
)
} throws ApiException(
@@ -183,7 +188,7 @@ class ObtainPasswordScopeTest {
// WHEN
val throwable = assertFailsWith(ApiException::class) {
useCase.invoke(testUserId, testSessionId, testUsername, testPasswordEncrypted, null)
useCase.invoke(testUserId, testSessionId, testUsername, testPasswordEncrypted, null, null)
}
// THEN
+14 -1
View File
@@ -442,9 +442,13 @@ public final class me/proton/core/auth/presentation/alert/confirmpass/ConfirmPas
public static final field BUNDLE_CONFIRM_PASS_DATA Ljava/lang/String;
public static final field CONFIRM_PASS_SET Ljava/lang/String;
public static final field Companion Lme/proton/core/auth/presentation/alert/confirmpass/ConfirmPasswordDialog$Companion;
public field performTwoFaWithSecurityKey Ljava/util/Optional;
public fun <init> ()V
public final fun getPerformTwoFaWithSecurityKey ()Ljava/util/Optional;
public fun onCreate (Landroid/os/Bundle;)V
public fun onCreateDialog (Landroid/os/Bundle;)Landroid/app/Dialog;
public fun onDismiss (Landroid/content/DialogInterface;)V
public final fun setPerformTwoFaWithSecurityKey (Ljava/util/Optional;)V
}
public final class me/proton/core/auth/presentation/alert/confirmpass/ConfirmPasswordDialog$Companion {
@@ -455,6 +459,14 @@ public abstract interface class me/proton/core/auth/presentation/alert/confirmpa
public abstract fun injectConfirmPasswordDialog (Lme/proton/core/auth/presentation/alert/confirmpass/ConfirmPasswordDialog;)V
}
public final class me/proton/core/auth/presentation/alert/confirmpass/ConfirmPasswordDialog_MembersInjector : dagger/MembersInjector {
public fun <init> (Ljavax/inject/Provider;)V
public static fun create (Ljavax/inject/Provider;)Ldagger/MembersInjector;
public synthetic fun injectMembers (Ljava/lang/Object;)V
public fun injectMembers (Lme/proton/core/auth/presentation/alert/confirmpass/ConfirmPasswordDialog;)V
public static fun injectPerformTwoFaWithSecurityKey (Lme/proton/core/auth/presentation/alert/confirmpass/ConfirmPasswordDialog;Ljava/util/Optional;)V
}
public abstract class me/proton/core/auth/presentation/alert/confirmpass/Hilt_ConfirmPasswordDialog : androidx/fragment/app/DialogFragment, dagger/hilt/internal/GeneratedComponentManagerHolder {
public final fun componentManager ()Ldagger/hilt/android/internal/managers/FragmentComponentManager;
public synthetic fun componentManager ()Ldagger/hilt/internal/GeneratedComponentManager;
@@ -2467,7 +2479,8 @@ public final class me/proton/core/auth/presentation/viewmodel/ConfirmPasswordDia
public final fun getState ()Lkotlinx/coroutines/flow/SharedFlow;
public final fun onConfirmPasswordResult (Lme/proton/core/network/domain/scopes/MissingScopeState;)Lkotlinx/coroutines/Job;
public final fun setFido2Info (Lme/proton/core/auth/domain/entity/Fido2Info;)V
public final fun unlock (Lme/proton/core/domain/entity/UserId;Lme/proton/core/network/domain/scopes/Scope;Ljava/lang/String;Ljava/lang/String;)Lkotlinx/coroutines/Job;
public final fun unlock (Lme/proton/core/domain/entity/UserId;Lme/proton/core/network/domain/scopes/Scope;Ljava/lang/String;Ljava/lang/String;Lme/proton/core/user/domain/entity/SecondFactorFido;)Lkotlinx/coroutines/Job;
public static synthetic fun unlock$default (Lme/proton/core/auth/presentation/viewmodel/ConfirmPasswordDialogViewModel;Lme/proton/core/domain/entity/UserId;Lme/proton/core/network/domain/scopes/Scope;Ljava/lang/String;Ljava/lang/String;Lme/proton/core/user/domain/entity/SecondFactorFido;ILjava/lang/Object;)Lkotlinx/coroutines/Job;
}
public abstract class me/proton/core/auth/presentation/viewmodel/ConfirmPasswordDialogViewModel$State {
@@ -22,6 +22,7 @@ import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
@@ -32,7 +33,11 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import me.proton.core.auth.domain.entity.SecondFactorMethod
import me.proton.core.auth.fido.domain.entity.Fido2PublicKeyCredentialRequestOptions
import me.proton.core.auth.fido.domain.usecase.PerformTwoFaWithSecurityKey
import me.proton.core.auth.presentation.LogTag
import me.proton.core.auth.presentation.R
import me.proton.core.auth.presentation.databinding.DialogConfirmPasswordBinding
import me.proton.core.auth.presentation.entity.confirmpass.ConfirmPasswordInput
@@ -43,14 +48,22 @@ import me.proton.core.network.domain.scopes.MissingScopeState
import me.proton.core.network.domain.scopes.Scope
import me.proton.core.presentation.utils.ProtectScreenConfiguration
import me.proton.core.presentation.utils.ScreenContentProtector
import me.proton.core.presentation.utils.errorSnack
import me.proton.core.presentation.utils.errorToast
import me.proton.core.presentation.utils.openBrowserLink
import me.proton.core.user.domain.entity.SecondFactorFido
import me.proton.core.util.kotlin.CoreLogger
import java.util.Optional
import javax.inject.Inject
import kotlin.jvm.optionals.getOrNull
/**
* This dialog handles only [Scope.PASSWORD] or [Scope.LOCKED]. Any other scope will be ignored.
*/
@AndroidEntryPoint
class ConfirmPasswordDialog : DialogFragment() {
@Inject
lateinit var performTwoFaWithSecurityKey: Optional<PerformTwoFaWithSecurityKey<ComponentActivity>>
private val viewModel by viewModels<ConfirmPasswordDialogViewModel>()
@@ -83,16 +96,8 @@ class ConfirmPasswordDialog : DialogFragment() {
ConfirmPasswordDialogViewController(
DialogConfirmPasswordBinding.inflate(layoutInflater),
lifecycleOwner = this,
onEnterButtonClick = { selectedSecondFactorMethod ->
when (selectedSecondFactorMethod) {
SecondFactorMethod.Totp -> onTotpSubmitted()
SecondFactorMethod.Authenticator -> onSecurityKeySubmitted()
null -> Unit
}
},
onCancelButtonClick = {
setResultAndDismiss(null)
},
onEnterButtonClick = this::onEnterButtonClick,
onCancelButtonClick = { setResultAndDismiss(null) },
onSecurityKeyInfoClick = {
context?.let {
it.openBrowserLink(it.getString(R.string.confirm_password_2fa_security_key))
@@ -101,6 +106,11 @@ class ConfirmPasswordDialog : DialogFragment() {
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
performTwoFaWithSecurityKey.getOrNull()?.register(requireActivity(), this::onTwoFaWithSecurityKeyResult)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
super.onCreateDialog(savedInstanceState)
screenProtector.protect(requireActivity())
@@ -129,15 +139,45 @@ class ConfirmPasswordDialog : DialogFragment() {
.setView(viewController.root)
.create()
private fun onEnterButtonClick(selectedSecondFactorMethod: SecondFactorMethod?) {
val password = viewController.password.orEmpty()
when (missingScope) {
Scope.PASSWORD -> when (selectedSecondFactorMethod) {
SecondFactorMethod.Totp -> onTotpSubmitted()
SecondFactorMethod.Authenticator -> onSecurityKeySubmitted()
null -> viewModel.unlock(userId, missingScope, password)
}
Scope.LOCKED -> viewModel.unlock(userId, missingScope, password)
}
}
private fun onTotpSubmitted() {
val password = viewController.password
val password = viewController.password.orEmpty()
val twoFactorCode = viewController.twoFactorCode
viewModel.unlock(userId, missingScope, password.orEmpty(), twoFactorCode)
viewModel.unlock(userId, missingScope, password, secondFactorCode = twoFactorCode)
}
private fun onSecurityKeySubmitted() {
// TODO Launch Fido2ApiClient (with data from viewModel.fido2Info),
// get the result and pass it back to the view model for final processing.
val performTwoFaWithSecurityKey = performTwoFaWithSecurityKey.getOrNull() ?: return
val requestOptions = requireNotNull(viewModel.fido2Info?.authenticationOptions?.publicKey)
viewController.setLoading()
lifecycleScope.launch {
val activity = activity ?: return@launch
when (val launchResult = performTwoFaWithSecurityKey.invoke(activity, requestOptions)) {
is PerformTwoFaWithSecurityKey.LaunchResult.Failure -> {
viewController.setIdle()
viewController.root.errorSnack(
message = launchResult.exception.localizedMessage
?: getString(R.string.auth_login_general_error)
)
}
is PerformTwoFaWithSecurityKey.LaunchResult.Success -> Unit
}
}
}
private fun handleState(state: ConfirmPasswordDialogViewModel.State) = when (state) {
@@ -177,6 +217,49 @@ class ConfirmPasswordDialog : DialogFragment() {
screenProtector.unprotect(requireActivity())
}
private fun onTwoFaWithSecurityKeyResult(
result: PerformTwoFaWithSecurityKey.Result,
options: Fido2PublicKeyCredentialRequestOptions
) {
viewController.setIdle()
when (result) {
is PerformTwoFaWithSecurityKey.Result.Success -> onSecurityKeyAuthSuccess(result, options)
is PerformTwoFaWithSecurityKey.Result.Cancelled -> Unit
is PerformTwoFaWithSecurityKey.Result.EmptyResult -> viewController.root.errorSnack(
getString(R.string.auth_login_general_error)
)
is PerformTwoFaWithSecurityKey.Result.Error -> viewController.root.errorSnack(
result.error.message ?: getString(R.string.auth_login_general_error)
)
is PerformTwoFaWithSecurityKey.Result.UnknownResult -> {
getString(R.string.auth_login_general_error)
CoreLogger.e(LogTag.FLOW_ERROR_2FA, result.toString())
}
}
}
private fun onSecurityKeyAuthSuccess(
result: PerformTwoFaWithSecurityKey.Result.Success,
options: Fido2PublicKeyCredentialRequestOptions
) {
val password = viewController.password.orEmpty()
viewModel.unlock(
userId = userId,
missingScope = missingScope,
password = password,
secondFactorFido = SecondFactorFido(
publicKeyOptions = options,
clientData = result.response.clientDataJSON,
authenticatorData = result.response.authenticatorData,
signature = result.response.signature,
credentialID = result.rawId
)
)
}
companion object {
private const val ARG_INPUT = "arg.confirmPasswordInput"
@@ -44,6 +44,7 @@ import me.proton.core.network.domain.scopes.MissingScopeListener
import me.proton.core.network.domain.scopes.MissingScopeState
import me.proton.core.network.domain.scopes.Scope
import me.proton.core.presentation.viewmodel.ProtonViewModel
import me.proton.core.user.domain.entity.SecondFactorFido
import me.proton.core.util.kotlin.exhaustive
import me.proton.core.util.kotlin.takeIfNotEmpty
import javax.inject.Inject
@@ -107,7 +108,8 @@ class ConfirmPasswordDialogViewModel @Inject constructor(
userId: UserId,
missingScope: Scope,
password: String,
twoFactorCode: String?
secondFactorCode: String? = null,
secondFactorFido: SecondFactorFido? = null
) = flow {
emit(State.ProcessingObtainScope)
val account = accountManager.getAccount(userId).firstOrNull()
@@ -121,7 +123,8 @@ class ConfirmPasswordDialogViewModel @Inject constructor(
sessionId = requireNotNull(account.sessionId),
username = requireNotNull(account.username),
password = password.encrypt(keyStoreCrypto),
twoFactorCode = twoFactorCode?.takeIfNotEmpty()
secondFactorCode = secondFactorCode?.takeIfNotEmpty(),
secondFactorFido = secondFactorFido
)
Scope.LOCKED -> obtainLockedScope(
@@ -195,6 +195,7 @@ class ConfirmPasswordDialogViewModelTest :
testSessionId,
testUsername,
testPasswordEncrypted,
null,
null
)
} returns true
@@ -220,6 +221,7 @@ class ConfirmPasswordDialogViewModelTest :
testSessionId,
testUsername,
testPasswordEncrypted,
null,
null
)
} throws ApiException(
@@ -253,7 +255,8 @@ class ConfirmPasswordDialogViewModelTest :
testSessionId,
testUsername,
testPasswordEncrypted,
test2FACode
test2FACode,
null
)
} returns true
flowTest(viewModel.state) {
@@ -278,7 +281,8 @@ class ConfirmPasswordDialogViewModelTest :
testSessionId,
testUsername,
testPasswordEncrypted,
test2FACode
test2FACode,
null
)
} throws ApiException(
ApiResult.Error.Http(
+7 -5
View File
@@ -426,17 +426,19 @@ public final class me/proton/core/user/data/api/request/CreateUserRequest$Compan
public final class me/proton/core/user/data/api/request/UnlockPasswordRequest {
public static final field Companion Lme/proton/core/user/data/api/request/UnlockPasswordRequest$Companion;
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lme/proton/core/auth/data/api/request/Fido2Request;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lme/proton/core/auth/data/api/request/Fido2Request;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/lang/String;
public final fun component3 ()Ljava/lang/String;
public final fun component4 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lme/proton/core/user/data/api/request/UnlockPasswordRequest;
public static synthetic fun copy$default (Lme/proton/core/user/data/api/request/UnlockPasswordRequest;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lme/proton/core/user/data/api/request/UnlockPasswordRequest;
public final fun component5 ()Lme/proton/core/auth/data/api/request/Fido2Request;
public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lme/proton/core/auth/data/api/request/Fido2Request;)Lme/proton/core/user/data/api/request/UnlockPasswordRequest;
public static synthetic fun copy$default (Lme/proton/core/user/data/api/request/UnlockPasswordRequest;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lme/proton/core/auth/data/api/request/Fido2Request;ILjava/lang/Object;)Lme/proton/core/user/data/api/request/UnlockPasswordRequest;
public fun equals (Ljava/lang/Object;)Z
public final fun getClientEphemeral ()Ljava/lang/String;
public final fun getClientProof ()Ljava/lang/String;
public final fun getFido2 ()Lme/proton/core/auth/data/api/request/Fido2Request;
public final fun getSrpSession ()Ljava/lang/String;
public final fun getTwoFactorCode ()Ljava/lang/String;
public fun hashCode ()I
@@ -1000,7 +1002,7 @@ public final class me/proton/core/user/data/repository/UserRepositoryImpl : me/p
public fun removeLockedAndPasswordScopes (Lme/proton/core/domain/entity/UserId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun setPassphrase (Lme/proton/core/domain/entity/UserId;Lme/proton/core/crypto/common/keystore/EncryptedByteArray;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun unlockUserForLockedScope (Lme/proton/core/domain/entity/UserId;Lme/proton/core/crypto/common/srp/SrpProofs;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun unlockUserForPasswordScope (Lme/proton/core/domain/entity/UserId;Lme/proton/core/crypto/common/srp/SrpProofs;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun unlockUserForPasswordScope (Lme/proton/core/domain/entity/UserId;Lme/proton/core/crypto/common/srp/SrpProofs;Ljava/lang/String;Ljava/lang/String;Lme/proton/core/user/domain/entity/SecondFactorFido;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun updateUser (Lme/proton/core/user/domain/entity/User;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun updateUserUsedBaseSpace (Lme/proton/core/domain/entity/UserId;JLkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun updateUserUsedDriveSpace (Lme/proton/core/domain/entity/UserId;JLkotlin/coroutines/Continuation;)Ljava/lang/Object;
+2 -2
View File
@@ -30,8 +30,8 @@ protonBuild {
}
protonCoverage {
branchCoveragePercentage.set(34)
lineCoveragePercentage.set(37)
branchCoveragePercentage.set(33)
lineCoveragePercentage.set(36)
}
android {
@@ -25,6 +25,7 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull
@@ -35,9 +36,15 @@ import me.proton.core.account.data.repository.AccountRepositoryImpl
import me.proton.core.accountmanager.data.AccountManagerImpl
import me.proton.core.accountmanager.data.db.AccountManagerDatabase
import me.proton.core.accountmanager.domain.AccountManager
import me.proton.core.auth.data.api.fido2.AuthenticationOptionsData
import me.proton.core.auth.data.api.fido2.PublicKeyCredentialDescriptorData
import me.proton.core.auth.data.api.fido2.PublicKeyCredentialRequestOptionsResponse
import me.proton.core.auth.data.api.request.Fido2Request
import me.proton.core.auth.data.api.response.SRPAuthenticationResponse
import me.proton.core.auth.domain.exception.InvalidServerAuthenticationException
import me.proton.core.auth.domain.usecase.ValidateServerProof
import me.proton.core.auth.fido.domain.entity.Fido2PublicKeyCredentialDescriptor
import me.proton.core.auth.fido.domain.entity.Fido2PublicKeyCredentialRequestOptions
import me.proton.core.challenge.domain.entity.ChallengeFrameDetails
import me.proton.core.crypto.android.context.AndroidCryptoContext
import me.proton.core.crypto.common.context.CryptoContext
@@ -68,6 +75,7 @@ import me.proton.core.user.data.api.request.UnlockPasswordRequest
import me.proton.core.user.data.entity.UserEntity
import me.proton.core.user.data.extension.toUser
import me.proton.core.user.domain.entity.CreateUserType
import me.proton.core.user.domain.entity.SecondFactorFido
import me.proton.core.user.domain.entity.Type
import me.proton.core.user.domain.repository.UserLocalDataSource
import me.proton.core.user.domain.repository.UserRemoteDataSource
@@ -75,6 +83,7 @@ import me.proton.core.user.domain.repository.UserRepository
import org.junit.After
import org.junit.Before
import org.junit.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
@@ -90,7 +99,7 @@ class UserRepositoryImplTests {
@MockK(relaxed = true)
private lateinit var apiManagerFactory: ApiManagerFactory
@MockK(relaxed = true)
@MockK
private lateinit var userApi: UserApi
private val cryptoContext: CryptoContext = AndroidCryptoContext(
@@ -477,6 +486,7 @@ class UserRepositoryImplTests {
TestUsers.User1.id,
testSrpProofs,
"test-srp-session",
null,
null
)
assertNotNull(response)
@@ -507,12 +517,75 @@ class UserRepositoryImplTests {
TestUsers.User1.id,
testSrpProofs,
"test-srp-session",
"test-2fa"
"test-2fa",
null
)
assertNotNull(response)
assertTrue(response)
}
@OptIn(ExperimentalUnsignedTypes::class)
@Test
fun unlockUser_2fa_fido_passwordScope() = runTest {
// GIVEN
val credentialId = ubyteArrayOf(7U, 8U)
val challenge = ubyteArrayOf(9U, 10U)
val credentialType = "credential-type"
val unlockPasswordRequestSlot = slot<UnlockPasswordRequest>()
coEvery {
userApi.unlockPasswordScope(capture(unlockPasswordRequestSlot))
} answers {
SRPAuthenticationResponse(
code = 1000,
serverProof = testSrpProofs.expectedServerProof,
)
}
// WHEN
val response = userRepository.unlockUserForPasswordScope(
TestUsers.User1.id,
testSrpProofs,
"test-srp-session",
null,
SecondFactorFido(
publicKeyOptions = Fido2PublicKeyCredentialRequestOptions(
challenge = challenge,
allowCredentials = listOf(
Fido2PublicKeyCredentialDescriptor(
type = credentialType,
id = credentialId,
transports = null
)
)
),
clientData = byteArrayOf(1, 2),
authenticatorData = byteArrayOf(3, 4),
signature = byteArrayOf(5, 6),
credentialID = credentialId.toByteArray()
)
)
assertNotNull(response)
assertTrue(response)
val capturedFido2Request = unlockPasswordRequestSlot.captured.fido2!!
val capturedPublicKey = capturedFido2Request.authenticationOptions.publicKey
assertEquals("AQI=", capturedFido2Request.clientData)
assertEquals("AwQ=", capturedFido2Request.authenticatorData)
assertEquals("BQY=", capturedFido2Request.signature)
assertContentEquals(credentialId, capturedFido2Request.credentialID)
assertContentEquals(challenge, capturedPublicKey.challenge)
assertContentEquals(
listOf(
PublicKeyCredentialDescriptorData(
type = credentialType,
id = credentialId,
transports = null
)
), capturedPublicKey.allowCredentials
)
}
@Test
fun unlockUser_wrong_server_proof(): Unit = runTest {
// GIVEN
@@ -20,6 +20,7 @@ package me.proton.core.user.data.api.request
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import me.proton.core.auth.data.api.request.Fido2Request
@Serializable
data class UnlockPasswordRequest(
@@ -30,5 +31,7 @@ data class UnlockPasswordRequest(
@SerialName("SRPSession")
val srpSession: String,
@SerialName("TwoFactorCode")
val twoFactorCode: String? = null
val twoFactorCode: String? = null,
@SerialName("FIDO2")
val fido2: Fido2Request? = null
)
@@ -19,6 +19,7 @@
package me.proton.core.user.data.repository
import android.content.Context
import android.util.Base64
import com.dropbox.android.external.store4.Fetcher
import com.dropbox.android.external.store4.SourceOfTruth
import com.dropbox.android.external.store4.StoreBuilder
@@ -27,8 +28,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import me.proton.core.auth.data.api.fido2.AuthenticationOptionsData
import me.proton.core.auth.data.api.fido2.PublicKeyCredentialDescriptorData
import me.proton.core.auth.data.api.fido2.PublicKeyCredentialRequestOptionsResponse
import me.proton.core.auth.data.api.request.Fido2Request
import me.proton.core.auth.data.api.response.isSuccess
import me.proton.core.auth.domain.usecase.ValidateServerProof
import me.proton.core.auth.fido.domain.ext.toJson
import me.proton.core.challenge.data.frame.ChallengeFrame
import me.proton.core.challenge.domain.entity.ChallengeFrameDetails
import me.proton.core.challenge.domain.framePrefix
@@ -50,6 +56,7 @@ import me.proton.core.user.data.api.request.UnlockPasswordRequest
import me.proton.core.user.data.api.request.UnlockRequest
import me.proton.core.user.data.extension.toUser
import me.proton.core.user.domain.entity.CreateUserType
import me.proton.core.user.domain.entity.SecondFactorFido
import me.proton.core.user.domain.entity.User
import me.proton.core.user.domain.repository.PassphraseRepository
import me.proton.core.user.domain.repository.UserLocalDataSource
@@ -198,14 +205,16 @@ class UserRepositoryImpl @Inject constructor(
sessionUserId: SessionUserId,
srpProofs: SrpProofs,
srpSession: String,
twoFactorCode: String?
secondFactorCode: String?,
secondFactorFido: SecondFactorFido?
): Boolean =
provider.get<UserApi>(sessionUserId).invoke {
val request = UnlockPasswordRequest(
srpProofs.clientEphemeral,
srpProofs.clientProof,
srpSession,
twoFactorCode
secondFactorCode,
secondFactorFido?.toFido2Request()
)
val response = unlockPasswordScope(request)
validateServerProof(
@@ -276,3 +285,32 @@ class UserRepositoryImpl @Inject constructor(
// endregion
}
@OptIn(ExperimentalUnsignedTypes::class)
private fun SecondFactorFido.toFido2Request(): Fido2Request {
val optionsData = AuthenticationOptionsData(
PublicKeyCredentialRequestOptionsResponse(
challenge = publicKeyOptions.challenge,
timeout = publicKeyOptions.timeout,
rpId = publicKeyOptions.rpId,
allowCredentials = publicKeyOptions.allowCredentials?.map {
PublicKeyCredentialDescriptorData(
type = it.type,
id = it.id,
transports = it.transports
)
},
userVerification = publicKeyOptions.userVerification,
extensions = publicKeyOptions.extensions?.toJson()
)
)
return Fido2Request(
authenticationOptions = optionsData,
clientData = clientData.toBase64(),
authenticatorData = authenticatorData.toBase64(),
signature = signature.toBase64(),
credentialID = credentialID.toUByteArray()
)
}
private fun ByteArray.toBase64(): String = Base64.encodeToString(this, Base64.NO_WRAP)
+10 -1
View File
@@ -205,6 +205,15 @@ public final class me/proton/core/user/domain/entity/Role$Companion {
public final fun getMap ()Ljava/util/Map;
}
public final class me/proton/core/user/domain/entity/SecondFactorFido {
public fun <init> (Lme/proton/core/auth/fido/domain/entity/Fido2PublicKeyCredentialRequestOptions;[B[B[B[B)V
public final fun getAuthenticatorData ()[B
public final fun getClientData ()[B
public final fun getCredentialID ()[B
public final fun getPublicKeyOptions ()Lme/proton/core/auth/fido/domain/entity/Fido2PublicKeyCredentialRequestOptions;
public final fun getSignature ()[B
}
public final class me/proton/core/user/domain/entity/Type : java/lang/Enum {
public static final field Companion Lme/proton/core/user/domain/entity/Type$Companion;
public static final field CredentialLess Lme/proton/core/user/domain/entity/Type;
@@ -551,7 +560,7 @@ public abstract interface class me/proton/core/user/domain/repository/UserReposi
public abstract fun observeUser (Lme/proton/core/domain/entity/UserId;Z)Lkotlinx/coroutines/flow/Flow;
public abstract fun removeLockedAndPasswordScopes (Lme/proton/core/domain/entity/UserId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun unlockUserForLockedScope (Lme/proton/core/domain/entity/UserId;Lme/proton/core/crypto/common/srp/SrpProofs;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun unlockUserForPasswordScope (Lme/proton/core/domain/entity/UserId;Lme/proton/core/crypto/common/srp/SrpProofs;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun unlockUserForPasswordScope (Lme/proton/core/domain/entity/UserId;Lme/proton/core/crypto/common/srp/SrpProofs;Ljava/lang/String;Ljava/lang/String;Lme/proton/core/user/domain/entity/SecondFactorFido;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun updateUser (Lme/proton/core/user/domain/entity/User;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun updateUserUsedBaseSpace (Lme/proton/core/domain/entity/UserId;JLkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun updateUserUsedDriveSpace (Lme/proton/core/domain/entity/UserId;JLkotlin/coroutines/Continuation;)Ljava/lang/Object;
+2 -1
View File
@@ -30,12 +30,13 @@ publishOption.shouldBePublishedAsLib = true
protonCoverage {
branchCoveragePercentage.set(77)
lineCoveragePercentage.set(56)
lineCoveragePercentage.set(55)
}
dependencies {
api(
project(Module.accountDomain),
project(Module.authFidoDomain),
project(Module.challengeDomain),
project(Module.cryptoCommon),
project(Module.domain),
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.user.domain.entity
import me.proton.core.auth.fido.domain.entity.Fido2PublicKeyCredentialRequestOptions
class SecondFactorFido(
val publicKeyOptions: Fido2PublicKeyCredentialRequestOptions,
val clientData: ByteArray,
val authenticatorData: ByteArray,
val signature: ByteArray,
val credentialID: ByteArray
)
@@ -29,6 +29,7 @@ import me.proton.core.domain.entity.UserId
import me.proton.core.network.domain.session.SessionId
import me.proton.core.user.domain.entity.CreateUserType
import me.proton.core.user.domain.entity.Domain
import me.proton.core.user.domain.entity.SecondFactorFido
import me.proton.core.user.domain.entity.User
@Suppress("TooManyFunctions")
@@ -154,7 +155,8 @@ interface UserRepository : PassphraseRepository {
sessionUserId: SessionUserId,
srpProofs: SrpProofs,
srpSession: String,
twoFactorCode: String?
secondFactorCode: String?,
secondFactorFido: SecondFactorFido?
): Boolean
/**