From 56befef575c0b861b4532ad6863c103305d26358 Mon Sep 17 00:00:00 2001 From: SanderKondratjevNortal Date: Mon, 28 Jul 2025 16:33:30 +0300 Subject: [PATCH 01/10] Implement initial Web eID mobile authentication flow (#242) --- app/build.gradle.kts | 1 + .../DigiDoc/viewmodel/WebEidViewModelTest.kt | 176 ++++++++++----- .../ee/ria/DigiDoc/RIADigiDocAppNavigation.kt | 22 +- .../ee/ria/DigiDoc/fragment/WebEidFragment.kt | 23 +- .../DigiDoc/fragment/screen/WebEidScreen.kt | 192 +++++++++++++--- .../DigiDoc/ui/component/signing/NFCView.kt | 44 +++- .../ee/ria/DigiDoc/viewmodel/NFCViewModel.kt | 150 +++++++++++++ .../ria/DigiDoc/viewmodel/WebEidViewModel.kt | 104 ++++++--- app/src/main/res/values/donottranslate.xml | 5 + .../DigiDoc/domain/service/IdCardService.kt | 8 + .../domain/service/IdCardServiceImpl.kt | 45 ++++ settings.gradle.kts | 1 + web-eid-lib/.gitignore | 1 + web-eid-lib/build.gradle.kts | 66 ++++++ web-eid-lib/proguard-rules.pro | 21 ++ .../DigiDoc/webEid/WebEidAuthParserTest.kt | 134 +++++++++++ .../DigiDoc/webEid/WebEidAuthServiceTest.kt | 211 ++++++++++++++++++ .../webEid/utils/WebEidResponseUtilTest.kt | 68 ++++++ web-eid-lib/src/main/AndroidManifest.xml | 4 + .../ria/DigiDoc/webEid/WebEidAuthService.kt | 28 +++ .../DigiDoc/webEid/WebEidAuthServiceImpl.kt | 77 +++++++ .../ee/ria/DigiDoc/webEid/di/AppModules.kt | 22 ++ .../webEid/domain/model/WebEidAuthParser.kt | 20 ++ .../domain/model/WebEidAuthParserImpl.kt | 205 +++++++++++++++++ .../webEid/domain/model/WebEidAuthRequest.kt | 10 + .../webEid/domain/model/WebEidSignRequest.kt | 10 + .../DigiDoc/webEid/utils/WebEidErrorCodes.kt | 11 + .../webEid/utils/WebEidResponseUtil.kt | 55 +++++ 28 files changed, 1585 insertions(+), 129 deletions(-) create mode 100644 web-eid-lib/.gitignore create mode 100644 web-eid-lib/build.gradle.kts create mode 100644 web-eid-lib/proguard-rules.pro create mode 100644 web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthParserTest.kt create mode 100644 web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt create mode 100644 web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt create mode 100644 web-eid-lib/src/main/AndroidManifest.xml create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthService.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/di/AppModules.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthParser.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthParserImpl.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthRequest.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidErrorCodes.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a9038cde..f57d6b40 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -214,6 +214,7 @@ dependencies { implementation(project(":utils-lib")) implementation(project(":commons-lib")) implementation(project(":id-card-lib")) + implementation(project(":web-eid-lib")) androidTestImplementation(project(":commons-lib:test-files")) } diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt index 4b2f12c6..f37f1523 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt @@ -2,80 +2,156 @@ package ee.ria.DigiDoc.viewmodel +import android.app.Activity import android.net.Uri -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import ee.ria.DigiDoc.webEid.WebEidAuthService +import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest +import kotlinx.coroutines.flow.MutableStateFlow +import org.json.JSONObject import org.junit.Before +import org.junit.Rule import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.verify + +@RunWith(MockitoJUnitRunner::class) class WebEidViewModelTest { + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + @Mock + private lateinit var authService: WebEidAuthService + + @Mock + private lateinit var activity: Activity private lateinit var viewModel: WebEidViewModel @Before - fun setUp() { - viewModel = WebEidViewModel() - } + fun setup() { + MockitoAnnotations.openMocks(this) - @Test - fun handleAuth_validUri_setsAuthPayload() = runTest { - val json = """ - { - "challenge": "abc123", - "login_uri": "https://example.com/auth/login", - "get_signing_certificate": true - } - """.trimIndent() - - val encoded = java.util.Base64.getEncoder().encodeToString(json.toByteArray()) - val uri = Uri.parse("web-eid-mobile://auth#$encoded") + `when`(authService.authRequest).thenReturn(MutableStateFlow(null)) + `when`(authService.signRequest).thenReturn(MutableStateFlow(null)) + `when`(authService.errorState).thenReturn(MutableStateFlow(null)) + `when`(authService.redirectUri).thenReturn(MutableStateFlow(null)) - viewModel.handleAuth(uri) - - val result = viewModel.authPayload.value - assertEquals("abc123", result?.challenge) - assertEquals("https://example.com/auth/login", result?.loginUri) - assertEquals(true, result?.getSigningCertificate) + viewModel = WebEidViewModel(authService) } @Test - fun handleAuth_missingFragment_setsNullPayload() = runTest { - val uri = Uri.parse("web-eid-mobile://auth") - + fun handleAuth_callsParseAuthUri() { + val uri = Uri.parse("web-eid-mobile://auth#dummyData") viewModel.handleAuth(uri) - - assertNull(viewModel.authPayload.value) + verify(authService).parseAuthUri(uri) } @Test - fun handleAuth_invalidBase64_setsNullPayload() = runTest { - val uri = Uri.parse("web-eid-mobile://auth#invalid-base64!!") + fun handleSign_callsParseSignUri() { + val uri = Uri.parse("web-eid-mobile://sign#dummyData") + viewModel.handleSign(uri) + verify(authService).parseSignUri(uri) + } - viewModel.handleAuth(uri) + @Test + fun reset_callsResetValues() { + viewModel.reset() + verify(authService).resetValues() + } - assertNull(viewModel.authPayload.value) + @Test + fun redirectUri_isExposedFromAuthService() { + val redirectFlow = MutableStateFlow("https://example.com#encodedPayload") + `when`(authService.redirectUri).thenReturn(redirectFlow) + val vm = WebEidViewModel(authService) + assert(vm.redirectUri.value == "https://example.com#encodedPayload") } @Test - fun handleAuth_missingOptionalField_defaultsToFalse() = runTest { - val json = """ - { - "challenge": "xyz456", - "login_uri": "https://rp.example.com/login" - } - """.trimIndent() + fun redirectUri_updatesWhenServiceUpdates() { + val redirectFlow = MutableStateFlow(null) + `when`(authService.redirectUri).thenReturn(redirectFlow) + val vm = WebEidViewModel(authService) + redirectFlow.value = "https://example.com#updatedPayload" + assert(vm.redirectUri.value == "https://example.com#updatedPayload") + } - val encoded = java.util.Base64.getEncoder().encodeToString(json.toByteArray()) - val uri = Uri.parse("web-eid-mobile://auth#$encoded") + @Test + fun handleWebEidAuthResult_callsBuildAuthToken_whenPayloadValid() { + val cert = byteArrayOf(1, 2, 3) + val signature = byteArrayOf(4, 5, 6) + val challenge = "test-challenge" + val loginUri = "https://example.com/login" + val getSigningCertificate = true + val origin = "https://example.com" + + val authRequest = + WebEidAuthRequest( + challenge = challenge, + loginUri = loginUri, + getSigningCertificate = getSigningCertificate, + origin = origin, + ) + `when`(authService.authRequest).thenReturn(MutableStateFlow(authRequest)) + + val token = JSONObject().put("mock", "token") + `when`(authService.buildAuthToken(cert, signature, challenge)).thenReturn(token) + + viewModel = WebEidViewModel(authService) + + viewModel.handleWebEidAuthResult(cert, signature, activity) + + verify(authService).buildAuthToken(cert, signature, challenge) + verify(activity).startActivity(any()) + verify(activity).finish() + } - viewModel.handleAuth(uri) + @Test + fun handleWebEidAuthResult_doesNothing_whenChallengeMissing() { + val cert = byteArrayOf(1) + val signature = byteArrayOf(2) + + val authRequest = + WebEidAuthRequest( + challenge = "", + loginUri = "https://example.com", + getSigningCertificate = true, + origin = "https://example.com", + ) + `when`(authService.authRequest).thenReturn(MutableStateFlow(authRequest)) + + viewModel = WebEidViewModel(authService) + viewModel.handleWebEidAuthResult(cert, signature, activity) + + verify(authService, never()).buildAuthToken(any(), any(), any()) + verify(activity, never()).startActivity(any()) + } - val result = viewModel.authPayload.value - assertEquals("xyz456", result?.challenge) - assertEquals("https://rp.example.com/login", result?.loginUri) - assertEquals(false, result?.getSigningCertificate) + @Test + fun handleWebEidAuthResult_doesNothing_whenLoginUriMissing() { + val cert = byteArrayOf(1) + val signature = byteArrayOf(2) + + val authRequest = + WebEidAuthRequest( + challenge = "abc", + loginUri = "", + getSigningCertificate = true, + origin = "https://example.com", + ) + `when`(authService.authRequest).thenReturn(MutableStateFlow(authRequest)) + + viewModel = WebEidViewModel(authService) + viewModel.handleWebEidAuthResult(cert, signature, activity) + + verify(authService, never()).buildAuthToken(any(), any(), any()) + verify(activity, never()).startActivity(any()) } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt b/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt index 749639c8..9173ad71 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt @@ -76,7 +76,10 @@ import ee.ria.DigiDoc.viewmodel.shared.SharedSettingsViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedSignatureViewModel @Composable -fun RIADigiDocAppScreen(externalFileUris: List, webEidUri: Uri? = null) { +fun RIADigiDocAppScreen( + externalFileUris: List, + webEidUri: Uri? = null, +) { val navController = rememberNavController() val sharedMenuViewModel: SharedMenuViewModel = hiltViewModel() val sharedContainerViewModel: SharedContainerViewModel = hiltViewModel() @@ -88,11 +91,12 @@ fun RIADigiDocAppScreen(externalFileUris: List, webEidUri: Uri? = null) { sharedContainerViewModel.setExternalFileUris(externalFileUris) - val startDestination = when { - webEidUri != null -> Route.WebEidScreen.route - sharedSettingsViewModel.dataStore.getLocale() != null -> Route.Home.route - else -> Route.Init.route - } + val startDestination = + when { + webEidUri != null -> Route.WebEidScreen.route + sharedSettingsViewModel.dataStore.getLocale() != null -> Route.Home.route + else -> Route.Init.route + } NavHost( navController = navController, @@ -392,7 +396,9 @@ fun RIADigiDocAppScreen(externalFileUris: List, webEidUri: Uri? = null) { @Composable fun RIADigiDocAppScreenPreview() { RIADigiDocTheme { - RIADigiDocAppScreen(listOf(), - webEidUri = null) + RIADigiDocAppScreen( + listOf(), + webEidUri = null, + ) } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt index 66b897d8..09df40ff 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt @@ -21,7 +21,11 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import ee.ria.DigiDoc.fragment.screen.WebEidScreen import ee.ria.DigiDoc.ui.theme.RIADigiDocTheme +import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog import ee.ria.DigiDoc.viewmodel.WebEidViewModel +import ee.ria.DigiDoc.viewmodel.shared.SharedContainerViewModel +import ee.ria.DigiDoc.viewmodel.shared.SharedMenuViewModel +import ee.ria.DigiDoc.viewmodel.shared.SharedSettingsViewModel @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -30,10 +34,18 @@ fun WebEidFragment( navController: NavHostController, webEidUri: Uri?, viewModel: WebEidViewModel = hiltViewModel(), + sharedSettingsViewModel: SharedSettingsViewModel = hiltViewModel(), + sharedContainerViewModel: SharedContainerViewModel = hiltViewModel(), + sharedMenuViewModel: SharedMenuViewModel = hiltViewModel(), ) { LaunchedEffect(webEidUri) { - println("DEBUG: WebEidFragment got URI = $webEidUri") - webEidUri?.let { viewModel.handleAuth(it) } + webEidUri?.let { + when (it.host) { + "auth" -> viewModel.handleAuth(it) + "sign" -> viewModel.handleSign(it) + else -> debugLog("WebEidFragment", "Unknown Web eID URI host: ${it.host}") + } + } } Surface( @@ -49,6 +61,9 @@ fun WebEidFragment( modifier = modifier, navController = navController, viewModel = viewModel, + sharedSettingsViewModel = sharedSettingsViewModel, + sharedContainerViewModel = sharedContainerViewModel, + sharedMenuViewModel = sharedMenuViewModel, ) } } @@ -60,7 +75,7 @@ fun WebEidFragmentPreview() { RIADigiDocTheme { WebEidFragment( navController = rememberNavController(), - webEidUri = null + webEidUri = null, ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt index abfd7aaa..94b75f44 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt @@ -2,56 +2,190 @@ package ee.ria.DigiDoc.fragment.screen +import android.app.Activity import android.content.res.Configuration -import androidx.compose.foundation.background +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController +import ee.ria.DigiDoc.R +import ee.ria.DigiDoc.domain.model.IdentityAction +import ee.ria.DigiDoc.ui.component.menu.SettingsMenuBottomSheet +import ee.ria.DigiDoc.ui.component.shared.TopBar +import ee.ria.DigiDoc.ui.component.signing.NFCView +import ee.ria.DigiDoc.ui.theme.Dimensions.MSPadding +import ee.ria.DigiDoc.ui.theme.Dimensions.SPadding import ee.ria.DigiDoc.ui.theme.RIADigiDocTheme +import ee.ria.DigiDoc.utils.snackbar.SnackBarManager import ee.ria.DigiDoc.viewmodel.WebEidViewModel +import ee.ria.DigiDoc.viewmodel.shared.SharedContainerViewModel +import ee.ria.DigiDoc.viewmodel.shared.SharedMenuViewModel +import ee.ria.DigiDoc.viewmodel.shared.SharedSettingsViewModel +import kotlinx.coroutines.launch @Composable fun WebEidScreen( modifier: Modifier = Modifier, - navController: NavHostController, // navController is not yet used; reserved for navigation after auth completes - viewModel: WebEidViewModel, + navController: NavHostController, + viewModel: WebEidViewModel = hiltViewModel(), + sharedSettingsViewModel: SharedSettingsViewModel = hiltViewModel(), + sharedContainerViewModel: SharedContainerViewModel = hiltViewModel(), + sharedMenuViewModel: SharedMenuViewModel, ) { - val auth = viewModel.authPayload.collectAsState().value + val noAuthLabel = stringResource(id = R.string.web_eid_auth_no_payload) + val activity = LocalActivity.current as Activity + val authPayload = viewModel.authPayload.collectAsState().value + var isWebEidAuthenticating by rememberSaveable { mutableStateOf(false) } + var webEidAuthenticateAction by remember { mutableStateOf<() -> Unit>({}) } + var cancelWebEidAuthenticateAction by remember { mutableStateOf<() -> Unit>({}) } + var isValidToWebEidAuthenticate by remember { mutableStateOf(false) } + var nfcSupported by remember { mutableStateOf(false) } - Surface( - modifier = - modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .semantics { testTagsAsResourceId = true } - .testTag("webEidScreen"), - color = MaterialTheme.colorScheme.background, - ) { - Box(modifier = Modifier.fillMaxSize().padding(16.dp)) { - if (auth != null) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Challenge: ${auth.challenge}") - Text("Login URI: ${auth.loginUri}") - Text("Get Signing Cert: ${auth.getSigningCertificate}") - } + val isSettingsMenuBottomSheetVisible = rememberSaveable { mutableStateOf(false) } + val snackBarHostState = remember { SnackbarHostState() } + val snackBarScope = rememberCoroutineScope() + val messages by SnackBarManager.messages.collectAsState(emptyList()) + + LaunchedEffect(messages) { + messages.forEach { message -> + snackBarScope.launch { + snackBarHostState.showSnackbar(message) + } + SnackBarManager.removeMessage(message) + } + } + + Scaffold( + snackbarHost = { + SnackbarHost( + modifier = modifier.padding(vertical = SPadding), + hostState = snackBarHostState, + ) + }, + topBar = { + TopBar( + modifier = modifier, + sharedMenuViewModel = sharedMenuViewModel, + title = null, + leftIconContentDescription = + if (isWebEidAuthenticating) { + R.string.signing_cancel + } else { + R.string.back + }, + onLeftButtonClick = { + if (isWebEidAuthenticating) { + cancelWebEidAuthenticateAction() + isWebEidAuthenticating = false + } else { + navController.navigateUp() + } + }, + onRightSecondaryButtonClick = { + isSettingsMenuBottomSheetVisible.value = true + }, + ) + }, + ) { paddingValues -> + SettingsMenuBottomSheet( + navController = navController, + isBottomSheetVisible = isSettingsMenuBottomSheetVisible, + ) + + Column( + modifier = + modifier + .fillMaxSize() + .padding(paddingValues) + .padding(SPadding) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(MSPadding), + ) { + Text( + text = stringResource(R.string.web_eid_auth_title), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.semantics { heading() }, + ) + if (authPayload != null) { + NFCView( + activity = activity, + identityAction = IdentityAction.AUTH, + isSigning = false, + isDecrypting = false, + isWebEidAuthenticating = isWebEidAuthenticating, + onError = { + isWebEidAuthenticating = false + cancelWebEidAuthenticateAction() + }, + onSuccess = { + isWebEidAuthenticating = false + navController.navigateUp() + }, + sharedSettingsViewModel = sharedSettingsViewModel, + sharedContainerViewModel = sharedContainerViewModel, + isSupported = { supported -> + nfcSupported = supported + }, + isValidToWebEidAuthenticate = { isValid -> + isValidToWebEidAuthenticate = isValid + }, + authenticateWebEidAction = { action -> + webEidAuthenticateAction = action + }, + cancelWebEidAuthenticateAction = { action -> + cancelWebEidAuthenticateAction = action + }, + isValidToSign = {}, + isValidToDecrypt = {}, + isAuthenticated = { _, _ -> }, + webEidViewModel = viewModel, + ) } else { - Text("No auth payload received.") + Text(noAuthLabel) + } + + if (!isWebEidAuthenticating && nfcSupported) { + Button( + onClick = { + isWebEidAuthenticating = true + webEidAuthenticateAction() + }, + enabled = isValidToWebEidAuthenticate, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.web_eid_authenticate), + color = MaterialTheme.colorScheme.surface, + ) + } } } } @@ -64,7 +198,9 @@ fun WebEidScreenPreview() { RIADigiDocTheme { WebEidScreen( navController = rememberNavController(), - viewModel = hiltViewModel(), + sharedMenuViewModel = hiltViewModel(), + sharedSettingsViewModel = hiltViewModel(), + sharedContainerViewModel = hiltViewModel(), ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt index 609d3445..95fa7b27 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt @@ -120,6 +120,7 @@ import ee.ria.DigiDoc.utils.extensions.notAccessible import ee.ria.DigiDoc.utils.pin.PinCodeUtil.shouldShowPINCodeError import ee.ria.DigiDoc.utils.snackbar.SnackBarManager.showMessage import ee.ria.DigiDoc.viewmodel.NFCViewModel +import ee.ria.DigiDoc.viewmodel.WebEidViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedContainerViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedSettingsViewModel import kotlinx.coroutines.Dispatchers.IO @@ -138,7 +139,8 @@ fun NFCView( identityAction: IdentityAction, isSigning: Boolean = false, isDecrypting: Boolean = false, - isAuthenticating: Boolean, + isAuthenticating: Boolean = false, + isWebEidAuthenticating: Boolean = false, onError: () -> Unit = {}, onSuccess: () -> Unit = {}, isAddingRoleAndAddress: Boolean = false, @@ -149,13 +151,17 @@ fun NFCView( isSupported: (Boolean) -> Unit = {}, isValidToSign: (Boolean) -> Unit = {}, isValidToDecrypt: (Boolean) -> Unit = {}, + isValidToWebEidAuthenticate: (Boolean) -> Unit = {}, showPinField: Boolean = true, - isValidToAuthenticate: (Boolean) -> Unit, + isValidToAuthenticate: (Boolean) -> Unit = {}, signAction: (() -> Unit) -> Unit = {}, decryptAction: (() -> Unit) -> Unit = {}, cancelAction: (() -> Unit) -> Unit = {}, cancelDecryptAction: (() -> Unit) -> Unit = {}, + authenticateWebEidAction: (() -> Unit) -> Unit = {}, + cancelWebEidAuthenticateAction: (() -> Unit) -> Unit = {}, isAuthenticated: (Boolean, IdCardData) -> Unit, + webEidViewModel: WebEidViewModel? = null, ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -226,6 +232,10 @@ fun NFCView( CodeType.PIN1 } + val webEidAuth = webEidViewModel?.authPayload?.collectAsState()?.value + val originString = webEidAuth?.origin ?: "" + val challengeString = webEidAuth?.challenge ?: "" + BackHandler { nfcViewModel.handleBackButton() if (isSigning || isDecrypting || isAuthenticating) { @@ -314,6 +324,16 @@ fun NFCView( } } + LaunchedEffect(nfcViewModel.webEidAuthResult) { + nfcViewModel.webEidAuthResult.asFlow().collect { result -> + result?.let { (authCert, signature) -> + webEidViewModel?.handleWebEidAuthResult(authCert, signature, activity) + nfcViewModel.resetWebEidAuthResult() + onSuccess() + } + } + } + LaunchedEffect(nfcViewModel.dialogError) { pinCode.value.fill(0) nfcViewModel.dialogError @@ -446,7 +466,7 @@ fun NFCView( ) { if (isAddingRoleAndAddress) { RoleDataView(modifier, sharedSettingsViewModel) - } else if (isSigning || isAuthenticating || isDecrypting) { + } else if (isSigning || isWebEidAuthenticating || isAuthenticating || isDecrypting) { NFCSignatureUpdateContainer( nfcViewModel = nfcViewModel, onError = onError, @@ -504,6 +524,7 @@ fun NFCView( LaunchedEffect(isValid) { isValidToSign(isValid) isValidToDecrypt(isValid) + isValidToWebEidAuthenticate(isValid) } LaunchedEffect(Unit, rememberMe) { @@ -564,6 +585,19 @@ fun NFCView( ) } } + authenticateWebEidAction { + saveFormParams() + scope.launch(IO) { + nfcViewModel.performNFCWebEidAuthWorkRequest( + activity = activity, + context = context, + canNumber = canNumber.text, + pin1Code = pinCode.value, + origin = originString, + challenge = challengeString, + ) + } + } cancelAction { nfcViewModel.handleBackButton() scope.launch(IO) { @@ -574,6 +608,10 @@ fun NFCView( nfcViewModel.handleBackButton() nfcViewModel.cancelNFCDecryptWorkRequest() } + cancelWebEidAuthenticateAction { + nfcViewModel.handleBackButton() + nfcViewModel.cancelWebEidAuthWorkRequest() + } } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt index e41b2fa1..d3e4e14a 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt @@ -102,6 +102,8 @@ class NFCViewModel val userData: LiveData = _userData private val _dialogError = MutableLiveData(0) val dialogError: LiveData = _dialogError + private val _webEidAuthResult = MutableLiveData?>() + val webEidAuthResult: LiveData?> = _webEidAuthResult private val dialogMessages: ImmutableMap = ImmutableMap @@ -138,6 +140,10 @@ class NFCViewModel _shouldResetPIN.postValue(false) } + fun resetWebEidAuthResult() { + _webEidAuthResult.postValue(null) + } + fun shouldShowCANNumberError(canNumber: String?): Boolean = ( !canNumber.isNullOrEmpty() && @@ -197,6 +203,10 @@ class NFCViewModel nfcSmartCardReaderManager.disableNfcReaderMode() } + fun cancelWebEidAuthWorkRequest() { + nfcSmartCardReaderManager.disableNfcReaderMode() + } + suspend fun checkNFCStatus(nfcStatus: NfcStatus) { withContext(Main) { _nfcStatus.postValue(nfcStatus) @@ -588,6 +598,146 @@ class NFCViewModel ) } + suspend fun performNFCWebEidAuthWorkRequest( + activity: Activity, + context: Context, + canNumber: String, + pin1Code: ByteArray, + origin: String, + challenge: String, + ) { + val pinType = context.getString(R.string.signature_id_card_pin1) + activity.requestedOrientation = activity.resources.configuration.orientation + resetValues() + + withContext(Main) { + _message.postValue(R.string.signature_update_nfc_hold) + } + + checkNFCStatus( + nfcSmartCardReaderManager.startDiscovery(activity) { nfcReader, exc -> + if ((nfcReader != null) && (exc == null)) { + try { + CoroutineScope(Main).launch { + _message.postValue(R.string.signature_update_nfc_detected) + } + + val card = TokenWithPace.create(nfcReader) + card.tunnel(canNumber) + + val (authCert, signatureArray) = + idCardService.authenticate( + token = card, + pin1 = pin1Code, + origin = origin, + challenge = challenge, + ) + + if (pin1Code.isNotEmpty()) { + Arrays.fill(pin1Code, 0.toByte()) + } + debugLog(logTag, "Auth certificate: " + Base64.getEncoder().encodeToString(authCert)) + debugLog(logTag, "Auth signature: " + Hex.toHexString(signatureArray)) + + CoroutineScope(Main).launch { + _shouldResetPIN.postValue(true) + _webEidAuthResult.postValue(Pair(authCert, signatureArray)) + } + } catch (ex: SmartCardReaderException) { + _decryptStatus.postValue(false) + + if (ex.message?.contains("TagLostException") == true) { + _errorState.postValue(Triple(R.string.signature_update_nfc_tag_lost, null, null)) + } else if (ex.message?.contains("PIN1 verification failed") == true && + ex.message?.contains("Retries left: 2") == true + ) { + _shouldResetPIN.postValue(true) + _errorState.postValue( + Triple( + R.string.id_card_sign_pin_invalid, + pinType, + 2, + ), + ) + } else if (ex.message?.contains("PIN1 verification failed") == true && + ex.message?.contains("Retries left: 1") == true + ) { + _shouldResetPIN.postValue(true) + _errorState.postValue( + Triple( + R.string.id_card_sign_pin_invalid_final, + pinType, + null, + ), + ) + } else if (ex.message?.contains("PIN1 verification failed") == true && + ex.message?.contains("Retries left: 0") == true + ) { + _shouldResetPIN.postValue(true) + _errorState.postValue( + Triple( + R.string.id_card_sign_pin_locked, + pinType, + null, + ), + ) + } else if (ex is ApduResponseException) { + _errorState.postValue( + Triple(R.string.signature_update_nfc_technical_error, null, null), + ) + } else if (ex is PaceTunnelException) { + _errorState.postValue( + Triple(R.string.signature_update_nfc_wrong_can, null, null), + ) + } else { + showTechnicalError(ex) + } + + errorLog(logTag, "Exception: " + ex.message, ex) + } catch (ex: Exception) { + _decryptStatus.postValue(false) + _shouldResetPIN.postValue(true) + + val message = ex.message ?: "" + + when { + message.contains("Failed to connect") || + message.contains("Failed to create connection with host") -> + showNetworkError(ex) + + message.contains( + "Failed to create proxy connection with host", + ) -> showProxyError(ex) + + message.contains("Too Many Requests") -> + setErrorState( + SessionStatusResponseProcessStatus.TOO_MANY_REQUESTS, + ) + + message.contains("OCSP response not in valid time slot") -> + setErrorState( + SessionStatusResponseProcessStatus.OCSP_INVALID_TIME_SLOT, + ) + message.contains("No lock found with certificate key") -> + showNoLockFoundError(ex) + + else -> showTechnicalError(ex) + } + + errorLog(logTag, "Exception: " + ex.message, ex) + } finally { + if (pin1Code.isNotEmpty()) { + Arrays.fill(pin1Code, 0.toByte()) + } + nfcSmartCardReaderManager.disableNfcReaderMode() + activity.requestedOrientation = + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + }, + ) + } + fun handleBackButton() { _shouldResetPIN.postValue(true) resetValues() diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt index 2d5859e7..cc1e3a6c 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt @@ -2,51 +2,83 @@ package ee.ria.DigiDoc.viewmodel +import android.app.Activity +import android.content.Intent import android.net.Uri +import android.util.Base64 +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow +import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog +import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog +import ee.ria.DigiDoc.webEid.WebEidAuthService +import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest +import ee.ria.DigiDoc.webEid.domain.model.WebEidSignRequest import kotlinx.coroutines.flow.StateFlow import org.json.JSONObject -import java.util.Base64 import javax.inject.Inject @HiltViewModel class WebEidViewModel -@Inject -constructor() : ViewModel() { - - private val _authPayload = MutableStateFlow(null) - val authPayload: StateFlow = _authPayload - - fun handleAuth(uri: Uri) { - try { - val fragment = uri.fragment ?: return - val decoded = decodeBase64(fragment) - val json = JSONObject(decoded) - - val challenge = json.getString("challenge") - val loginUri = json.getString("login_uri") - val getSigningCertificate = json.optBoolean("get_signing_certificate", false) - - _authPayload.value = AuthRequest( - challenge = challenge, - loginUri = loginUri, - getSigningCertificate = getSigningCertificate - ) - } catch (e: Exception) { - println("Failed to authenticate: ${e.message}") - _authPayload.value = null + @Inject + constructor( + private val authService: WebEidAuthService, + ) : ViewModel() { + val authPayload: StateFlow = authService.authRequest + val signPayload: StateFlow = authService.signRequest + val errorState: StateFlow = authService.errorState + val redirectUri: StateFlow = authService.redirectUri + + fun handleAuth(uri: Uri) { + authService.parseAuthUri(uri) } - } - private fun decodeBase64(encoded: String): String { - return String(Base64.getDecoder().decode(encoded)) - } + fun handleSign(uri: Uri) { + authService.parseSignUri(uri) + } + + fun reset() { + authService.resetValues() + } + + fun handleWebEidAuthResult( + cert: ByteArray, + signature: ByteArray, + activity: Activity, + ) { + val challenge = authPayload.value?.challenge + val loginUri = authPayload.value?.loginUri - data class AuthRequest( - val challenge: String, - val loginUri: String, - val getSigningCertificate: Boolean = false - ) -} \ No newline at end of file + if (challenge.isNullOrBlank()) { + errorLog("WebEidViewModel", "Missing challenge in auth payload") + return + } + + if (loginUri.isNullOrBlank()) { + errorLog("WebEidViewModel", "Missing login_uri in auth payload") + return + } + + val token = authService.buildAuthToken(cert, signature, challenge) + + try { + val payload = JSONObject().put("auth-token", token) + val encoded = + Base64.encodeToString( + payload.toString().toByteArray(Charsets.UTF_8), + Base64.NO_WRAP, + ) + val browserUri = "$loginUri#$encoded" + + debugLog("WebEidViewModel", "Opening browser with loginUri: $browserUri") + val intent = + Intent(Intent.ACTION_VIEW, browserUri.toUri()).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + activity.startActivity(intent) + activity.finish() + } catch (e: Exception) { + errorLog("WebEidViewModel", "Failed to open browser with token", e) + } + } + } diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 6007af78..78a6a3ab 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -135,4 +135,9 @@ Fingerprints SHA-256 SHA-1 + + + No auth payload received. + Authenticate + Authenticate with ID-card \ No newline at end of file diff --git a/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardService.kt b/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardService.kt index fa46518a..99c15fe8 100644 --- a/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardService.kt +++ b/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardService.kt @@ -56,4 +56,12 @@ interface IdCardService { currentPuk: ByteArray, newPin: ByteArray, ): IdCardData + + @Throws(Exception::class) + fun authenticate( + token: Token, + pin1: ByteArray, + origin: String, + challenge: String, + ): Pair } diff --git a/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImpl.kt b/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImpl.kt index ee1153a1..f7c9913c 100644 --- a/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImpl.kt +++ b/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImpl.kt @@ -41,6 +41,7 @@ import ee.ria.libdigidocpp.ExternalSigner import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.withContext +import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton @@ -150,4 +151,48 @@ class IdCardServiceImpl ) return signedContainer } + + @Throws(Exception::class) + override fun authenticate( + token: Token, + pin1: ByteArray, + origin: String, + challenge: String, + ): Pair { + val authCert = token.certificate(CertificateType.AUTHENTICATION) + + val cert = + java.security.cert.CertificateFactory + .getInstance("X.509") + .generateCertificate(authCert.inputStream()) + val publicKey = cert.publicKey + + val hashAlg = + when (publicKey) { + is java.security.interfaces.RSAPublicKey -> + when (publicKey.modulus.bitLength()) { + 2048 -> "SHA-256" + 3072 -> "SHA-384" + 4096 -> "SHA-512" + else -> throw IllegalArgumentException("Unsupported RSA key length") + } + is java.security.interfaces.ECPublicKey -> + when (publicKey.params.curve.field.fieldSize) { + 256 -> "SHA-256" + 384 -> "SHA-384" + 512 -> "SHA-512" + else -> throw IllegalArgumentException("Unsupported EC key length") + } + else -> throw IllegalArgumentException("Unsupported key type") + } + + val md = MessageDigest.getInstance(hashAlg) + val originHash = md.digest(origin.toByteArray(Charsets.UTF_8)) + val challengeHash = md.digest(challenge.toByteArray(Charsets.UTF_8)) + val signedData = originHash + challengeHash + val tbsHash = MessageDigest.getInstance("SHA-384").digest(signedData) + val signature = token.authenticate(pin1, tbsHash) + + return authCert to signature + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 19b9123d..49431513 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,3 +33,4 @@ include(":id-card-lib") include(":commons-lib:test-files") include(":id-card-lib:id-lib") include(":id-card-lib:smart-lib") +include(":web-eid-lib") diff --git a/web-eid-lib/.gitignore b/web-eid-lib/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/web-eid-lib/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/web-eid-lib/build.gradle.kts b/web-eid-lib/build.gradle.kts new file mode 100644 index 00000000..ddb457da --- /dev/null +++ b/web-eid-lib/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.jetbrainsKotlinAndroid) + kotlin("kapt") + id("com.google.dagger.hilt.android") +} + +android { + namespace = "com.example.web_eid_lib" + compileSdk = Integer.parseInt(libs.versions.compileSdkVersion.get()) + + defaultConfig { + minSdk = Integer.parseInt(libs.versions.minSdkVersion.get()) + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + packaging { + resources { + pickFirsts += "META-INF/LICENSE.md" + pickFirsts += "META-INF/LICENSE-notice.md" + pickFirsts += "/META-INF/{AL2.0,LGPL2.1}" + pickFirsts += "META-INF/versions/9/OSGI-INF/MANIFEST.MF" + } + } + + buildTypes { + debug { + enableUnitTestCoverage = true + enableAndroidTestCoverage = true + } + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.gson) + implementation(libs.preferencex) + + implementation(libs.google.dagger.hilt.android) + kapt(libs.google.dagger.hilt.android.compile) + implementation(libs.androidx.hilt) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.arch.core.testing) + androidTestImplementation(libs.kotlinx.coroutines.test) + + implementation(project(":libdigidoc-lib")) + implementation(project(":networking-lib")) + implementation(project(":utils-lib")) + implementation(project(":commons-lib")) + implementation(project(":config-lib")) + + androidTestImplementation(project(":commons-lib:test-files")) +} diff --git a/web-eid-lib/proguard-rules.pro b/web-eid-lib/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/web-eid-lib/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthParserTest.kt b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthParserTest.kt new file mode 100644 index 00000000..b5f50490 --- /dev/null +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthParserTest.kt @@ -0,0 +1,134 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthParser +import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthParserImpl +import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Base64 + +@RunWith(AndroidJUnit4::class) +class WebEidAuthParserTest { + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + private lateinit var context: Context + private lateinit var parser: WebEidAuthParser + private val cert = + "MIIDuzCCAqOgAwIBAgIUBkYXJdruP6EuH/+I4YoXxIQ3WcowDQYJKoZIhvcNAQELBQAw" + + "bTELMAkGA1UEBhMCRUUxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3QxDTALBgNV" + + "BAoMBFRlc3QxDTALBgNVBAsMBFRlc3QxDTALBgNVBAMMBFRlc3QxEzARBgkqhkiG9w0B" + + "CQEWBHRlc3QwHhcNMjQwNjEwMTI1OTA3WhcNMjUwNjEwMTI1OTA3WjBtMQswCQYDVQQG" + + "EwJFRTENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDENMAsGA1UECgwEVGVzdDEN" + + "MAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDETMBEGCSqGSIb3DQEJARYEdGVzdDCC" + + "ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNQx56UkGcNvUrEsdzqhn94nHb3" + + "X8oa1+JUWLHE9KUe2ZiNaIMjMOEuMKtss3tKHHBwLig0by24cwySNozoL156i9a5J8VX" + + "zkuEr0dKlkGm13BnSBVY+gdRB47oh1ZocSewyyJmWetLiOzgRq4xkYLuV/xP+lmum580" + + "MomZcwB06/C42FWIlkPqQF4NFTT1mXjHCzl5uY3OZN9+2KGPa5/QOS9ZI3ixp9TiS8oI" + + "Y7VskIk6tUJcnSF3pN6cI+EkS5zODV3Cs33S2Z3mskC3uBTZQxua75NUxycB5wvg4jbf" + + "GcKOaA9QhHmaloNDwXcw7v9hTwg/xe148mt+D5wABl8CAwEAAaNTMFEwHQYDVR0OBBYE" + + "FCM1tdnw9XYxBNieiNJ8liORKwlpMB8GA1UdIwQYMBaAFCM1tdnw9XYxBNieiNJ8liOR" + + "KwlpMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBALmgdhGrkMLsc/g" + + "n2BsaFx3S7fHaO3MEV0krghH9TMk+M1y0oghAjotm/bGqOmZ4x/Hv08YputTMLTK2qpa" + + "Xtf0Q75V7tOr29jpL10lFALuhNtjRt/Ha5mV4qYGDk+vT8+Rw7SzeVhhSr1pM/MmjN3c" + + "AKDZbI0RINIXarZCb2j963eCfguxXZJbxzW09S6kZ/bDEOwi4PLwE0kln9NqQW6JEBHY" + + "kDeYQonkKm1VrZklb1obq+g1UIJkTOAXQdJDyvfHWyKzKE8cUHGxYUvlxOL/YCyLkUGa" + + "eE/VmJs0niWtKlX4UURG0HAGjZIQ/pJejV+7GzknFMZmuiwJQe4yT4mw=" + + @Before + fun setup() { + context = InstrumentationRegistry.getInstrumentation().targetContext + parser = WebEidAuthParserImpl() + } + + @Test + fun parseAuthUri_httpsOriginIsValid() { + val loginUri = "https://rp.example.com/auth/eid/login" + val uri = android.net.Uri.parse(createAuthUri("abc123", loginUri, true)) + val result: WebEidAuthRequest = parser.parseAuthUri(uri) + + assertEquals("abc123", result.challenge) + assertEquals(loginUri, result.loginUri) + assertEquals(true, result.getSigningCertificate) + assertTrue(result.origin.startsWith("https://rp.example.com")) + } + + @Test + fun parseAuthUri_invalidScheme_throwsException() { + val loginUri = "http://rp.example.com/auth/eid/login" + val uri = android.net.Uri.parse(createAuthUri("abc1234", loginUri, false)) + + val exception = + assertThrows(IllegalArgumentException::class.java) { + parser.parseAuthUri(uri) + } + assertEquals("login_uri must use HTTPS", exception.message) + } + + @Test + fun parseAuthUri_detectsUserInfoPhishing() { + val loginUri = "https://rp.example.com:pass@evil.example.com/auth/eid/login" + val uri = android.net.Uri.parse(createAuthUri("abc1235", loginUri, false)) + + val exception = + assertThrows(IllegalArgumentException::class.java) { + parser.parseAuthUri(uri) + } + assertTrue(exception.message!!.contains("Login URI contains userinfo")) + } + + private fun createAuthUri( + challenge: String, + loginUri: String, + getCert: Boolean, + ): String { + val json = + """ + { + "challenge": "$challenge", + "login_uri": "$loginUri", + "get_signing_certificate": $getCert + } + """.trimIndent() + val encoded = Base64.getEncoder().encodeToString(json.toByteArray()) + return "web-eid://auth#$encoded" + } + + @Test + fun parseAuthUri_invalidBase64_throwsException() { + val uri = android.net.Uri.parse("web-eid://auth#%%%INVALID%%%") + val exception = + assertThrows(IllegalArgumentException::class.java) { + parser.parseAuthUri(uri) + } + assertTrue(exception.message!!.contains("Invalid URI fragment format")) + } + + @Test + fun buildAuthToken_returnsExpectedJsonStructure() { + val certBytes = Base64.getDecoder().decode(cert) + val signature = byteArrayOf(1, 2, 3, 4, 5) + val challenge = "abc123" + + val token = parser.buildAuthToken(certBytes, signature, challenge) + + assertEquals("web-eid:1.1", token.getString("format")) + assertTrue(token.getString("unverifiedCertificate").isNotEmpty()) + assertTrue(token.getString("unverifiedSigningCertificate").isNotEmpty()) + assertEquals(challenge, token.getString("challenge")) + assertTrue(token.getString("signature").isNotEmpty()) + assertTrue(token.has("algorithm")) + assertTrue(token.has("supportedSignatureAlgorithms")) + } +} diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt new file mode 100644 index 00000000..a9d452d3 --- /dev/null +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt @@ -0,0 +1,211 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid + +import android.net.Uri +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthParser +import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthParserImpl +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Base64 + +@RunWith(AndroidJUnit4::class) +class WebEidAuthServiceTest { + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + private lateinit var parser: WebEidAuthParser + private lateinit var service: WebEidAuthService + private val cert = + "MIIDuzCCAqOgAwIBAgIUBkYXJdruP6EuH/+I4YoXxIQ3WcowDQYJKoZIhvcNAQELBQAw" + + "bTELMAkGA1UEBhMCRUUxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3QxDTALBgNV" + + "BAoMBFRlc3QxDTALBgNVBAsMBFRlc3QxDTALBgNVBAMMBFRlc3QxEzARBgkqhkiG9w0B" + + "CQEWBHRlc3QwHhcNMjQwNjEwMTI1OTA3WhcNMjUwNjEwMTI1OTA3WjBtMQswCQYDVQQG" + + "EwJFRTENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDENMAsGA1UECgwEVGVzdDEN" + + "MAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDETMBEGCSqGSIb3DQEJARYEdGVzdDCC" + + "ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNQx56UkGcNvUrEsdzqhn94nHb3" + + "X8oa1+JUWLHE9KUe2ZiNaIMjMOEuMKtss3tKHHBwLig0by24cwySNozoL156i9a5J8VX" + + "zkuEr0dKlkGm13BnSBVY+gdRB47oh1ZocSewyyJmWetLiOzgRq4xkYLuV/xP+lmum580" + + "MomZcwB06/C42FWIlkPqQF4NFTT1mXjHCzl5uY3OZN9+2KGPa5/QOS9ZI3ixp9TiS8oI" + + "Y7VskIk6tUJcnSF3pN6cI+EkS5zODV3Cs33S2Z3mskC3uBTZQxua75NUxycB5wvg4jbf" + + "GcKOaA9QhHmaloNDwXcw7v9hTwg/xe148mt+D5wABl8CAwEAAaNTMFEwHQYDVR0OBBYE" + + "FCM1tdnw9XYxBNieiNJ8liORKwlpMB8GA1UdIwQYMBaAFCM1tdnw9XYxBNieiNJ8liOR" + + "KwlpMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBALmgdhGrkMLsc/g" + + "n2BsaFx3S7fHaO3MEV0krghH9TMk+M1y0oghAjotm/bGqOmZ4x/Hv08YputTMLTK2qpa" + + "Xtf0Q75V7tOr29jpL10lFALuhNtjRt/Ha5mV4qYGDk+vT8+Rw7SzeVhhSr1pM/MmjN3c" + + "AKDZbI0RINIXarZCb2j963eCfguxXZJbxzW09S6kZ/bDEOwi4PLwE0kln9NqQW6JEBHY" + + "kDeYQonkKm1VrZklb1obq+g1UIJkTOAXQdJDyvfHWyKzKE8cUHGxYUvlxOL/YCyLkUGa" + + "eE/VmJs0niWtKlX4UURG0HAGjZIQ/pJejV+7GzknFMZmuiwJQe4yT4mw=" + + @Before + fun setup() { + parser = WebEidAuthParserImpl() + service = WebEidAuthServiceImpl(parser) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun parseAuthUri_validUri_updatesAuthRequest() = + runTest { + val uri = + createAuthUri( + challenge = "abc123", + loginUri = "https://rp.example.com/auth/eid/login", + getCert = true, + ) + + service.parseAuthUri(uri) + + val auth = service.authRequest.value + assertEquals("abc123", auth?.challenge) + assertEquals("https://rp.example.com/auth/eid/login", auth?.loginUri) + assertEquals(true, auth?.getSigningCertificate) + assertEquals("https://rp.example.com", auth?.origin) + assertNull(service.errorState.value) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun parseSignUri_validUri_updatesSignRequest() = + runTest { + val uri = + createSignUri( + responseUri = "https://rp.example.com/sign/ok", + signCert = "CERTDATA", + hash = "abcd1234", + hashFunc = "SHA-256", + ) + + service.parseSignUri(uri) + + val sign = service.signRequest.value + assertEquals("https://rp.example.com/sign/ok", sign?.responseUri) + assertEquals("CERTDATA", sign?.signCertificate) + assertEquals("abcd1234", sign?.hash) + assertEquals("SHA-256", sign?.hashFunction) + assertNull(service.errorState.value) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun resetValues_clearsAllState() = + runTest { + val uri = createAuthUri("abc123", "https://rp.example.com", false) + service.parseAuthUri(uri) + + service.resetValues() + + assertNull(service.authRequest.value) + assertNull(service.signRequest.value) + assertNull(service.errorState.value) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun parseAuthUri_invalidUri_setsErrorState() = + runTest { + val badUri = Uri.parse("web-eid://auth#not-base64!!!") + + service.parseAuthUri(badUri) + + assertNull(service.authRequest.value) + assertNull(service.signRequest.value) + assert(service.errorState.value != null) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun parseAuthUri_validUri_setsRedirectUriToSuccess() = + runTest { + val uri = + createAuthUri( + challenge = "abc123", + loginUri = "https://rp.example.com/auth/eid/login", + getCert = false, + ) + + service.parseAuthUri(uri) + + val redirect = service.redirectUri.value + assert(redirect != null) + val decodedFragment = String(Base64.getUrlDecoder().decode(Uri.parse(redirect).fragment)) + assert(decodedFragment.contains("mock-web-eid-auth-token")) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun parseAuthUri_invalidUri_setsRedirectUriToError() = + runTest { + val badUri = Uri.parse("web-eid://auth#not-base64!!!") + + service.parseAuthUri(badUri) + + val redirect = service.redirectUri.value + assert(redirect != null) + val decodedFragment = String(Base64.getUrlDecoder().decode(Uri.parse(redirect).fragment)) + assert(decodedFragment.contains("ERR_WEBEID_INVALID_REQUEST")) + } + + @Test + fun buildAuthToken_withValidInputs_returnsValidJson() { + val certBytes = Base64.getDecoder().decode(cert) + val signature = byteArrayOf(1, 2, 3, 4, 5) + val challenge = "abc123" + + val token = service.buildAuthToken(certBytes, signature, challenge) + + assertEquals("web-eid:1.1", token.getString("format")) + assertEquals(challenge, token.getString("challenge")) + assert(token.getString("unverifiedCertificate").isNotBlank()) + assert(token.getString("unverifiedSigningCertificate").isNotBlank()) + assert(token.getString("signature").isNotBlank()) + assert(token.has("algorithm")) + assert(token.has("supportedSignatureAlgorithms")) + } + + @Suppress("SameParameterValue") + private fun createAuthUri( + challenge: String, + loginUri: String, + getCert: Boolean, + ): Uri { + val json = + """ + { + "challenge": "$challenge", + "login_uri": "$loginUri", + "get_signing_certificate": $getCert + } + """.trimIndent() + val encoded = Base64.getEncoder().encodeToString(json.toByteArray()) + return Uri.parse("web-eid://auth#$encoded") + } + + @Suppress("SameParameterValue") + private fun createSignUri( + responseUri: String, + signCert: String, + hash: String, + hashFunc: String, + ): Uri { + val json = + """ + { + "response_uri": "$responseUri", + "sign_certificate": "$signCert", + "hash": "$hash", + "hash_function": "$hashFunc" + } + """.trimIndent() + val encoded = Base64.getEncoder().encodeToString(json.toByteArray()) + return Uri.parse("web-eid://sign#$encoded") + } +} diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt new file mode 100644 index 00000000..34f7d83e --- /dev/null +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt @@ -0,0 +1,68 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.utils + +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Base64 + +@RunWith(AndroidJUnit4::class) +class WebEidResponseUtilTest { + @Test + fun createErrorRedirect_withCustomCodeAndMessage_encodesCorrectly() { + val loginUri = "https://rp.example.com/auth/eid/login" + val code = "ERR_CUSTOM" + val message = "Custom error message" + + val resultUri = WebEidResponseUtil.createErrorRedirect(loginUri, code, message) + + val fragment = Uri.parse(resultUri).fragment + val decodedJson = String(Base64.getUrlDecoder().decode(fragment)) + val json = JSONObject(decodedJson) + + assertEquals(code, json.getString("code")) + assertEquals(message, json.getString("message")) + } + + @Test + fun createErrorRedirect_withDefaults_usesUnknownErrorValues() { + val loginUri = "https://rp.example.com/auth/eid/login" + + val resultUri = WebEidResponseUtil.createErrorRedirect(loginUri) + + val fragment = Uri.parse(resultUri).fragment + val decodedJson = String(Base64.getUrlDecoder().decode(fragment)) + val json = JSONObject(decodedJson) + + assertEquals(WebEidErrorCodes.UNKNOWN, json.getString("code")) + assertEquals(WebEidErrorCodes.UNKNOWN_MESSAGE, json.getString("message")) + } + + @Test + fun createSuccessRedirect_containsMockSuccessPayload() { + val loginUri = "https://rp.example.com/auth/eid/login" + + val resultUri = WebEidResponseUtil.createSuccessRedirect(loginUri) + + val fragment = Uri.parse(resultUri).fragment + val decodedJson = String(Base64.getUrlDecoder().decode(fragment)) + val json = JSONObject(decodedJson) + + assertEquals("mock-web-eid-auth-token", json.getString("web_eid_auth_token")) + assertEquals("mock-attestation", json.getString("eid_instance_attestation")) + assertEquals("mock-attestation-proof", json.getString("eid_instance_attestation_proof")) + } + + @Test + fun appendedFragment_keepsBaseUriIntact() { + val loginUri = "https://rp.example.com/auth/eid/login" + val resultUri = WebEidResponseUtil.createSuccessRedirect(loginUri) + + assertTrue(resultUri.startsWith(loginUri)) + } +} diff --git a/web-eid-lib/src/main/AndroidManifest.xml b/web-eid-lib/src/main/AndroidManifest.xml new file mode 100644 index 00000000..44008a43 --- /dev/null +++ b/web-eid-lib/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthService.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthService.kt new file mode 100644 index 00000000..1e365506 --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthService.kt @@ -0,0 +1,28 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid + +import android.net.Uri +import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest +import ee.ria.DigiDoc.webEid.domain.model.WebEidSignRequest +import kotlinx.coroutines.flow.StateFlow +import org.json.JSONObject + +interface WebEidAuthService { + val authRequest: StateFlow + val signRequest: StateFlow + val errorState: StateFlow + val redirectUri: StateFlow + + fun resetValues() + + fun parseAuthUri(uri: Uri) + + fun parseSignUri(uri: Uri) + + fun buildAuthToken( + certBytes: ByteArray, + signature: ByteArray, + challenge: String, + ): JSONObject +} diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt new file mode 100644 index 00000000..79688b5f --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt @@ -0,0 +1,77 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid + +import android.net.Uri +import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog +import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthParser +import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest +import ee.ria.DigiDoc.webEid.domain.model.WebEidSignRequest +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONObject +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WebEidAuthServiceImpl + @Inject + constructor( + private val parserImpl: WebEidAuthParser, + ) : WebEidAuthService { + private val logTag = javaClass.simpleName + + private val _authRequest = MutableStateFlow(null) + override val authRequest: StateFlow = _authRequest.asStateFlow() + + private val _signRequest = MutableStateFlow(null) + override val signRequest: StateFlow = _signRequest.asStateFlow() + + private val _errorState = MutableStateFlow(null) + override val errorState: StateFlow = _errorState.asStateFlow() + + private val _redirectUri = MutableStateFlow(null) + override val redirectUri: StateFlow = _redirectUri.asStateFlow() + + override fun resetValues() { + _authRequest.value = null + _signRequest.value = null + _errorState.value = null + _redirectUri.value = null + } + + override fun parseAuthUri(uri: Uri) { + try { + val resultRedirect = parserImpl.handleAuthFlow(uri) + _redirectUri.value = resultRedirect + _authRequest.value = parserImpl.parseAuthUri(uri) + } catch (e: IllegalArgumentException) { + errorLog(logTag, "Validation failed in parseAuthUri", e) + _errorState.value = e.message + } catch (e: Exception) { + errorLog(logTag, "Failed to parse Web eID auth URI", e) + _errorState.value = e.message + } + } + + override fun parseSignUri(uri: Uri) { + try { + _signRequest.value = parserImpl.parseSignUri(uri) + } catch (e: IllegalArgumentException) { + errorLog(logTag, "Validation failed in parseSignUri", e) + _errorState.value = e.message + } catch (e: Exception) { + errorLog(logTag, "Failed to parse Web eID sign URI", e) + _errorState.value = e.message + } + } + + override fun buildAuthToken( + certBytes: ByteArray, + signature: ByteArray, + challenge: String, + ): JSONObject { + return parserImpl.buildAuthToken(certBytes, signature, challenge) + } + } diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/di/AppModules.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/di/AppModules.kt new file mode 100644 index 00000000..2573a012 --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/di/AppModules.kt @@ -0,0 +1,22 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import ee.ria.DigiDoc.webEid.WebEidAuthService +import ee.ria.DigiDoc.webEid.WebEidAuthServiceImpl +import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthParser +import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthParserImpl + +@Module +@InstallIn(SingletonComponent::class) +class AppModules { + @Provides + fun provideWebEidAuthParser(): WebEidAuthParser = WebEidAuthParserImpl() + + @Provides + fun provideWebEidAuthService(parser: WebEidAuthParser): WebEidAuthService = WebEidAuthServiceImpl(parser) +} diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthParser.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthParser.kt new file mode 100644 index 00000000..b8375a36 --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthParser.kt @@ -0,0 +1,20 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.domain.model + +import android.net.Uri +import org.json.JSONObject + +interface WebEidAuthParser { + fun parseAuthUri(uri: Uri): WebEidAuthRequest + + fun parseSignUri(uri: Uri): WebEidSignRequest + + fun handleAuthFlow(uri: Uri): String + + fun buildAuthToken( + certBytes: ByteArray, + signature: ByteArray, + challenge: String, + ): JSONObject +} diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthParserImpl.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthParserImpl.kt new file mode 100644 index 00000000..c2755778 --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthParserImpl.kt @@ -0,0 +1,205 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.domain.model + +import android.net.Uri +import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog +import ee.ria.DigiDoc.webEid.utils.WebEidError +import ee.ria.DigiDoc.webEid.utils.WebEidErrorCodes +import ee.ria.DigiDoc.webEid.utils.WebEidResponseUtil +import org.json.JSONArray +import org.json.JSONObject +import java.net.URI +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.Base64 +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WebEidAuthParserImpl + @Inject + constructor() : WebEidAuthParser { + private val logTag = javaClass.simpleName + + override fun handleAuthFlow(uri: Uri): String { + return try { + val request = parseAuthUri(uri) + WebEidResponseUtil.createSuccessRedirect(request.loginUri) + } catch (e: Exception) { + errorLog(logTag, "Error in auth flow", e) + val err = mapExceptionToError(e) + WebEidResponseUtil.createErrorRedirect(extractLoginUriSafe(uri), err.code, err.message) + } + } + + override fun parseAuthUri(uri: Uri): WebEidAuthRequest { + val json = decodeUriFragment(uri) + + val challenge = json.getString("challenge") + val loginUriEncoded = json.getString("login_uri") + val getSigningCertificate = json.optBoolean("get_signing_certificate", false) + + val loginUri = java.net.URLDecoder.decode(loginUriEncoded, java.nio.charset.StandardCharsets.UTF_8.name()) + + validateHttpsScheme(loginUri) + val origin = parseOriginFromLoginUri(loginUri) + validateOriginCorrectness(loginUri, origin) + + return WebEidAuthRequest( + challenge = challenge, + loginUri = loginUri, + getSigningCertificate = getSigningCertificate, + origin = origin, + ) + } + + override fun parseSignUri(uri: Uri): WebEidSignRequest { + val json = decodeUriFragment(uri) + return WebEidSignRequest( + responseUri = json.getString("response_uri"), + signCertificate = json.getString("sign_certificate"), + hash = json.getString("hash"), + hashFunction = json.getString("hash_function"), + ) + } + + private fun decodeUriFragment(uri: Uri): JSONObject { + try { + val fragment = uri.fragment ?: throw IllegalArgumentException("No fragment in URI") + val decoded = String(Base64.getDecoder().decode(fragment)) + return JSONObject(decoded) + } catch (e: Exception) { + errorLog(logTag, "Failed to decode or parse URI fragment: ${uri.fragment}", e) + throw IllegalArgumentException("Invalid URI fragment format", e) + } + } + + private fun validateHttpsScheme(loginUri: String) { + try { + val parsed = URI(loginUri) + if (!parsed.scheme.equals("https", ignoreCase = true)) { + errorLog(logTag, "Invalid scheme in login_uri: $loginUri — must be HTTPS") + throw IllegalArgumentException("login_uri must use HTTPS") + } + } catch (e: IllegalArgumentException) { + throw e + } catch (e: Exception) { + errorLog(logTag, "Invalid login_uri format: $loginUri", e) + throw IllegalArgumentException("Invalid login_uri format", e) + } + } + + private fun validateOriginCorrectness( + loginUri: String, + origin: String, + ) { + try { + val parsedLogin = URI(loginUri) + val expected = URI(origin) + + if (!parsedLogin.host.equals(expected.host, ignoreCase = true) || + parsedLogin.port != expected.port + ) { + errorLog( + logTag, + "Origin mismatch: expected $origin but login_uri points to host ${parsedLogin.host}", + ) + throw IllegalArgumentException("Origin mismatch: expected $origin") + } + + if (parsedLogin.userInfo != null) { + errorLog( + logTag, + "Login URI contains userinfo (possible phishing attempt): $loginUri", + ) + throw IllegalArgumentException("Login URI contains userinfo (possible phishing attempt)") + } + } catch (e: IllegalArgumentException) { + throw e + } catch (e: Exception) { + errorLog(logTag, "Failed to validate origin correctness for $loginUri", e) + throw IllegalArgumentException("Invalid origin in login_uri", e) + } + } + + private fun parseOriginFromLoginUri(loginUri: String): String { + return try { + val parsed = URI(loginUri) + if (parsed.scheme.isNullOrBlank() || parsed.host.isNullOrBlank()) { + errorLog(logTag, "Invalid login_uri: missing scheme or host — $loginUri") + return "" + } + val portPart = if (parsed.port != -1) ":${parsed.port}" else "" + "${parsed.scheme}://${parsed.host}$portPart" + } catch (e: Exception) { + errorLog(logTag, "Failed to parse origin from login_uri: $loginUri", e) + "" + } + } + + private fun extractLoginUriSafe(uri: Uri): String { + return try { + val json = decodeUriFragment(uri) + json.optString("login_uri", "") + } catch (e: Exception) { + errorLog(logTag, "Failed to safely extract login_uri from URI: $uri", e) + "" + } + } + + private fun mapExceptionToError(e: Exception): WebEidError { + return when (e) { + is IllegalArgumentException -> + WebEidError( + code = WebEidErrorCodes.INVALID_REQUEST, + message = e.message ?: WebEidErrorCodes.INVALID_REQUEST_MESSAGE, + ) + else -> + WebEidError( + code = WebEidErrorCodes.UNKNOWN, + message = WebEidErrorCodes.UNKNOWN_MESSAGE, + ) + } + } + + override fun buildAuthToken( + certBytes: ByteArray, + signature: ByteArray, + challenge: String, + ): JSONObject { + val cert = + CertificateFactory.getInstance("X.509") + .generateCertificate(certBytes.inputStream()) as X509Certificate + + val publicKey = cert.publicKey + val algorithm = + when (publicKey) { + is java.security.interfaces.RSAPublicKey -> "RS256" + is java.security.interfaces.ECPublicKey -> "ES384" + else -> "RS256" + } + + val supportedSignatureAlgorithms = + JSONArray().apply { + put( + JSONObject().apply { + put("cryptoAlgorithm", "RSA") + put("hashFunction", "SHA-256") + put("paddingScheme", "PKCS1.5") + }, + ) + } + + return JSONObject().apply { + put("algorithm", algorithm) + put("unverifiedCertificate", Base64.getEncoder().encodeToString(certBytes)) + put("unverifiedSigningCertificate", Base64.getEncoder().encodeToString(certBytes)) + put("supportedSignatureAlgorithms", supportedSignatureAlgorithms) + put("issuerApp", "https://web-eid.eu/web-eid-mobile-app/releases/v1.0.0") + put("signature", Base64.getEncoder().encodeToString(signature)) + put("format", "web-eid:1.1") + put("challenge", challenge) + } + } + } diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthRequest.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthRequest.kt new file mode 100644 index 00000000..b26bddae --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthRequest.kt @@ -0,0 +1,10 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.domain.model + +data class WebEidAuthRequest( + val challenge: String, + val loginUri: String, + val getSigningCertificate: Boolean, + val origin: String, +) diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt new file mode 100644 index 00000000..b5f3431d --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt @@ -0,0 +1,10 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.domain.model + +data class WebEidSignRequest( + val responseUri: String, + val signCertificate: String, + val hash: String, + val hashFunction: String, +) diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidErrorCodes.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidErrorCodes.kt new file mode 100644 index 00000000..5a3ca2b1 --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidErrorCodes.kt @@ -0,0 +1,11 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.utils + +object WebEidErrorCodes { + const val INVALID_REQUEST = "ERR_WEBEID_INVALID_REQUEST" + const val UNKNOWN = "ERR_WEBEID_UNKNOWN" + + const val INVALID_REQUEST_MESSAGE = "Invalid authentication request" + const val UNKNOWN_MESSAGE = "Unexpected error occurred" +} diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt new file mode 100644 index 00000000..39b859f2 --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt @@ -0,0 +1,55 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.utils + +import androidx.core.net.toUri +import org.json.JSONObject +import java.util.Base64 + +data class WebEidError(val code: String, val message: String) + +object WebEidResponseUtil { + fun createErrorRedirect( + loginUri: String, + code: String = WebEidErrorCodes.UNKNOWN, + message: String = WebEidErrorCodes.UNKNOWN_MESSAGE, + ): String { + val errorJson = + JSONObject() + .put("code", code) + .put("message", message) + .toString() + + val encoded = base64UrlEncode(errorJson) + return appendFragment(loginUri, encoded) + } + + fun createSuccessRedirect(loginUri: String): String { + val successJson = + JSONObject() + .put("web_eid_auth_token", "mock-web-eid-auth-token") + .put("eid_instance_attestation", "mock-attestation") + .put("eid_instance_attestation_proof", "mock-attestation-proof") + .toString() + + val encoded = base64UrlEncode(successJson) + return appendFragment(loginUri, encoded) + } + + private fun base64UrlEncode(input: String): String { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(input.toByteArray(Charsets.UTF_8)) + } + + private fun appendFragment( + loginUri: String, + fragment: String, + ): String { + val uri = loginUri.toUri() + return uri.buildUpon() + .fragment(fragment) + .build() + .toString() + } +} From cb2543415e2aa4f860db344b9b3767603a17d5e0 Mon Sep 17 00:00:00 2001 From: SanderKondratjevNortal Date: Fri, 12 Sep 2025 14:26:27 +0300 Subject: [PATCH 02/10] NFC-57 Fix and improve authentication flow --- .../DigiDoc/viewmodel/WebEidViewModelTest.kt | 251 +++++++++++------- .../ee/ria/DigiDoc/fragment/WebEidFragment.kt | 21 +- .../DigiDoc/fragment/screen/WebEidScreen.kt | 167 ++++++++++-- .../ria/DigiDoc/ui/component/shared/TopBar.kt | 45 ++-- .../DigiDoc/ui/component/signing/NFCView.kt | 6 +- .../ee/ria/DigiDoc/viewmodel/NFCViewModel.kt | 11 +- .../ria/DigiDoc/viewmodel/WebEidViewModel.kt | 109 ++++---- app/src/main/res/values-et/strings.xml | 10 + app/src/main/res/values/donottranslate.xml | 5 - app/src/main/res/values/strings.xml | 10 + .../DigiDoc/domain/service/IdCardService.kt | 2 +- .../domain/service/IdCardServiceImpl.kt | 22 +- .../DigiDoc/webEid/WebEidAuthParserTest.kt | 134 ---------- .../DigiDoc/webEid/WebEidAuthServiceTest.kt | 231 +++++----------- .../DigiDoc/webEid/WebEidRequestParserTest.kt | 94 +++++++ .../webEid/utils/WebEidResponseUtilTest.kt | 57 ++-- .../ria/DigiDoc/webEid/WebEidAuthService.kt | 19 +- .../DigiDoc/webEid/WebEidAuthServiceImpl.kt | 120 +++++---- .../ee/ria/DigiDoc/webEid/di/AppModules.kt | 7 +- .../webEid/domain/model/WebEidAuthParser.kt | 20 -- .../domain/model/WebEidAuthParserImpl.kt | 205 -------------- .../webEid/exception/WebEidErrorCode.kt | 8 + .../webEid/exception/WebEidException.kt | 9 + .../DigiDoc/webEid/utils/WebEidErrorCodes.kt | 11 - .../webEid/utils/WebEidRequestParser.kt | 100 +++++++ .../webEid/utils/WebEidResponseUtil.kt | 69 ++--- 26 files changed, 833 insertions(+), 910 deletions(-) delete mode 100644 web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthParserTest.kt create mode 100644 web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt delete mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthParser.kt delete mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthParserImpl.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/exception/WebEidErrorCode.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/exception/WebEidException.kt delete mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidErrorCodes.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt index f37f1523..1b87e251 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt @@ -2,24 +2,27 @@ package ee.ria.DigiDoc.viewmodel -import android.app.Activity import android.net.Uri +import android.util.Base64.URL_SAFE +import android.util.Base64.decode import androidx.arch.core.executor.testing.InstantTaskExecutorRule import ee.ria.DigiDoc.webEid.WebEidAuthService -import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.json.JSONObject +import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock -import org.mockito.Mockito.never -import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import org.mockito.junit.MockitoJUnitRunner -import org.mockito.kotlin.any import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever @RunWith(MockitoJUnitRunner::class) class WebEidViewModelTest { @@ -29,129 +32,177 @@ class WebEidViewModelTest { @Mock private lateinit var authService: WebEidAuthService - @Mock - private lateinit var activity: Activity - private lateinit var viewModel: WebEidViewModel @Before fun setup() { MockitoAnnotations.openMocks(this) - - `when`(authService.authRequest).thenReturn(MutableStateFlow(null)) - `when`(authService.signRequest).thenReturn(MutableStateFlow(null)) - `when`(authService.errorState).thenReturn(MutableStateFlow(null)) - `when`(authService.redirectUri).thenReturn(MutableStateFlow(null)) - viewModel = WebEidViewModel(authService) } @Test - fun handleAuth_callsParseAuthUri() { - val uri = Uri.parse("web-eid-mobile://auth#dummyData") - viewModel.handleAuth(uri) - verify(authService).parseAuthUri(uri) + fun webEidViewModel_handleAuth_parsesAuthUriAndSetsStateFlow() { + runTest { + val uri = + Uri.parse( + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6dHJ1ZX0", + ) + viewModel.handleAuth(uri) + val authRequest = viewModel.authRequest.value + val signRequest = viewModel.signRequest.value + assert(authRequest != null) + assert(signRequest == null) + assertEquals("test-challenge-00000000000000000000000000000", authRequest?.challenge) + assertEquals("https://example.com/response", authRequest?.loginUri) + assertEquals("https://example.com", authRequest?.origin) + assertEquals(true, authRequest?.getSigningCertificate) + } } @Test - fun handleSign_callsParseSignUri() { - val uri = Uri.parse("web-eid-mobile://sign#dummyData") - viewModel.handleSign(uri) - verify(authService).parseSignUri(uri) + fun webEidViewModel_handleAuth_emitErrorResponseEventWhenChallengeMinLength() { + val uri = + Uri.parse( + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwibG9naW5fdXJpIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZXNwb25zZSIsImdldF9zaWduaW5nX2NlcnRpZmljYXRlIjp0cnVlfQ", + ) + webEidViewModel_handleAuth_emitErrorResponseEventWhenInvalidChallenge(uri) } @Test - fun reset_callsResetValues() { - viewModel.reset() - verify(authService).resetValues() + fun webEidViewModel_handleAuth_emitErrorResponseEventWhenChallengeMaxLength() { + val uri = + Uri.parse( + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLCJsb2dpbl91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwiZ2V0X3NpZ25pbmdfY2VydGlmaWNhdGUiOnRydWV9", + ) + webEidViewModel_handleAuth_emitErrorResponseEventWhenInvalidChallenge(uri) } - @Test - fun redirectUri_isExposedFromAuthService() { - val redirectFlow = MutableStateFlow("https://example.com#encodedPayload") - `when`(authService.redirectUri).thenReturn(redirectFlow) - val vm = WebEidViewModel(authService) - assert(vm.redirectUri.value == "https://example.com#encodedPayload") + @OptIn(ExperimentalCoroutinesApi::class) + private fun webEidViewModel_handleAuth_emitErrorResponseEventWhenInvalidChallenge(uri: Uri) { + runTest(UnconfinedTestDispatcher()) { + val deferred = + async { + viewModel.relyingPartyResponseEvents.first() + } + + viewModel.handleAuth(uri) + + val emittedUri = deferred.await() + assert(emittedUri.toString().startsWith("https://example.com/response#")) + assert(emittedUri.fragment != null) + val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) + val jsonPayload = JSONObject(decodedPayload) + assertEquals("ERR_WEBEID_MOBILE_INVALID_REQUEST", jsonPayload.getString("code")) + assertEquals("Invalid challenge length", jsonPayload.getString("message")) + } } @Test - fun redirectUri_updatesWhenServiceUpdates() { - val redirectFlow = MutableStateFlow(null) - `when`(authService.redirectUri).thenReturn(redirectFlow) - val vm = WebEidViewModel(authService) - redirectFlow.value = "https://example.com#updatedPayload" - assert(vm.redirectUri.value == "https://example.com#updatedPayload") + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleAuth_emitErrorResponseEventWhenOriginMaxLength() { + runTest(UnconfinedTestDispatcher()) { + val uri = + Uri.parse( + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS54eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eC5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6dHJ1ZX0", + ) + val deferred = + async { + viewModel.relyingPartyResponseEvents.first() + } + + viewModel.handleAuth(uri) + + val emittedUri = deferred.await() + assert( + emittedUri.toString().startsWith( + "https://example.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.com/response#", + ), + ) + assert(emittedUri.fragment != null) + val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) + val jsonPayload = JSONObject(decodedPayload) + assertEquals("ERR_WEBEID_MOBILE_INVALID_REQUEST", jsonPayload.getString("code")) + assertEquals("Invalid origin length", jsonPayload.getString("message")) + } } @Test - fun handleWebEidAuthResult_callsBuildAuthToken_whenPayloadValid() { - val cert = byteArrayOf(1, 2, 3) - val signature = byteArrayOf(4, 5, 6) - val challenge = "test-challenge" - val loginUri = "https://example.com/login" - val getSigningCertificate = true - val origin = "https://example.com" - - val authRequest = - WebEidAuthRequest( - challenge = challenge, - loginUri = loginUri, - getSigningCertificate = getSigningCertificate, - origin = origin, + fun webEidViewModel_handleSign_parsesSignUriAndSetsStateFlow() { + val uri = + Uri.parse( + "web-eid-mobile://sign#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwic2lnbl9jZXJ0aWZpY2F0ZSI6InNpZ25pbmdfY2VydGlmaWNhdGUiLCJoYXNoIjoiaGFzaCIsImhhc2hfZnVuY3Rpb24iOiJoYXNoX2Z1bmN0aW9uIn0", ) - `when`(authService.authRequest).thenReturn(MutableStateFlow(authRequest)) - - val token = JSONObject().put("mock", "token") - `when`(authService.buildAuthToken(cert, signature, challenge)).thenReturn(token) - - viewModel = WebEidViewModel(authService) - - viewModel.handleWebEidAuthResult(cert, signature, activity) - - verify(authService).buildAuthToken(cert, signature, challenge) - verify(activity).startActivity(any()) - verify(activity).finish() + viewModel.handleSign(uri) + val authRequest = viewModel.authRequest.value + val signRequest = viewModel.signRequest.value + assert(authRequest == null) + assert(signRequest != null) + assertEquals("https://example.com/response", signRequest?.responseUri) + assertEquals("signing_certificate", signRequest?.signCertificate) + assertEquals("hash", signRequest?.hash) + assertEquals("hash_function", signRequest?.hashFunction) } @Test - fun handleWebEidAuthResult_doesNothing_whenChallengeMissing() { - val cert = byteArrayOf(1) - val signature = byteArrayOf(2) - - val authRequest = - WebEidAuthRequest( - challenge = "", - loginUri = "https://example.com", - getSigningCertificate = true, - origin = "https://example.com", - ) - `when`(authService.authRequest).thenReturn(MutableStateFlow(authRequest)) - - viewModel = WebEidViewModel(authService) - viewModel.handleWebEidAuthResult(cert, signature, activity) - - verify(authService, never()).buildAuthToken(any(), any(), any()) - verify(activity, never()).startActivity(any()) + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleWebEidAuthResult_buildsAuthTokenAndEmitsResponseEvent() { + runTest(UnconfinedTestDispatcher()) { + val cert = byteArrayOf(1, 2, 3) + val signingCert = byteArrayOf(9, 9, 9) + val signature = byteArrayOf(4, 5, 6) + val uri = + Uri.parse( + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6dHJ1ZX0", + ) + whenever(authService.buildAuthToken(cert, signingCert, signature)) + .thenReturn(JSONObject().put("format", "web-eid:1.0")) + val deferred = + async { + viewModel.relyingPartyResponseEvents.first() + } + viewModel.handleAuth(uri) + viewModel.handleWebEidAuthResult(cert, signingCert, signature) + + verify(authService).buildAuthToken(cert, signingCert, signature) + val emittedUri = deferred.await() + assert(emittedUri.toString().startsWith("https://example.com/response#")) + assert(emittedUri.fragment != null) + val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) + val jsonPayload = JSONObject(decodedPayload) + val authToken = jsonPayload.getJSONObject("auth-token") + assertEquals("web-eid:1.0", authToken.getString("format")) + } } @Test - fun handleWebEidAuthResult_doesNothing_whenLoginUriMissing() { - val cert = byteArrayOf(1) - val signature = byteArrayOf(2) - - val authRequest = - WebEidAuthRequest( - challenge = "abc", - loginUri = "", - getSigningCertificate = true, - origin = "https://example.com", - ) - `when`(authService.authRequest).thenReturn(MutableStateFlow(authRequest)) - - viewModel = WebEidViewModel(authService) - viewModel.handleWebEidAuthResult(cert, signature, activity) - - verify(authService, never()).buildAuthToken(any(), any(), any()) - verify(activity, never()).startActivity(any()) + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleWebEidAuthResult_emitErrorResponseEventWhenException() { + runTest(UnconfinedTestDispatcher()) { + val cert = byteArrayOf(1, 2, 3) + val signingCert = byteArrayOf(9, 9, 9) + val signature = byteArrayOf(4, 5, 6) + val uri = + Uri.parse( + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6dHJ1ZX0", + ) + whenever(authService.buildAuthToken(cert, signingCert, signature)) + .thenThrow(RuntimeException("Test exception")) + val deferred = + async { + viewModel.relyingPartyResponseEvents.first() + } + viewModel.handleAuth(uri) + + viewModel.handleWebEidAuthResult(cert, signingCert, signature) + + verify(authService).buildAuthToken(cert, signingCert, signature) + val emittedUri = deferred.await() + assert(emittedUri.toString().startsWith("https://example.com/response#")) + assert(emittedUri.fragment != null) + val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) + val jsonPayload = JSONObject(decodedPayload) + assertEquals("ERR_WEBEID_MOBILE_UNKNOWN_ERROR", jsonPayload.getString("code")) + assertEquals("Unexpected error", jsonPayload.getString("message")) + } } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt index 09df40ff..50932f39 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt @@ -2,8 +2,11 @@ package ee.ria.DigiDoc.fragment +import android.app.Activity +import android.content.Intent import android.content.res.Configuration import android.net.Uri +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme @@ -21,7 +24,6 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import ee.ria.DigiDoc.fragment.screen.WebEidScreen import ee.ria.DigiDoc.ui.theme.RIADigiDocTheme -import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog import ee.ria.DigiDoc.viewmodel.WebEidViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedContainerViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedMenuViewModel @@ -38,12 +40,27 @@ fun WebEidFragment( sharedContainerViewModel: SharedContainerViewModel = hiltViewModel(), sharedMenuViewModel: SharedMenuViewModel = hiltViewModel(), ) { + val activity = LocalActivity.current as Activity + + LaunchedEffect(viewModel) { + viewModel.relyingPartyResponseEvents.collect { responseUri -> + val browserIntent = + Intent(Intent.ACTION_VIEW, responseUri).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + activity.startActivity(browserIntent) + activity.finishAndRemoveTask() + } + } + LaunchedEffect(webEidUri) { webEidUri?.let { when (it.host) { "auth" -> viewModel.handleAuth(it) "sign" -> viewModel.handleSign(it) - else -> debugLog("WebEidFragment", "Unknown Web eID URI host: ${it.host}") + else -> { + viewModel.handleUnknown(it) + } } } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt index 94b75f44..e9d06bcf 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt @@ -5,19 +5,30 @@ package ee.ria.DigiDoc.fragment.screen import android.app.Activity import android.content.res.Configuration import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -27,29 +38,44 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.asFlow import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import ee.ria.DigiDoc.R import ee.ria.DigiDoc.domain.model.IdentityAction import ee.ria.DigiDoc.ui.component.menu.SettingsMenuBottomSheet +import ee.ria.DigiDoc.ui.component.shared.DynamicText +import ee.ria.DigiDoc.ui.component.shared.InvisibleElement import ee.ria.DigiDoc.ui.component.shared.TopBar import ee.ria.DigiDoc.ui.component.signing.NFCView import ee.ria.DigiDoc.ui.theme.Dimensions.MSPadding import ee.ria.DigiDoc.ui.theme.Dimensions.SPadding +import ee.ria.DigiDoc.ui.theme.Dimensions.XSPadding import ee.ria.DigiDoc.ui.theme.RIADigiDocTheme +import ee.ria.DigiDoc.ui.theme.buttonRoundCornerShape import ee.ria.DigiDoc.utils.snackbar.SnackBarManager import ee.ria.DigiDoc.viewmodel.WebEidViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedContainerViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedMenuViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedSettingsViewModel +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +@OptIn(ExperimentalMaterial3Api::class) @Composable fun WebEidScreen( modifier: Modifier = Modifier, @@ -61,7 +87,7 @@ fun WebEidScreen( ) { val noAuthLabel = stringResource(id = R.string.web_eid_auth_no_payload) val activity = LocalActivity.current as Activity - val authPayload = viewModel.authPayload.collectAsState().value + val authRequest = viewModel.authRequest.collectAsState().value var isWebEidAuthenticating by rememberSaveable { mutableStateOf(false) } var webEidAuthenticateAction by remember { mutableStateOf<() -> Unit>({}) } var cancelWebEidAuthenticateAction by remember { mutableStateOf<() -> Unit>({}) } @@ -72,6 +98,8 @@ fun WebEidScreen( val snackBarHostState = remember { SnackbarHostState() } val snackBarScope = rememberCoroutineScope() val messages by SnackBarManager.messages.collectAsState(emptyList()) + val dialogError by viewModel.dialogError.asFlow().collectAsState(0) + val showErrorDialog = rememberSaveable { mutableStateOf(false) } LaunchedEffect(messages) { messages.forEach { message -> @@ -82,6 +110,18 @@ fun WebEidScreen( } } + LaunchedEffect(viewModel.dialogError) { + viewModel.dialogError + .asFlow() + .filterNotNull() + .filterNot { it == 0 } + .collect { + withContext(Main) { + showErrorDialog.value = true + } + } + } + Scaffold( snackbarHost = { SnackbarHost( @@ -94,20 +134,8 @@ fun WebEidScreen( modifier = modifier, sharedMenuViewModel = sharedMenuViewModel, title = null, - leftIconContentDescription = - if (isWebEidAuthenticating) { - R.string.signing_cancel - } else { - R.string.back - }, - onLeftButtonClick = { - if (isWebEidAuthenticating) { - cancelWebEidAuthenticateAction() - isWebEidAuthenticating = false - } else { - navController.navigateUp() - } - }, + showNavigationIcon = false, + onLeftButtonClick = {}, onRightSecondaryButtonClick = { isSettingsMenuBottomSheetVisible.value = true }, @@ -119,6 +147,74 @@ fun WebEidScreen( isBottomSheetVisible = isSettingsMenuBottomSheetVisible, ) + if (showErrorDialog.value) { + BasicAlertDialog( + modifier = + modifier + .clip(buttonRoundCornerShape) + .background(MaterialTheme.colorScheme.surface) + .semantics { + testTagsAsResourceId = true + }.testTag("webEidErrorDialog"), + onDismissRequest = {}, + ) { + Surface( + modifier = + modifier + .padding(SPadding) + .wrapContentHeight() + .wrapContentWidth() + .verticalScroll(rememberScrollState()), + ) { + Column { + Box( + modifier = modifier.fillMaxWidth(), + ) { + Text( + modifier = + modifier + .padding(horizontal = SPadding) + .padding(top = XSPadding), + text = stringResource(id = R.string.web_eid_request_error), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + DynamicText( + modifier = + modifier + .fillMaxWidth() + .padding(SPadding), + text = stringResource(dialogError), + ) + Row( + modifier = + modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = { + activity.finishAndRemoveTask() + }) { + Text( + modifier = + modifier + .semantics { + testTagsAsResourceId = true + }.testTag("webEidRequestErrorCloseButton"), + text = stringResource(R.string.close_button), + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + InvisibleElement(modifier = modifier) + } + } + } + Column( modifier = modifier @@ -134,7 +230,25 @@ fun WebEidScreen( color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.semantics { heading() }, ) - if (authPayload != null) { + if (authRequest != null) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = authRequest.origin, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.web_eid_requests_authentication), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + } + NFCView( activity = activity, identityAction = IdentityAction.AUTH, @@ -180,13 +294,32 @@ fun WebEidScreen( }, enabled = isValidToWebEidAuthenticate, modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), ) { Text( text = stringResource(R.string.web_eid_authenticate), - color = MaterialTheme.colorScheme.surface, ) } } + + OutlinedButton( + onClick = { + isWebEidAuthenticating = false + activity.finishAndRemoveTask() + }, + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onBackground, + ), + ) { + Text( + text = stringResource(R.string.web_eid_ignore), + ) + } } } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/TopBar.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/TopBar.kt index 18f3bc0d..93b735d0 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/TopBar.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/TopBar.kt @@ -89,6 +89,7 @@ fun TopBar( @DrawableRes extraButtonIcon: Int = R.drawable.ic_m3_notifications_48dp_wght400, @StringRes extraButtonIconContentDescription: Int = R.string.notifications, showRightSideIcons: Boolean = true, + showNavigationIcon: Boolean = true, onLeftButtonClick: () -> Unit = {}, onRightPrimaryButtonClick: (() -> Unit)? = null, onRightSecondaryButtonClick: () -> Unit = {}, @@ -141,27 +142,29 @@ fun TopBar( titleContentColor = MaterialTheme.colorScheme.onSurface, ), navigationIcon = { - IconButton( - modifier = modifier.testTag("toolBarLeftButton"), - onClick = { - // Add debounce to prevent rapid navigation clicks - debounceJob?.cancel() - debounceJob = - coroutineScope.launch { - onLeftButtonClick() - } - }, - ) { - Icon( - imageVector = ImageVector.vectorResource(id = leftIcon), - contentDescription = stringResource(id = leftIconContentDescription), - tint = MaterialTheme.colorScheme.onSurface, - modifier = - modifier - .size(iconSizeXXS) - .focusable(false) - .testTag("leftNavigationButton"), - ) + if (showNavigationIcon) { + IconButton( + modifier = modifier.testTag("toolBarLeftButton"), + onClick = { + // Add debounce to prevent rapid navigation clicks + debounceJob?.cancel() + debounceJob = + coroutineScope.launch { + onLeftButtonClick() + } + }, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = leftIcon), + contentDescription = stringResource(id = leftIconContentDescription), + tint = MaterialTheme.colorScheme.onSurface, + modifier = + modifier + .size(iconSizeXXS) + .focusable(false) + .testTag("leftNavigationButton"), + ) + } } }, title = { diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt index 95fa7b27..8ebc8eec 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt @@ -232,7 +232,7 @@ fun NFCView( CodeType.PIN1 } - val webEidAuth = webEidViewModel?.authPayload?.collectAsState()?.value + val webEidAuth = webEidViewModel?.authRequest?.collectAsState()?.value val originString = webEidAuth?.origin ?: "" val challengeString = webEidAuth?.challenge ?: "" @@ -326,8 +326,8 @@ fun NFCView( LaunchedEffect(nfcViewModel.webEidAuthResult) { nfcViewModel.webEidAuthResult.asFlow().collect { result -> - result?.let { (authCert, signature) -> - webEidViewModel?.handleWebEidAuthResult(authCert, signature, activity) + result?.let { (authCert, signingCert, signature) -> + webEidViewModel?.handleWebEidAuthResult(authCert, signingCert, signature) nfcViewModel.resetWebEidAuthResult() onSuccess() } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt index d3e4e14a..a7ce4d8d 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt @@ -102,8 +102,8 @@ class NFCViewModel val userData: LiveData = _userData private val _dialogError = MutableLiveData(0) val dialogError: LiveData = _dialogError - private val _webEidAuthResult = MutableLiveData?>() - val webEidAuthResult: LiveData?> = _webEidAuthResult + private val _webEidAuthResult = MutableLiveData?>() + val webEidAuthResult: LiveData?> = _webEidAuthResult private val dialogMessages: ImmutableMap = ImmutableMap @@ -625,7 +625,7 @@ class NFCViewModel val card = TokenWithPace.create(nfcReader) card.tunnel(canNumber) - val (authCert, signatureArray) = + val (authCert, signingCert, signatureArray) = idCardService.authenticate( token = card, pin1 = pin1Code, @@ -641,11 +641,9 @@ class NFCViewModel CoroutineScope(Main).launch { _shouldResetPIN.postValue(true) - _webEidAuthResult.postValue(Pair(authCert, signatureArray)) + _webEidAuthResult.postValue(Triple(authCert, signingCert, signatureArray)) } } catch (ex: SmartCardReaderException) { - _decryptStatus.postValue(false) - if (ex.message?.contains("TagLostException") == true) { _errorState.postValue(Triple(R.string.signature_update_nfc_tag_lost, null, null)) } else if (ex.message?.contains("PIN1 verification failed") == true && @@ -695,7 +693,6 @@ class NFCViewModel errorLog(logTag, "Exception: " + ex.message, ex) } catch (ex: Exception) { - _decryptStatus.postValue(false) _shouldResetPIN.postValue(true) val message = ex.message ?: "" diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt index cc1e3a6c..b2bd9567 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt @@ -2,19 +2,26 @@ package ee.ria.DigiDoc.viewmodel -import android.app.Activity -import android.content.Intent import android.net.Uri -import android.util.Base64 -import androidx.core.net.toUri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog +import ee.ria.DigiDoc.R import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog import ee.ria.DigiDoc.webEid.WebEidAuthService import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest import ee.ria.DigiDoc.webEid.domain.model.WebEidSignRequest +import ee.ria.DigiDoc.webEid.exception.WebEidErrorCode +import ee.ria.DigiDoc.webEid.exception.WebEidException +import ee.ria.DigiDoc.webEid.utils.WebEidRequestParser +import ee.ria.DigiDoc.webEid.utils.WebEidResponseUtil +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import org.json.JSONObject import javax.inject.Inject @@ -24,61 +31,71 @@ class WebEidViewModel constructor( private val authService: WebEidAuthService, ) : ViewModel() { - val authPayload: StateFlow = authService.authRequest - val signPayload: StateFlow = authService.signRequest - val errorState: StateFlow = authService.errorState - val redirectUri: StateFlow = authService.redirectUri + private val logTag = javaClass.simpleName + private val _authRequest = MutableStateFlow(null) + val authRequest: StateFlow = _authRequest.asStateFlow() + private val _signRequest = MutableStateFlow(null) + val signRequest: StateFlow = _signRequest.asStateFlow() + private val _relyingPartyResponseEvents = MutableSharedFlow() + val relyingPartyResponseEvents: SharedFlow = _relyingPartyResponseEvents.asSharedFlow() + private val _dialogError = MutableLiveData(null) + val dialogError: LiveData = _dialogError - fun handleAuth(uri: Uri) { - authService.parseAuthUri(uri) + suspend fun handleAuth(uri: Uri) { + try { + _authRequest.value = WebEidRequestParser.parseAuthUri(uri) + } catch (e: WebEidException) { + errorLog(logTag, "Invalid Web eID authentication request: $uri", e) + val errorPayload = WebEidResponseUtil.createErrorPayload(e.errorCode, e.message) + val responseUri = WebEidResponseUtil.createResponseUri(e.responseUri, errorPayload) + _relyingPartyResponseEvents.emit(responseUri) + } catch (e: Exception) { + errorLog(logTag, "Unable parse Web eID authentication request: $uri", e) + _dialogError.postValue(R.string.web_eid_invalid_auth_request_error) + } } fun handleSign(uri: Uri) { - authService.parseSignUri(uri) + try { + _signRequest.value = WebEidRequestParser.parseSignUri(uri) + } catch (e: Exception) { + errorLog(logTag, "Unable parse Web eID signing request: $uri", e) + _dialogError.postValue(R.string.web_eid_invalid_sign_request_error) + } } - fun reset() { - authService.resetValues() + fun handleUnknown(uri: Uri) { + errorLog(logTag, "Unable parse Web eID request: $uri") + _dialogError.postValue(R.string.web_eid_invalid_sign_request_error) } - fun handleWebEidAuthResult( - cert: ByteArray, + suspend fun handleWebEidAuthResult( + authCert: ByteArray, + signingCert: ByteArray, signature: ByteArray, - activity: Activity, ) { - val challenge = authPayload.value?.challenge - val loginUri = authPayload.value?.loginUri - - if (challenge.isNullOrBlank()) { - errorLog("WebEidViewModel", "Missing challenge in auth payload") - return - } - - if (loginUri.isNullOrBlank()) { - errorLog("WebEidViewModel", "Missing login_uri in auth payload") - return - } - - val token = authService.buildAuthToken(cert, signature, challenge) + val loginUri = authRequest.value?.loginUri!! + val getSigningCertificate = authRequest.value?.getSigningCertificate try { - val payload = JSONObject().put("auth-token", token) - val encoded = - Base64.encodeToString( - payload.toString().toByteArray(Charsets.UTF_8), - Base64.NO_WRAP, + val token = + authService.buildAuthToken( + authCert, + if (getSigningCertificate == true) signingCert else null, + signature, ) - val browserUri = "$loginUri#$encoded" - - debugLog("WebEidViewModel", "Opening browser with loginUri: $browserUri") - val intent = - Intent(Intent.ACTION_VIEW, browserUri.toUri()).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - activity.startActivity(intent) - activity.finish() + val payload = JSONObject().put("auth-token", token) + val responseUri = WebEidResponseUtil.createResponseUri(loginUri, payload) + _relyingPartyResponseEvents.emit(responseUri) } catch (e: Exception) { - errorLog("WebEidViewModel", "Failed to open browser with token", e) + errorLog(logTag, "Unexpected error building auth token", e) + val errorPayload = + WebEidResponseUtil.createErrorPayload( + WebEidErrorCode.ERR_WEBEID_MOBILE_UNKNOWN_ERROR, + "Unexpected error", + ) + val responseUri = WebEidResponseUtil.createResponseUri(loginUri, errorPayload) + _relyingPartyResponseEvents.emit(responseUri) } } } diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 0164a115..ab62998e 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -659,4 +659,14 @@ Muuda + + Autentimine + Autentimine ID-kaardiga + Autentimispäringut ei saadetud + soovib autentimist + Ignoreeri + Vigane Web eID päring + Päringu viga + Vigane autentimispäring + Vigane allkirjastamisspäring \ No newline at end of file diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 78a6a3ab..6007af78 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -135,9 +135,4 @@ Fingerprints SHA-256 SHA-1 - - - No auth payload received. - Authenticate - Authenticate with ID-card \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 37d81193..23071167 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -659,4 +659,14 @@ Edit + + Authenticate + Authenticate with ID-card + No auth payload received. + requests authentication + Ignore + Invalid Web eID request + Request error + Invalid authentication request + Invalid signing request \ No newline at end of file diff --git a/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardService.kt b/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardService.kt index 99c15fe8..ba927284 100644 --- a/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardService.kt +++ b/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardService.kt @@ -63,5 +63,5 @@ interface IdCardService { pin1: ByteArray, origin: String, challenge: String, - ): Pair + ): Triple } diff --git a/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImpl.kt b/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImpl.kt index f7c9913c..e70f1781 100644 --- a/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImpl.kt +++ b/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImpl.kt @@ -42,6 +42,8 @@ import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.withContext import java.security.MessageDigest +import java.security.cert.CertificateFactory +import java.security.interfaces.ECPublicKey import javax.inject.Inject import javax.inject.Singleton @@ -158,29 +160,23 @@ class IdCardServiceImpl pin1: ByteArray, origin: String, challenge: String, - ): Pair { + ): Triple { val authCert = token.certificate(CertificateType.AUTHENTICATION) + val signingCert = token.certificate(CertificateType.SIGNING) val cert = - java.security.cert.CertificateFactory + CertificateFactory .getInstance("X.509") .generateCertificate(authCert.inputStream()) val publicKey = cert.publicKey val hashAlg = when (publicKey) { - is java.security.interfaces.RSAPublicKey -> - when (publicKey.modulus.bitLength()) { - 2048 -> "SHA-256" - 3072 -> "SHA-384" - 4096 -> "SHA-512" - else -> throw IllegalArgumentException("Unsupported RSA key length") - } - is java.security.interfaces.ECPublicKey -> + is ECPublicKey -> when (publicKey.params.curve.field.fieldSize) { 256 -> "SHA-256" 384 -> "SHA-384" - 512 -> "SHA-512" + 521 -> "SHA-512" else -> throw IllegalArgumentException("Unsupported EC key length") } else -> throw IllegalArgumentException("Unsupported key type") @@ -190,9 +186,9 @@ class IdCardServiceImpl val originHash = md.digest(origin.toByteArray(Charsets.UTF_8)) val challengeHash = md.digest(challenge.toByteArray(Charsets.UTF_8)) val signedData = originHash + challengeHash - val tbsHash = MessageDigest.getInstance("SHA-384").digest(signedData) + val tbsHash = md.digest(signedData) val signature = token.authenticate(pin1, tbsHash) - return authCert to signature + return Triple(authCert, signingCert, signature) } } diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthParserTest.kt b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthParserTest.kt deleted file mode 100644 index b5f50490..00000000 --- a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthParserTest.kt +++ /dev/null @@ -1,134 +0,0 @@ -@file:Suppress("PackageName") - -package ee.ria.DigiDoc.webEid - -import android.content.Context -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthParser -import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthParserImpl -import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import java.util.Base64 - -@RunWith(AndroidJUnit4::class) -class WebEidAuthParserTest { - @get:Rule - val instantExecutorRule = InstantTaskExecutorRule() - - private lateinit var context: Context - private lateinit var parser: WebEidAuthParser - private val cert = - "MIIDuzCCAqOgAwIBAgIUBkYXJdruP6EuH/+I4YoXxIQ3WcowDQYJKoZIhvcNAQELBQAw" + - "bTELMAkGA1UEBhMCRUUxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3QxDTALBgNV" + - "BAoMBFRlc3QxDTALBgNVBAsMBFRlc3QxDTALBgNVBAMMBFRlc3QxEzARBgkqhkiG9w0B" + - "CQEWBHRlc3QwHhcNMjQwNjEwMTI1OTA3WhcNMjUwNjEwMTI1OTA3WjBtMQswCQYDVQQG" + - "EwJFRTENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDENMAsGA1UECgwEVGVzdDEN" + - "MAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDETMBEGCSqGSIb3DQEJARYEdGVzdDCC" + - "ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNQx56UkGcNvUrEsdzqhn94nHb3" + - "X8oa1+JUWLHE9KUe2ZiNaIMjMOEuMKtss3tKHHBwLig0by24cwySNozoL156i9a5J8VX" + - "zkuEr0dKlkGm13BnSBVY+gdRB47oh1ZocSewyyJmWetLiOzgRq4xkYLuV/xP+lmum580" + - "MomZcwB06/C42FWIlkPqQF4NFTT1mXjHCzl5uY3OZN9+2KGPa5/QOS9ZI3ixp9TiS8oI" + - "Y7VskIk6tUJcnSF3pN6cI+EkS5zODV3Cs33S2Z3mskC3uBTZQxua75NUxycB5wvg4jbf" + - "GcKOaA9QhHmaloNDwXcw7v9hTwg/xe148mt+D5wABl8CAwEAAaNTMFEwHQYDVR0OBBYE" + - "FCM1tdnw9XYxBNieiNJ8liORKwlpMB8GA1UdIwQYMBaAFCM1tdnw9XYxBNieiNJ8liOR" + - "KwlpMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBALmgdhGrkMLsc/g" + - "n2BsaFx3S7fHaO3MEV0krghH9TMk+M1y0oghAjotm/bGqOmZ4x/Hv08YputTMLTK2qpa" + - "Xtf0Q75V7tOr29jpL10lFALuhNtjRt/Ha5mV4qYGDk+vT8+Rw7SzeVhhSr1pM/MmjN3c" + - "AKDZbI0RINIXarZCb2j963eCfguxXZJbxzW09S6kZ/bDEOwi4PLwE0kln9NqQW6JEBHY" + - "kDeYQonkKm1VrZklb1obq+g1UIJkTOAXQdJDyvfHWyKzKE8cUHGxYUvlxOL/YCyLkUGa" + - "eE/VmJs0niWtKlX4UURG0HAGjZIQ/pJejV+7GzknFMZmuiwJQe4yT4mw=" - - @Before - fun setup() { - context = InstrumentationRegistry.getInstrumentation().targetContext - parser = WebEidAuthParserImpl() - } - - @Test - fun parseAuthUri_httpsOriginIsValid() { - val loginUri = "https://rp.example.com/auth/eid/login" - val uri = android.net.Uri.parse(createAuthUri("abc123", loginUri, true)) - val result: WebEidAuthRequest = parser.parseAuthUri(uri) - - assertEquals("abc123", result.challenge) - assertEquals(loginUri, result.loginUri) - assertEquals(true, result.getSigningCertificate) - assertTrue(result.origin.startsWith("https://rp.example.com")) - } - - @Test - fun parseAuthUri_invalidScheme_throwsException() { - val loginUri = "http://rp.example.com/auth/eid/login" - val uri = android.net.Uri.parse(createAuthUri("abc1234", loginUri, false)) - - val exception = - assertThrows(IllegalArgumentException::class.java) { - parser.parseAuthUri(uri) - } - assertEquals("login_uri must use HTTPS", exception.message) - } - - @Test - fun parseAuthUri_detectsUserInfoPhishing() { - val loginUri = "https://rp.example.com:pass@evil.example.com/auth/eid/login" - val uri = android.net.Uri.parse(createAuthUri("abc1235", loginUri, false)) - - val exception = - assertThrows(IllegalArgumentException::class.java) { - parser.parseAuthUri(uri) - } - assertTrue(exception.message!!.contains("Login URI contains userinfo")) - } - - private fun createAuthUri( - challenge: String, - loginUri: String, - getCert: Boolean, - ): String { - val json = - """ - { - "challenge": "$challenge", - "login_uri": "$loginUri", - "get_signing_certificate": $getCert - } - """.trimIndent() - val encoded = Base64.getEncoder().encodeToString(json.toByteArray()) - return "web-eid://auth#$encoded" - } - - @Test - fun parseAuthUri_invalidBase64_throwsException() { - val uri = android.net.Uri.parse("web-eid://auth#%%%INVALID%%%") - val exception = - assertThrows(IllegalArgumentException::class.java) { - parser.parseAuthUri(uri) - } - assertTrue(exception.message!!.contains("Invalid URI fragment format")) - } - - @Test - fun buildAuthToken_returnsExpectedJsonStructure() { - val certBytes = Base64.getDecoder().decode(cert) - val signature = byteArrayOf(1, 2, 3, 4, 5) - val challenge = "abc123" - - val token = parser.buildAuthToken(certBytes, signature, challenge) - - assertEquals("web-eid:1.1", token.getString("format")) - assertTrue(token.getString("unverifiedCertificate").isNotEmpty()) - assertTrue(token.getString("unverifiedSigningCertificate").isNotEmpty()) - assertEquals(challenge, token.getString("challenge")) - assertTrue(token.getString("signature").isNotEmpty()) - assertTrue(token.has("algorithm")) - assertTrue(token.has("supportedSignatureAlgorithms")) - } -} diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt index a9d452d3..fd889bc9 100644 --- a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt @@ -2,15 +2,11 @@ package ee.ria.DigiDoc.webEid -import android.net.Uri import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 -import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthParser -import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthParserImpl -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals import org.junit.Before import org.junit.Rule import org.junit.Test @@ -22,190 +18,83 @@ class WebEidAuthServiceTest { @get:Rule val instantExecutorRule = InstantTaskExecutorRule() - private lateinit var parser: WebEidAuthParser private lateinit var service: WebEidAuthService - private val cert = - "MIIDuzCCAqOgAwIBAgIUBkYXJdruP6EuH/+I4YoXxIQ3WcowDQYJKoZIhvcNAQELBQAw" + - "bTELMAkGA1UEBhMCRUUxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3QxDTALBgNV" + - "BAoMBFRlc3QxDTALBgNVBAsMBFRlc3QxDTALBgNVBAMMBFRlc3QxEzARBgkqhkiG9w0B" + - "CQEWBHRlc3QwHhcNMjQwNjEwMTI1OTA3WhcNMjUwNjEwMTI1OTA3WjBtMQswCQYDVQQG" + - "EwJFRTENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDENMAsGA1UECgwEVGVzdDEN" + - "MAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDETMBEGCSqGSIb3DQEJARYEdGVzdDCC" + - "ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNQx56UkGcNvUrEsdzqhn94nHb3" + - "X8oa1+JUWLHE9KUe2ZiNaIMjMOEuMKtss3tKHHBwLig0by24cwySNozoL156i9a5J8VX" + - "zkuEr0dKlkGm13BnSBVY+gdRB47oh1ZocSewyyJmWetLiOzgRq4xkYLuV/xP+lmum580" + - "MomZcwB06/C42FWIlkPqQF4NFTT1mXjHCzl5uY3OZN9+2KGPa5/QOS9ZI3ixp9TiS8oI" + - "Y7VskIk6tUJcnSF3pN6cI+EkS5zODV3Cs33S2Z3mskC3uBTZQxua75NUxycB5wvg4jbf" + - "GcKOaA9QhHmaloNDwXcw7v9hTwg/xe148mt+D5wABl8CAwEAAaNTMFEwHQYDVR0OBBYE" + - "FCM1tdnw9XYxBNieiNJ8liORKwlpMB8GA1UdIwQYMBaAFCM1tdnw9XYxBNieiNJ8liOR" + - "KwlpMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBALmgdhGrkMLsc/g" + - "n2BsaFx3S7fHaO3MEV0krghH9TMk+M1y0oghAjotm/bGqOmZ4x/Hv08YputTMLTK2qpa" + - "Xtf0Q75V7tOr29jpL10lFALuhNtjRt/Ha5mV4qYGDk+vT8+Rw7SzeVhhSr1pM/MmjN3c" + - "AKDZbI0RINIXarZCb2j963eCfguxXZJbxzW09S6kZ/bDEOwi4PLwE0kln9NqQW6JEBHY" + - "kDeYQonkKm1VrZklb1obq+g1UIJkTOAXQdJDyvfHWyKzKE8cUHGxYUvlxOL/YCyLkUGa" + - "eE/VmJs0niWtKlX4UURG0HAGjZIQ/pJejV+7GzknFMZmuiwJQe4yT4mw=" + private val authCertBase64 = + """ + MIIECTCCA4+gAwIBAgIUN2tgxiz6MdXE3QfegLIoan8ZNW0wCgYIKoZIzj0EAwMwXDEYMBYGA1UEAwwPVGVzdCBFU1RFSUQyMDI1MRcwFQYDVQRh + DA5OVFJFRS0xNzA2NjA0OTEaMBgGA1UECgwRWmV0ZXMgRXN0b25pYSBPw5wxCzAJBgNVBAYTAkVFMB4XDTI0MTIxODEwMjYxMloXDTI5MTIwOTIw + NTkxMlowfzEqMCgGA1UEAwwhSsOVRU9SRyxKQUFLLUtSSVNUSkFOLDM4MDAxMDg1NzE4MRowGAYDVQQFExFQTk9FRS0zODAwMTA4NTcxODEWMBQG + A1UEKgwNSkFBSy1LUklTVEpBTjEQMA4GA1UEBAwHSsOVRU9SRzELMAkGA1UEBhMCRUUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARCqN9WLBaVniOO + qXCKa5yzvlXZNNfmTxxhduZX/81iNvB6BRDJEyyRgKMyn/32NuKUUxa+JqExAvT534kOOTQVPOcp/e2X5NUc+qCw1qsNcsMs60C7FSxzoyvZ+HIt + /oajggHtMIIB6TAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFO7ylT+MsvxRnoTm5l6EEX5CuiA2MHAGCCsGAQUFBwEBBGQwYjA4BggrBgEFBQcwAoYs + aHR0cDovL2NydC10ZXN0LmVpZHBraS5lZS90ZXN0RVNURUlEMjAyNS5jcnQwJgYIKwYBBQUHMAGGGmh0dHA6Ly9vY3NwLXRlc3QuZWlkcGtpLmVl + MB8GA1UdEQQYMBaBFDM4MDAxMDg1NzE4QGVlc3RpLmVlMFYGA1UdIARPME0wCAYGBACPegECMEEGDog3AQMGAQQBg5EhAgEBMC8wLQYIKwYBBQUH + AgEWIWh0dHBzOi8vcmVwb3NpdG9yeS10ZXN0LmVpZHBraS5lZTAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwQwQwYIKwYBBQUHAQMENzA1 + MDMGBgQAjkYBBTApMCcWIWh0dHBzOi8vcmVwb3NpdG9yeS10ZXN0LmVpZHBraS5lZRMCZW4wPQYDVR0fBDYwNDAyoDCgLoYsaHR0cDovL2NybC10 + ZXN0LmVpZHBraS5lZS90ZXN0RVNURUlEMjAyNS5jcmwwHQYDVR0OBBYEFIl1MYmBknWP4qF6QZmMHHVO4pnTMA4GA1UdDwEB/wQEAwIDiDAKBggq + hkjOPQQDAwNoADBlAjAk2dWjje4yKfESIYN2fU0vQM7+8BOyOD4qHdwSnh+XqphWXGEDIra6FgS4mY/uu0oCMQC4Hg18SnB6oy6dL4vEMFyTyx2F + iaiMnWMYd1/TyTQvUzvT2jmEA1a7DrALs0Pt3aA= + """.trimIndent() + + private val signingCertBase64 = + """ + MIID8zCCA3mgAwIBAgIUeHSVTuHxrs0ASYMbqOjDX5yFVnswCgYIKoZIzj0EAwMwXDEYMBYGA1UEAwwPVGVzdCBFU1RFSUQyMDI1MRcwFQYDVQRh + DA5OVFJFRS0xNzA2NjA0OTEaMBgGA1UECgwRWmV0ZXMgRXN0b25pYSBPw5wxCzAJBgNVBAYTAkVFMB4XDTI0MTIxODEwMjY0MVoXDTI5MTIwOTIw + NTk0MVowfzEqMCgGA1UEAwwhSsOVRU9SRyxKQUFLLUtSSVNUSkFOLDM4MDAxMDg1NzE4MRowGAYDVQQFExFQTk9FRS0zODAwMTA4NTcxODEWMBQG + A1UEKgwNSkFBSy1LUklTVEpBTjEQMA4GA1UEBAwHSsOVRU9SRzELMAkGA1UEBhMCRUUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAR9DpcXt4J2NwqG + B3pS1RcGlBM7tcoG82OGpLwCr4xn9LZgc5QRk/oGmRoJ6Nk9/BbHgoYYvBXW8xzcTNZwKIxwz7FRI9cFF+4+4i/ywqkRV9ApH112xQ7L+p9ANCP/ + va6jggHXMIIB0zAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFO7ylT+MsvxRnoTm5l6EEX5CuiA2MHAGCCsGAQUFBwEBBGQwYjA4BggrBgEFBQcwAoYs + aHR0cDovL2NydC10ZXN0LmVpZHBraS5lZS90ZXN0RVNURUlEMjAyNS5jcnQwJgYIKwYBBQUHMAGGGmh0dHA6Ly9vY3NwLXRlc3QuZWlkcGtpLmVl + MFcGA1UdIARQME4wCQYHBACL7EABAjBBBg6INwEDBgEEAYORIQIBATAvMC0GCCsGAQUFBwIBFiFodHRwczovL3JlcG9zaXRvcnktdGVzdC5laWRw + a2kuZWUwbAYIKwYBBQUHAQMEYDBeMAgGBgQAjkYBATAIBgYEAI5GAQQwEwYGBACORgEGMAkGBwQAjkYBBgEwMwYGBACORgEFMCkwJxYhaHR0cHM6 + Ly9yZXBvc2l0b3J5LXRlc3QuZWlkcGtpLmVlEwJlbjA9BgNVHR8ENjA0MDKgMKAuhixodHRwOi8vY3JsLXRlc3QuZWlkcGtpLmVlL3Rlc3RFU1RF + SUQyMDI1LmNybDAdBgNVHQ4EFgQUH6IlbFh9H8w0BIsDCgq01rqaFVUwDgYDVR0PAQH/BAQDAgZAMAoGCCqGSM49BAMDA2gAMGUCMQDGeR+QV6MF + sWnB7LoXrpOfPQFTT366CLbdmQQMbIzJtysZTrOSQ95yxpulvpxOKsoCMAsT41AJ3de5JSrW89S5x5zgvi1K7PG1zhzSGgUuMElzDZPJSyp4TE8k + FvCDizwjaQ== + """.trimIndent() @Before fun setup() { - parser = WebEidAuthParserImpl() - service = WebEidAuthServiceImpl(parser) + service = WebEidAuthServiceImpl() } - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun parseAuthUri_validUri_updatesAuthRequest() = - runTest { - val uri = - createAuthUri( - challenge = "abc123", - loginUri = "https://rp.example.com/auth/eid/login", - getCert = true, - ) - - service.parseAuthUri(uri) - - val auth = service.authRequest.value - assertEquals("abc123", auth?.challenge) - assertEquals("https://rp.example.com/auth/eid/login", auth?.loginUri) - assertEquals(true, auth?.getSigningCertificate) - assertEquals("https://rp.example.com", auth?.origin) - assertNull(service.errorState.value) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun parseSignUri_validUri_updatesSignRequest() = - runTest { - val uri = - createSignUri( - responseUri = "https://rp.example.com/sign/ok", - signCert = "CERTDATA", - hash = "abcd1234", - hashFunc = "SHA-256", - ) - - service.parseSignUri(uri) - - val sign = service.signRequest.value - assertEquals("https://rp.example.com/sign/ok", sign?.responseUri) - assertEquals("CERTDATA", sign?.signCertificate) - assertEquals("abcd1234", sign?.hash) - assertEquals("SHA-256", sign?.hashFunction) - assertNull(service.errorState.value) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun resetValues_clearsAllState() = - runTest { - val uri = createAuthUri("abc123", "https://rp.example.com", false) - service.parseAuthUri(uri) - - service.resetValues() - - assertNull(service.authRequest.value) - assertNull(service.signRequest.value) - assertNull(service.errorState.value) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun parseAuthUri_invalidUri_setsErrorState() = - runTest { - val badUri = Uri.parse("web-eid://auth#not-base64!!!") - - service.parseAuthUri(badUri) - - assertNull(service.authRequest.value) - assertNull(service.signRequest.value) - assert(service.errorState.value != null) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun parseAuthUri_validUri_setsRedirectUriToSuccess() = - runTest { - val uri = - createAuthUri( - challenge = "abc123", - loginUri = "https://rp.example.com/auth/eid/login", - getCert = false, - ) - - service.parseAuthUri(uri) - - val redirect = service.redirectUri.value - assert(redirect != null) - val decodedFragment = String(Base64.getUrlDecoder().decode(Uri.parse(redirect).fragment)) - assert(decodedFragment.contains("mock-web-eid-auth-token")) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun parseAuthUri_invalidUri_setsRedirectUriToError() = - runTest { - val badUri = Uri.parse("web-eid://auth#not-base64!!!") - - service.parseAuthUri(badUri) - - val redirect = service.redirectUri.value - assert(redirect != null) - val decodedFragment = String(Base64.getUrlDecoder().decode(Uri.parse(redirect).fragment)) - assert(decodedFragment.contains("ERR_WEBEID_INVALID_REQUEST")) - } - @Test fun buildAuthToken_withValidInputs_returnsValidJson() { - val certBytes = Base64.getDecoder().decode(cert) + val authCertBytes = Base64.getMimeDecoder().decode(authCertBase64) + val signingCertBytes = Base64.getMimeDecoder().decode(signingCertBase64) val signature = byteArrayOf(1, 2, 3, 4, 5) - val challenge = "abc123" - val token = service.buildAuthToken(certBytes, signature, challenge) + val token = service.buildAuthToken(authCertBytes, signingCertBytes, signature) assertEquals("web-eid:1.1", token.getString("format")) - assertEquals(challenge, token.getString("challenge")) assert(token.getString("unverifiedCertificate").isNotBlank()) assert(token.getString("unverifiedSigningCertificate").isNotBlank()) assert(token.getString("signature").isNotBlank()) assert(token.has("algorithm")) assert(token.has("supportedSignatureAlgorithms")) + assertEquals(Base64.getEncoder().encodeToString(authCertBytes), token.getString("unverifiedCertificate")) + assertEquals( + Base64.getEncoder().encodeToString(signingCertBytes), + token.getString("unverifiedSigningCertificate"), + ) + assertNotEquals( + token.getString("unverifiedCertificate"), + token.getString("unverifiedSigningCertificate"), + "Auth certificate and signing certificate should not be identical", + ) } - @Suppress("SameParameterValue") - private fun createAuthUri( - challenge: String, - loginUri: String, - getCert: Boolean, - ): Uri { - val json = - """ - { - "challenge": "$challenge", - "login_uri": "$loginUri", - "get_signing_certificate": $getCert - } - """.trimIndent() - val encoded = Base64.getEncoder().encodeToString(json.toByteArray()) - return Uri.parse("web-eid://auth#$encoded") - } + @Test + fun buildAuthToken_withoutSigningCertificate_returnsV1Format() { + val authCertBytes = Base64.getMimeDecoder().decode(authCertBase64) + val signature = byteArrayOf(1, 2, 3, 4, 5) - @Suppress("SameParameterValue") - private fun createSignUri( - responseUri: String, - signCert: String, - hash: String, - hashFunc: String, - ): Uri { - val json = - """ - { - "response_uri": "$responseUri", - "sign_certificate": "$signCert", - "hash": "$hash", - "hash_function": "$hashFunc" - } - """.trimIndent() - val encoded = Base64.getEncoder().encodeToString(json.toByteArray()) - return Uri.parse("web-eid://sign#$encoded") + val token = service.buildAuthToken(authCertBytes, null, signature) + + assertEquals("web-eid:1.0", token.getString("format")) + assert(token.getString("unverifiedCertificate").isNotBlank()) + assert(token.getString("signature").isNotBlank()) + assertFalse(token.has("unverifiedSigningCertificate")) + assertFalse(token.has("supportedSignatureAlgorithms")) } } diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt new file mode 100644 index 00000000..9e81833f --- /dev/null +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt @@ -0,0 +1,94 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest +import ee.ria.DigiDoc.webEid.utils.WebEidRequestParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Base64 + +@RunWith(AndroidJUnit4::class) +class WebEidRequestParserTest { + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + private lateinit var context: Context + + @Before + fun setup() { + context = InstrumentationRegistry.getInstrumentation().targetContext + } + + @Test + fun parseAuthUri_validUri_success() { + val loginUri = "https://rp.example.com/auth/eid/login" + val uri = android.net.Uri.parse(createAuthUri("test-challenge-00000000000000000000000000000", loginUri, true)) + val result: WebEidAuthRequest = WebEidRequestParser.parseAuthUri(uri) + + assertEquals("test-challenge-00000000000000000000000000000", result.challenge) + assertEquals(loginUri, result.loginUri) + assertEquals(true, result.getSigningCertificate) + assertTrue(result.origin.startsWith("https://rp.example.com")) + } + + @Test + fun parseAuthUri_invalidScheme_throwsException() { + val loginUri = "http://rp.example.com/auth/eid/login" + val uri = android.net.Uri.parse(createAuthUri("abc1234", loginUri, false)) + + val exception = + assertThrows(IllegalArgumentException::class.java) { + WebEidRequestParser.parseAuthUri(uri) + } + assertEquals("Response URI must use HTTPS scheme", exception.message) + } + + @Test + fun parseAuthUri_forbiddenUserInfo_throwsException() { + val loginUri = "https://rp.example.com:pass@evil.example.com/auth/eid/login" + val uri = android.net.Uri.parse(createAuthUri("abc1235", loginUri, false)) + + val exception = + assertThrows(IllegalArgumentException::class.java) { + WebEidRequestParser.parseAuthUri(uri) + } + assertTrue(exception.message!!.contains("Response URI must not contain userinfo")) + } + + private fun createAuthUri( + challenge: String, + loginUri: String, + getCert: Boolean, + ): String { + val json = + """ + { + "challenge": "$challenge", + "login_uri": "$loginUri", + "get_signing_certificate": $getCert + } + """.trimIndent() + val encoded = Base64.getEncoder().encodeToString(json.toByteArray()) + return "web-eid://auth#$encoded" + } + + @Test + fun parseAuthUri_invalidBase64_throwsException() { + val uri = android.net.Uri.parse("web-eid://auth#%%%INVALID%%%") + val exception = + assertThrows(IllegalArgumentException::class.java) { + WebEidRequestParser.parseAuthUri(uri) + } + assertTrue(exception.message!!.contains("Invalid URI fragment")) + } +} diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt index 34f7d83e..9185682e 100644 --- a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt @@ -2,67 +2,58 @@ package ee.ria.DigiDoc.webEid.utils -import android.net.Uri import androidx.test.ext.junit.runners.AndroidJUnit4 import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith -import java.util.Base64 @RunWith(AndroidJUnit4::class) class WebEidResponseUtilTest { @Test - fun createErrorRedirect_withCustomCodeAndMessage_encodesCorrectly() { + fun createRedirect_withCustomPayload_encodesAndAppendsCorrectly() { val loginUri = "https://rp.example.com/auth/eid/login" - val code = "ERR_CUSTOM" - val message = "Custom error message" + val payload = + JSONObject() + .put("code", "ERR_CUSTOM") + .put("message", "Custom error message") - val resultUri = WebEidResponseUtil.createErrorRedirect(loginUri, code, message) + val resultUri = WebEidResponseUtil.createResponseUri(loginUri, payload) - val fragment = Uri.parse(resultUri).fragment - val decodedJson = String(Base64.getUrlDecoder().decode(fragment)) + val fragment = resultUri.fragment + val decodedJson = String(android.util.Base64.decode(fragment, android.util.Base64.URL_SAFE)) val json = JSONObject(decodedJson) - assertEquals(code, json.getString("code")) - assertEquals(message, json.getString("message")) + assertEquals("ERR_CUSTOM", json.getString("code")) + assertEquals("Custom error message", json.getString("message")) } @Test - fun createErrorRedirect_withDefaults_usesUnknownErrorValues() { + fun createRedirect_withSuccessPayload_encodesAndAppendsCorrectly() { val loginUri = "https://rp.example.com/auth/eid/login" + val payload = + JSONObject() + .put("auth-token", "sample-token") + .put("challenge", "abc123") - val resultUri = WebEidResponseUtil.createErrorRedirect(loginUri) + val resultUri = WebEidResponseUtil.createResponseUri(loginUri, payload) - val fragment = Uri.parse(resultUri).fragment - val decodedJson = String(Base64.getUrlDecoder().decode(fragment)) + val fragment = resultUri.fragment + val decodedJson = String(android.util.Base64.decode(fragment, android.util.Base64.URL_SAFE)) val json = JSONObject(decodedJson) - assertEquals(WebEidErrorCodes.UNKNOWN, json.getString("code")) - assertEquals(WebEidErrorCodes.UNKNOWN_MESSAGE, json.getString("message")) + assertEquals("sample-token", json.getString("auth-token")) + assertEquals("abc123", json.getString("challenge")) } @Test - fun createSuccessRedirect_containsMockSuccessPayload() { + fun appendFragment_keepsBaseUriIntact() { val loginUri = "https://rp.example.com/auth/eid/login" + val payload = JSONObject().put("foo", "bar") - val resultUri = WebEidResponseUtil.createSuccessRedirect(loginUri) + val resultUri = WebEidResponseUtil.createResponseUri(loginUri, payload) - val fragment = Uri.parse(resultUri).fragment - val decodedJson = String(Base64.getUrlDecoder().decode(fragment)) - val json = JSONObject(decodedJson) - - assertEquals("mock-web-eid-auth-token", json.getString("web_eid_auth_token")) - assertEquals("mock-attestation", json.getString("eid_instance_attestation")) - assertEquals("mock-attestation-proof", json.getString("eid_instance_attestation_proof")) - } - - @Test - fun appendedFragment_keepsBaseUriIntact() { - val loginUri = "https://rp.example.com/auth/eid/login" - val resultUri = WebEidResponseUtil.createSuccessRedirect(loginUri) - - assertTrue(resultUri.startsWith(loginUri)) + assertTrue(resultUri.toString().startsWith(loginUri)) } } diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthService.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthService.kt index 1e365506..789e8e34 100644 --- a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthService.kt +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthService.kt @@ -2,27 +2,12 @@ package ee.ria.DigiDoc.webEid -import android.net.Uri -import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest -import ee.ria.DigiDoc.webEid.domain.model.WebEidSignRequest -import kotlinx.coroutines.flow.StateFlow import org.json.JSONObject interface WebEidAuthService { - val authRequest: StateFlow - val signRequest: StateFlow - val errorState: StateFlow - val redirectUri: StateFlow - - fun resetValues() - - fun parseAuthUri(uri: Uri) - - fun parseSignUri(uri: Uri) - fun buildAuthToken( - certBytes: ByteArray, + authCert: ByteArray, + signingCert: ByteArray?, signature: ByteArray, - challenge: String, ): JSONObject } diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt index 79688b5f..91458033 100644 --- a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt @@ -2,76 +2,88 @@ package ee.ria.DigiDoc.webEid -import android.net.Uri -import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog -import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthParser -import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest -import ee.ria.DigiDoc.webEid.domain.model.WebEidSignRequest -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONArray import org.json.JSONObject +import java.security.PublicKey +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.interfaces.ECPublicKey +import java.util.Base64 import javax.inject.Inject import javax.inject.Singleton @Singleton class WebEidAuthServiceImpl @Inject - constructor( - private val parserImpl: WebEidAuthParser, - ) : WebEidAuthService { - private val logTag = javaClass.simpleName + constructor() : WebEidAuthService { - private val _authRequest = MutableStateFlow(null) - override val authRequest: StateFlow = _authRequest.asStateFlow() - - private val _signRequest = MutableStateFlow(null) - override val signRequest: StateFlow = _signRequest.asStateFlow() + companion object { + val SUPPORTED_HASH_FUNCTIONS = listOf( + "SHA-224", "SHA-256", "SHA-384", "SHA-512", + "SHA3-224", "SHA3-256", "SHA3-384", "SHA3-512" + ) + } - private val _errorState = MutableStateFlow(null) - override val errorState: StateFlow = _errorState.asStateFlow() + override fun buildAuthToken( + authCert: ByteArray, + signingCert: ByteArray?, + signature: ByteArray, + ): JSONObject { + val cert = + CertificateFactory + .getInstance("X.509") + .generateCertificate(authCert.inputStream()) as X509Certificate - private val _redirectUri = MutableStateFlow(null) - override val redirectUri: StateFlow = _redirectUri.asStateFlow() + val publicKey = cert.publicKey + val algorithm = getAlgorithm(publicKey) - override fun resetValues() { - _authRequest.value = null - _signRequest.value = null - _errorState.value = null - _redirectUri.value = null - } + return JSONObject().apply { + put("algorithm", algorithm) + put("unverifiedCertificate", Base64.getEncoder().encodeToString(authCert)) + put("issuerApp", "https://web-eid.eu/web-eid-mobile-app/releases/v1.0.0") + put("signature", Base64.getEncoder().encodeToString(signature)) - override fun parseAuthUri(uri: Uri) { - try { - val resultRedirect = parserImpl.handleAuthFlow(uri) - _redirectUri.value = resultRedirect - _authRequest.value = parserImpl.parseAuthUri(uri) - } catch (e: IllegalArgumentException) { - errorLog(logTag, "Validation failed in parseAuthUri", e) - _errorState.value = e.message - } catch (e: Exception) { - errorLog(logTag, "Failed to parse Web eID auth URI", e) - _errorState.value = e.message + if (signingCert != null) { + val supportedSignatureAlgorithms = buildSupportedSignatureAlgorithms(publicKey) + put("unverifiedSigningCertificate", Base64.getEncoder().encodeToString(signingCert)) + put("supportedSignatureAlgorithms", supportedSignatureAlgorithms) + put("format", "web-eid:1.1") + } else { + put("format", "web-eid:1.0") + } } } - override fun parseSignUri(uri: Uri) { - try { - _signRequest.value = parserImpl.parseSignUri(uri) - } catch (e: IllegalArgumentException) { - errorLog(logTag, "Validation failed in parseSignUri", e) - _errorState.value = e.message - } catch (e: Exception) { - errorLog(logTag, "Failed to parse Web eID sign URI", e) - _errorState.value = e.message + private fun getAlgorithm(publicKey: PublicKey): String = + when (publicKey) { + is ECPublicKey -> { + when (publicKey.params.curve.field.fieldSize) { + 256 -> "ES256" + 384 -> "ES384" + 521 -> "ES512" + else -> throw IllegalArgumentException("Unsupported EC key length") + } + } + + else -> throw IllegalArgumentException("Unsupported key type") } - } - override fun buildAuthToken( - certBytes: ByteArray, - signature: ByteArray, - challenge: String, - ): JSONObject { - return parserImpl.buildAuthToken(certBytes, signature, challenge) + private fun buildSupportedSignatureAlgorithms(publicKey: PublicKey): JSONArray = + JSONArray().apply { + when (publicKey) { + is ECPublicKey -> { + SUPPORTED_HASH_FUNCTIONS.forEach { hashFunction -> + put( + JSONObject().apply { + put("cryptoAlgorithm", "ECC") + put("hashFunction", hashFunction) + put("paddingScheme", "NONE") + }, + ) + } + } + + else -> throw IllegalArgumentException("Unsupported key type") + } } } diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/di/AppModules.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/di/AppModules.kt index 2573a012..c3144f36 100644 --- a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/di/AppModules.kt +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/di/AppModules.kt @@ -8,15 +8,10 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import ee.ria.DigiDoc.webEid.WebEidAuthService import ee.ria.DigiDoc.webEid.WebEidAuthServiceImpl -import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthParser -import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthParserImpl @Module @InstallIn(SingletonComponent::class) class AppModules { @Provides - fun provideWebEidAuthParser(): WebEidAuthParser = WebEidAuthParserImpl() - - @Provides - fun provideWebEidAuthService(parser: WebEidAuthParser): WebEidAuthService = WebEidAuthServiceImpl(parser) + fun provideWebEidAuthService(): WebEidAuthService = WebEidAuthServiceImpl() } diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthParser.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthParser.kt deleted file mode 100644 index b8375a36..00000000 --- a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthParser.kt +++ /dev/null @@ -1,20 +0,0 @@ -@file:Suppress("PackageName") - -package ee.ria.DigiDoc.webEid.domain.model - -import android.net.Uri -import org.json.JSONObject - -interface WebEidAuthParser { - fun parseAuthUri(uri: Uri): WebEidAuthRequest - - fun parseSignUri(uri: Uri): WebEidSignRequest - - fun handleAuthFlow(uri: Uri): String - - fun buildAuthToken( - certBytes: ByteArray, - signature: ByteArray, - challenge: String, - ): JSONObject -} diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthParserImpl.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthParserImpl.kt deleted file mode 100644 index c2755778..00000000 --- a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthParserImpl.kt +++ /dev/null @@ -1,205 +0,0 @@ -@file:Suppress("PackageName") - -package ee.ria.DigiDoc.webEid.domain.model - -import android.net.Uri -import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog -import ee.ria.DigiDoc.webEid.utils.WebEidError -import ee.ria.DigiDoc.webEid.utils.WebEidErrorCodes -import ee.ria.DigiDoc.webEid.utils.WebEidResponseUtil -import org.json.JSONArray -import org.json.JSONObject -import java.net.URI -import java.security.cert.CertificateFactory -import java.security.cert.X509Certificate -import java.util.Base64 -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class WebEidAuthParserImpl - @Inject - constructor() : WebEidAuthParser { - private val logTag = javaClass.simpleName - - override fun handleAuthFlow(uri: Uri): String { - return try { - val request = parseAuthUri(uri) - WebEidResponseUtil.createSuccessRedirect(request.loginUri) - } catch (e: Exception) { - errorLog(logTag, "Error in auth flow", e) - val err = mapExceptionToError(e) - WebEidResponseUtil.createErrorRedirect(extractLoginUriSafe(uri), err.code, err.message) - } - } - - override fun parseAuthUri(uri: Uri): WebEidAuthRequest { - val json = decodeUriFragment(uri) - - val challenge = json.getString("challenge") - val loginUriEncoded = json.getString("login_uri") - val getSigningCertificate = json.optBoolean("get_signing_certificate", false) - - val loginUri = java.net.URLDecoder.decode(loginUriEncoded, java.nio.charset.StandardCharsets.UTF_8.name()) - - validateHttpsScheme(loginUri) - val origin = parseOriginFromLoginUri(loginUri) - validateOriginCorrectness(loginUri, origin) - - return WebEidAuthRequest( - challenge = challenge, - loginUri = loginUri, - getSigningCertificate = getSigningCertificate, - origin = origin, - ) - } - - override fun parseSignUri(uri: Uri): WebEidSignRequest { - val json = decodeUriFragment(uri) - return WebEidSignRequest( - responseUri = json.getString("response_uri"), - signCertificate = json.getString("sign_certificate"), - hash = json.getString("hash"), - hashFunction = json.getString("hash_function"), - ) - } - - private fun decodeUriFragment(uri: Uri): JSONObject { - try { - val fragment = uri.fragment ?: throw IllegalArgumentException("No fragment in URI") - val decoded = String(Base64.getDecoder().decode(fragment)) - return JSONObject(decoded) - } catch (e: Exception) { - errorLog(logTag, "Failed to decode or parse URI fragment: ${uri.fragment}", e) - throw IllegalArgumentException("Invalid URI fragment format", e) - } - } - - private fun validateHttpsScheme(loginUri: String) { - try { - val parsed = URI(loginUri) - if (!parsed.scheme.equals("https", ignoreCase = true)) { - errorLog(logTag, "Invalid scheme in login_uri: $loginUri — must be HTTPS") - throw IllegalArgumentException("login_uri must use HTTPS") - } - } catch (e: IllegalArgumentException) { - throw e - } catch (e: Exception) { - errorLog(logTag, "Invalid login_uri format: $loginUri", e) - throw IllegalArgumentException("Invalid login_uri format", e) - } - } - - private fun validateOriginCorrectness( - loginUri: String, - origin: String, - ) { - try { - val parsedLogin = URI(loginUri) - val expected = URI(origin) - - if (!parsedLogin.host.equals(expected.host, ignoreCase = true) || - parsedLogin.port != expected.port - ) { - errorLog( - logTag, - "Origin mismatch: expected $origin but login_uri points to host ${parsedLogin.host}", - ) - throw IllegalArgumentException("Origin mismatch: expected $origin") - } - - if (parsedLogin.userInfo != null) { - errorLog( - logTag, - "Login URI contains userinfo (possible phishing attempt): $loginUri", - ) - throw IllegalArgumentException("Login URI contains userinfo (possible phishing attempt)") - } - } catch (e: IllegalArgumentException) { - throw e - } catch (e: Exception) { - errorLog(logTag, "Failed to validate origin correctness for $loginUri", e) - throw IllegalArgumentException("Invalid origin in login_uri", e) - } - } - - private fun parseOriginFromLoginUri(loginUri: String): String { - return try { - val parsed = URI(loginUri) - if (parsed.scheme.isNullOrBlank() || parsed.host.isNullOrBlank()) { - errorLog(logTag, "Invalid login_uri: missing scheme or host — $loginUri") - return "" - } - val portPart = if (parsed.port != -1) ":${parsed.port}" else "" - "${parsed.scheme}://${parsed.host}$portPart" - } catch (e: Exception) { - errorLog(logTag, "Failed to parse origin from login_uri: $loginUri", e) - "" - } - } - - private fun extractLoginUriSafe(uri: Uri): String { - return try { - val json = decodeUriFragment(uri) - json.optString("login_uri", "") - } catch (e: Exception) { - errorLog(logTag, "Failed to safely extract login_uri from URI: $uri", e) - "" - } - } - - private fun mapExceptionToError(e: Exception): WebEidError { - return when (e) { - is IllegalArgumentException -> - WebEidError( - code = WebEidErrorCodes.INVALID_REQUEST, - message = e.message ?: WebEidErrorCodes.INVALID_REQUEST_MESSAGE, - ) - else -> - WebEidError( - code = WebEidErrorCodes.UNKNOWN, - message = WebEidErrorCodes.UNKNOWN_MESSAGE, - ) - } - } - - override fun buildAuthToken( - certBytes: ByteArray, - signature: ByteArray, - challenge: String, - ): JSONObject { - val cert = - CertificateFactory.getInstance("X.509") - .generateCertificate(certBytes.inputStream()) as X509Certificate - - val publicKey = cert.publicKey - val algorithm = - when (publicKey) { - is java.security.interfaces.RSAPublicKey -> "RS256" - is java.security.interfaces.ECPublicKey -> "ES384" - else -> "RS256" - } - - val supportedSignatureAlgorithms = - JSONArray().apply { - put( - JSONObject().apply { - put("cryptoAlgorithm", "RSA") - put("hashFunction", "SHA-256") - put("paddingScheme", "PKCS1.5") - }, - ) - } - - return JSONObject().apply { - put("algorithm", algorithm) - put("unverifiedCertificate", Base64.getEncoder().encodeToString(certBytes)) - put("unverifiedSigningCertificate", Base64.getEncoder().encodeToString(certBytes)) - put("supportedSignatureAlgorithms", supportedSignatureAlgorithms) - put("issuerApp", "https://web-eid.eu/web-eid-mobile-app/releases/v1.0.0") - put("signature", Base64.getEncoder().encodeToString(signature)) - put("format", "web-eid:1.1") - put("challenge", challenge) - } - } - } diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/exception/WebEidErrorCode.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/exception/WebEidErrorCode.kt new file mode 100644 index 00000000..64b69c97 --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/exception/WebEidErrorCode.kt @@ -0,0 +1,8 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.exception + +enum class WebEidErrorCode { + ERR_WEBEID_MOBILE_INVALID_REQUEST, + ERR_WEBEID_MOBILE_UNKNOWN_ERROR, +} diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/exception/WebEidException.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/exception/WebEidException.kt new file mode 100644 index 00000000..14ba077d --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/exception/WebEidException.kt @@ -0,0 +1,9 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.exception + +class WebEidException( + val errorCode: WebEidErrorCode, + override val message: String, + val responseUri: String, +) : Exception() diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidErrorCodes.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidErrorCodes.kt deleted file mode 100644 index 5a3ca2b1..00000000 --- a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidErrorCodes.kt +++ /dev/null @@ -1,11 +0,0 @@ -@file:Suppress("PackageName") - -package ee.ria.DigiDoc.webEid.utils - -object WebEidErrorCodes { - const val INVALID_REQUEST = "ERR_WEBEID_INVALID_REQUEST" - const val UNKNOWN = "ERR_WEBEID_UNKNOWN" - - const val INVALID_REQUEST_MESSAGE = "Invalid authentication request" - const val UNKNOWN_MESSAGE = "Unexpected error occurred" -} diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt new file mode 100644 index 00000000..329af3ab --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt @@ -0,0 +1,100 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.utils + +import android.net.Uri +import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest +import ee.ria.DigiDoc.webEid.domain.model.WebEidSignRequest +import ee.ria.DigiDoc.webEid.exception.WebEidErrorCode.ERR_WEBEID_MOBILE_INVALID_REQUEST +import ee.ria.DigiDoc.webEid.exception.WebEidException +import org.json.JSONObject +import java.net.URI +import java.net.URISyntaxException +import java.util.Base64 + +object WebEidRequestParser { + private const val MIN_CHALLENGE_LENGTH = 44 + private const val MAX_CHALLENGE_LENGTH = 128 + private const val MAX_ORIGIN_LENGTH = 255 + + fun parseAuthUri(authUri: Uri): WebEidAuthRequest { + val request = decodeUriFragment(authUri) + val challenge = request.getString("challenge") + val responseUri = validateResponseUri(request.getString("login_uri")) + val origin = parseOrigin(responseUri) + if (challenge.isNullOrBlank() || + challenge.length < MIN_CHALLENGE_LENGTH || + challenge.length > MAX_CHALLENGE_LENGTH + ) { + throw WebEidException( + ERR_WEBEID_MOBILE_INVALID_REQUEST, + "Invalid challenge length", + responseUri.toString(), + ) + } + + return WebEidAuthRequest( + challenge = challenge, + loginUri = responseUri.toString(), + getSigningCertificate = request.optBoolean("get_signing_certificate", false), + origin = origin, + ) + } + + fun parseSignUri(uri: Uri): WebEidSignRequest { + val request = decodeUriFragment(uri) + val responseUri = validateResponseUri(request.getString("response_uri")) + + return WebEidSignRequest( + responseUri = responseUri.toString(), + signCertificate = request.getString("sign_certificate"), + hash = request.getString("hash"), + hashFunction = request.getString("hash_function"), + ) + } + + private fun validateResponseUri(responseUri: String): URI { + try { + val uri = URI(responseUri) + if (uri.scheme.isNullOrBlank()) { + throw IllegalArgumentException("Invalid response URI scheme") + } + if (!uri.scheme.equals("https", ignoreCase = true)) { + throw IllegalArgumentException("Response URI must use HTTPS scheme") + } + if (uri.host.isNullOrBlank()) { + throw IllegalArgumentException("Invalid response URI host") + } + if (uri.userInfo != null) { + throw IllegalArgumentException("Response URI must not contain userinfo") + } + return uri + } catch (e: URISyntaxException) { + throw IllegalArgumentException("Invalid response URI", e) + } + } + + private fun decodeUriFragment(uri: Uri): JSONObject { + try { + val fragment = + uri.fragment ?: throw IllegalArgumentException("Missing URI fragment") + val decoded = String(Base64.getDecoder().decode(fragment)) + return JSONObject(decoded) + } catch (e: Exception) { + throw IllegalArgumentException("Invalid URI fragment", e) + } + } + + private fun parseOrigin(uri: URI): String { + val portPart = if (uri.port != -1) ":${uri.port}" else "" + val origin = "${uri.scheme}://${uri.host}$portPart" + if (origin.length > MAX_ORIGIN_LENGTH) { + throw WebEidException( + ERR_WEBEID_MOBILE_INVALID_REQUEST, + "Invalid origin length", + uri.toString(), + ) + } + return origin + } +} diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt index 39b859f2..94db64d6 100644 --- a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt @@ -2,54 +2,35 @@ package ee.ria.DigiDoc.webEid.utils +import android.net.Uri +import android.util.Base64 import androidx.core.net.toUri +import ee.ria.DigiDoc.webEid.exception.WebEidErrorCode import org.json.JSONObject -import java.util.Base64 - -data class WebEidError(val code: String, val message: String) object WebEidResponseUtil { - fun createErrorRedirect( - loginUri: String, - code: String = WebEidErrorCodes.UNKNOWN, - message: String = WebEidErrorCodes.UNKNOWN_MESSAGE, - ): String { - val errorJson = - JSONObject() - .put("code", code) - .put("message", message) - .toString() - - val encoded = base64UrlEncode(errorJson) - return appendFragment(loginUri, encoded) - } - - fun createSuccessRedirect(loginUri: String): String { - val successJson = - JSONObject() - .put("web_eid_auth_token", "mock-web-eid-auth-token") - .put("eid_instance_attestation", "mock-attestation") - .put("eid_instance_attestation_proof", "mock-attestation-proof") - .toString() - - val encoded = base64UrlEncode(successJson) - return appendFragment(loginUri, encoded) - } - - private fun base64UrlEncode(input: String): String { - return Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(input.toByteArray(Charsets.UTF_8)) - } - - private fun appendFragment( - loginUri: String, - fragment: String, - ): String { - val uri = loginUri.toUri() - return uri.buildUpon() - .fragment(fragment) + fun createErrorPayload( + code: WebEidErrorCode, + message: String, + ): JSONObject = + JSONObject() + .put("error", true) + .put("code", code) + .put("message", message) + + fun createResponseUri( + responseUri: String, + payload: JSONObject, + ): Uri { + val encodedPayload = + Base64.encodeToString( + payload.toString().toByteArray(Charsets.UTF_8), + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP, + ) + return responseUri + .toUri() + .buildUpon() + .fragment(encodedPayload) .build() - .toString() } } From df6d21aa90179f8b91aea3a836dffc01409e5ec5 Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Fri, 17 Oct 2025 11:06:25 +0300 Subject: [PATCH 03/10] NFC-83 Implement certificate and signing logic --- .../DigiDoc/viewmodel/WebEidViewModelTest.kt | 285 +++++++++++++++++- .../kotlin/ee/ria/DigiDoc/MainActivity.kt | 13 +- .../DigiDoc/domain/model/IdentityAction.kt | 1 + .../DigiDoc/domain/preferences/DataStore.kt | 29 ++ .../ee/ria/DigiDoc/fragment/WebEidFragment.kt | 1 + .../DigiDoc/fragment/screen/WebEidScreen.kt | 176 +++++++++-- .../DigiDoc/ui/component/signing/NFCView.kt | 94 +++++- .../ee/ria/DigiDoc/viewmodel/NFCViewModel.kt | 227 ++++++++++++++ .../ria/DigiDoc/viewmodel/WebEidViewModel.kt | 66 +++- app/src/main/res/values-et/strings.xml | 8 + app/src/main/res/values/donottranslate.xml | 1 + app/src/main/res/values/strings.xml | 8 + .../DigiDoc/domain/service/IdCardService.kt | 7 + .../domain/service/IdCardServiceImpl.kt | 11 + .../DigiDoc/webEid/WebEidRequestParserTest.kt | 140 +++++++++ .../DigiDoc/webEid/WebEidSignServiceTest.kt | 106 +++++++ .../ria/DigiDoc/webEid/di/AppModulesTest.kt | 36 +++ .../webEid/exception/WebEidExceptionTest.kt | 27 ++ .../webEid/utils/WebEidAlgorithmUtilTest.kt | 97 ++++++ .../webEid/utils/WebEidResponseUtilTest.kt | 24 +- .../DigiDoc/webEid/WebEidAuthServiceImpl.kt | 46 +-- .../ria/DigiDoc/webEid/WebEidSignService.kt | 14 + .../DigiDoc/webEid/WebEidSignServiceImpl.kt | 51 ++++ .../ee/ria/DigiDoc/webEid/di/AppModules.kt | 5 + .../webEid/domain/model/WebEidSignRequest.kt | 6 +- .../webEid/utils/WebEidAlgorithmUtil.kt | 55 ++++ .../webEid/utils/WebEidRequestParser.kt | 33 +- 27 files changed, 1465 insertions(+), 102 deletions(-) create mode 100644 web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidSignServiceTest.kt create mode 100644 web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/di/AppModulesTest.kt create mode 100644 web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/exception/WebEidExceptionTest.kt create mode 100644 web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtilTest.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidSignService.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidSignServiceImpl.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtil.kt diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt index 1b87e251..fbae22f7 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt @@ -6,7 +6,9 @@ import android.net.Uri import android.util.Base64.URL_SAFE import android.util.Base64.decode import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import ee.ria.DigiDoc.R import ee.ria.DigiDoc.webEid.WebEidAuthService +import ee.ria.DigiDoc.webEid.WebEidSignService import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first @@ -32,12 +34,15 @@ class WebEidViewModelTest { @Mock private lateinit var authService: WebEidAuthService + @Mock + private lateinit var signService: WebEidSignService + private lateinit var viewModel: WebEidViewModel @Before fun setup() { MockitoAnnotations.openMocks(this) - viewModel = WebEidViewModel(authService) + viewModel = WebEidViewModel(authService, signService) } @Test @@ -127,20 +132,13 @@ class WebEidViewModelTest { } @Test - fun webEidViewModel_handleSign_parsesSignUriAndSetsStateFlow() { - val uri = - Uri.parse( - "web-eid-mobile://sign#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwic2lnbl9jZXJ0aWZpY2F0ZSI6InNpZ25pbmdfY2VydGlmaWNhdGUiLCJoYXNoIjoiaGFzaCIsImhhc2hfZnVuY3Rpb24iOiJoYXNoX2Z1bmN0aW9uIn0", - ) - viewModel.handleSign(uri) - val authRequest = viewModel.authRequest.value - val signRequest = viewModel.signRequest.value - assert(authRequest == null) - assert(signRequest != null) - assertEquals("https://example.com/response", signRequest?.responseUri) - assertEquals("signing_certificate", signRequest?.signCertificate) - assertEquals("hash", signRequest?.hash) - assertEquals("hash_function", signRequest?.hashFunction) + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleAuth_emitDialogErrorWhenGenericException() { + runTest(UnconfinedTestDispatcher()) { + val uri = Uri.parse("web-eid-mobile://auth#{}") + viewModel.handleAuth(uri) + assertEquals(R.string.web_eid_invalid_auth_request_error, viewModel.dialogError.value) + } } @Test @@ -169,7 +167,38 @@ class WebEidViewModelTest { assert(emittedUri.fragment != null) val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) val jsonPayload = JSONObject(decodedPayload) - val authToken = jsonPayload.getJSONObject("auth-token") + val authToken = jsonPayload.getJSONObject("auth_token") + assertEquals("web-eid:1.0", authToken.getString("format")) + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleWebEidAuthResult_buildsAuthTokenWithoutSigningCert() { + runTest(UnconfinedTestDispatcher()) { + val cert = byteArrayOf(1, 2, 3) + val signingCert = byteArrayOf(9, 9, 9) + val signature = byteArrayOf(4, 5, 6) + val uri = + Uri.parse( + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6ZmFsc2V9", + ) + whenever(authService.buildAuthToken(cert, null, signature)) + .thenReturn(JSONObject().put("format", "web-eid:1.0")) + val deferred = + async { + viewModel.relyingPartyResponseEvents.first() + } + viewModel.handleAuth(uri) + viewModel.handleWebEidAuthResult(cert, signingCert, signature) + + verify(authService).buildAuthToken(cert, null, signature) + val emittedUri = deferred.await() + assert(emittedUri.toString().startsWith("https://example.com/response#")) + assert(emittedUri.fragment != null) + val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) + val jsonPayload = JSONObject(decodedPayload) + val authToken = jsonPayload.getJSONObject("auth_token") assertEquals("web-eid:1.0", authToken.getString("format")) } } @@ -205,4 +234,228 @@ class WebEidViewModelTest { assertEquals("Unexpected error", jsonPayload.getString("message")) } } + + @Test + fun webEidViewModel_handleCertificate_parsesCertificateUriAndSetsStateFlow() { + runTest { + val uri = + Uri.parse( + "web-eid-mobile://cert#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIn0", + ) + viewModel.handleCertificate(uri) + val authRequest = viewModel.authRequest.value + val signRequest = viewModel.signRequest.value + assert(authRequest == null) + assert(signRequest != null) + assertEquals("https://example.com/response", signRequest?.responseUri) + assertEquals(null, signRequest?.hash) + assertEquals(null, signRequest?.hashFunction) + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleCertificate_emitDialogErrorWhenGenericException() { + runTest(UnconfinedTestDispatcher()) { + val uri = Uri.parse("web-eid-mobile://cert#{}") + viewModel.handleCertificate(uri) + assertEquals( + R.string.web_eid_invalid_sign_request_error, + viewModel.dialogError.value, + ) + } + } + + @Test + fun webEidViewModel_handleSign_parsesSignUriAndSetsStateFlow() { + runTest { + val uri = + Uri.parse( + "web-eid-mobile://sign#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwic2lnbl9jZXJ0aWZpY2F0ZSI6InNpZ25pbmdfY2VydGlmaWNhdGUiLCJoYXNoIjoiaGFzaCIsImhhc2hfZnVuY3Rpb24iOiJoYXNoX2Z1bmN0aW9uIn0", + ) + viewModel.handleSign(uri) + val authRequest = viewModel.authRequest.value + val signRequest = viewModel.signRequest.value + assert(authRequest == null) + assert(signRequest != null) + assertEquals("https://example.com/response", signRequest?.responseUri) + assertEquals("hash", signRequest?.hash) + assertEquals("hash_function", signRequest?.hashFunction) + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleSign_emitErrorResponseEventWhenWebEidException() { + runTest(UnconfinedTestDispatcher()) { + val uri = + Uri.parse( + "web-eid-mobile://sign#" + + "eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwic2lnbl9jZXJ0aWZpY2F0ZSI6InNpZ25lcnNlcnQiLCJoYXNoIjoiIn0", + ) + + val deferred = + async { + viewModel.relyingPartyResponseEvents.first() + } + + viewModel.handleSign(uri) + + val emittedUri = deferred.await() + assert(emittedUri.toString().startsWith("https://example.com/response#")) + assert(emittedUri.fragment != null) + val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) + val jsonPayload = JSONObject(decodedPayload) + assertEquals("ERR_WEBEID_MOBILE_INVALID_REQUEST", jsonPayload.getString("code")) + assertEquals( + "Invalid signing request: missing hash or hash_function", + jsonPayload.getString("message"), + ) + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleSign_emitDialogErrorWhenGenericException() { + runTest(UnconfinedTestDispatcher()) { + val uri = Uri.parse("web-eid-mobile://sign#{}") + viewModel.handleSign(uri) + assertEquals(R.string.web_eid_invalid_sign_request_error, viewModel.dialogError.value) + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleUnknown_emitDialogError() { + runTest(UnconfinedTestDispatcher()) { + val uri = Uri.parse("web-eid-mobile://unknown#{}") + viewModel.handleUnknown(uri) + assertEquals( + R.string.web_eid_invalid_sign_request_error, + viewModel.dialogError.value, + ) + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleWebEidCertificateResult_buildsCertificatePayloadAndEmitsResponseEvent() { + runTest(UnconfinedTestDispatcher()) { + val signingCert = byteArrayOf(1, 2, 3) + val uri = + Uri.parse( + "web-eid-mobile://sign#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwic2lnbl9jZXJ0aWZpY2F0ZSI6InNpZ25pbmdfY2VydGlmaWNhdGUiLCJoYXNoIjoiaGFzaCIsImhhc2hfZnVuY3Rpb24iOiJoYXNoX2Z1bmN0aW9uIn0", + ) + viewModel.handleSign(uri) + + whenever(signService.buildCertificatePayload(signingCert)) + .thenReturn(JSONObject().put("certificate", "mock-cert")) + + val deferred = + async { + viewModel.relyingPartyResponseEvents.first() + } + + viewModel.handleWebEidCertificateResult(signingCert) + + verify(signService).buildCertificatePayload(signingCert) + val emittedUri = deferred.await() + assert(emittedUri.toString().startsWith("https://example.com/response#")) + assert(emittedUri.fragment != null) + val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) + val jsonPayload = JSONObject(decodedPayload) + val certificateValue = jsonPayload.getString("certificate") + assertEquals("mock-cert", certificateValue) + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleWebEidCertificateResult_emitErrorResponseEventWhenException() { + runTest(UnconfinedTestDispatcher()) { + val signingCert = byteArrayOf(1, 2, 3) + val uri = + Uri.parse( + "web-eid-mobile://sign#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwic2lnbl9jZXJ0aWZpY2F0ZSI6InNpZ25pbmdfY2VydGlmaWNhdGUiLCJoYXNoIjoiaGFzaCIsImhhc2hfZnVuY3Rpb24iOiJoYXNoX2Z1bmN0aW9uIn0", + ) + viewModel.handleSign(uri) + + whenever(signService.buildCertificatePayload(signingCert)) + .thenThrow(RuntimeException("Test exception")) + + val deferred = + async { + viewModel.relyingPartyResponseEvents.first() + } + + viewModel.handleWebEidCertificateResult(signingCert) + + verify(signService).buildCertificatePayload(signingCert) + val emittedUri = deferred.await() + assert(emittedUri.toString().startsWith("https://example.com/response#")) + assert(emittedUri.fragment != null) + val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) + val jsonPayload = JSONObject(decodedPayload) + assertEquals("ERR_WEBEID_MOBILE_UNKNOWN_ERROR", jsonPayload.getString("code")) + assertEquals("Unexpected error", jsonPayload.getString("message")) + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleWebEidSignResult_buildsSignPayloadAndEmitsResponseEvent() { + runTest(UnconfinedTestDispatcher()) { + val signingCert = "mock-sign-cert" + val signature = byteArrayOf(1, 2, 3) + val responseUri = "https://example.com/response" + + whenever(signService.buildSignPayload(signingCert, signature)) + .thenReturn(JSONObject().put("signature", "mock-signature")) + + val deferred = + async { + viewModel.relyingPartyResponseEvents.first() + } + + viewModel.handleWebEidSignResult(signingCert, signature, responseUri) + + verify(signService).buildSignPayload(signingCert, signature) + val emittedUri = deferred.await() + assert(emittedUri.toString().startsWith("https://example.com/response#")) + assert(emittedUri.fragment != null) + val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) + val jsonPayload = JSONObject(decodedPayload) + val signValue = jsonPayload.getString("signature") + assertEquals("mock-signature", signValue) + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleWebEidSignResult_emitErrorResponseEventWhenException() { + runTest(UnconfinedTestDispatcher()) { + val signingCert = "mock-sign-cert" + val signature = byteArrayOf(1, 2, 3) + val responseUri = "https://example.com/response" + + whenever(signService.buildSignPayload(signingCert, signature)) + .thenThrow(RuntimeException("Test exception")) + + val deferred = + async { + viewModel.relyingPartyResponseEvents.first() + } + + viewModel.handleWebEidSignResult(signingCert, signature, responseUri) + + verify(signService).buildSignPayload(signingCert, signature) + val emittedUri = deferred.await() + assert(emittedUri.toString().startsWith("https://example.com/response#")) + assert(emittedUri.fragment != null) + val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) + val jsonPayload = JSONObject(decodedPayload) + assertEquals("ERR_WEBEID_MOBILE_UNKNOWN_ERROR", jsonPayload.getString("code")) + assertEquals("Unexpected error", jsonPayload.getString("message")) + } + } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt b/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt index c0f9e289..a83126e5 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt @@ -123,11 +123,12 @@ class MainActivity : val locale = dataStore.getLocale() ?: getLocale("en") val webEidUri = intent?.data?.takeIf { it.scheme == "web-eid-mobile" } - val externalFileUris = if (webEidUri != null) { - listOf() - } else { - getExternalFileUris(intent) - } + val externalFileUris = + if (webEidUri != null) { + listOf() + } else { + getExternalFileUris(intent) + } localeUtil.updateLocale(applicationContext, locale) @@ -172,7 +173,7 @@ class MainActivity : RIADigiDocTheme(darkTheme = useDarkMode) { RIADigiDocAppScreen( externalFileUris = externalFileUris, - webEidUri = webEidUri + webEidUri = webEidUri, ) } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/domain/model/IdentityAction.kt b/app/src/main/kotlin/ee/ria/DigiDoc/domain/model/IdentityAction.kt index 19bb17ba..87978994 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/domain/model/IdentityAction.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/domain/model/IdentityAction.kt @@ -27,4 +27,5 @@ enum class IdentityAction( SIGN("SIGN"), AUTH("AUTH"), DECRYPT("DECRYPT"), + CERTIFICATE("CERTIFICATE"), } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/domain/preferences/DataStore.kt b/app/src/main/kotlin/ee/ria/DigiDoc/domain/preferences/DataStore.kt index 6365668a..f2fc84c2 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/domain/preferences/DataStore.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/domain/preferences/DataStore.kt @@ -21,6 +21,7 @@ package ee.ria.DigiDoc.domain.preferences +import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences import android.content.res.Resources @@ -119,6 +120,34 @@ class DataStore errorLog(logTag, "Unable to save CAN") } + fun getSigningCertificate(): String { + val encryptedPrefs = getEncryptedPreferences(context) + if (encryptedPrefs == null) { + errorLog(logTag, "Unable to read signing certificate") + return "" + } + + val currentCan = getCanNumber() + val key = "${resources.getString(R.string.main_settings_signing_cert_key)}_$currentCan" + return encryptedPrefs.getString(key, "") ?: "" + } + + @SuppressLint("ApplySharedPref") + fun setSigningCertificate(cert: String) { + val encryptedPrefs = getEncryptedPreferences(context) + if (encryptedPrefs == null) { + errorLog(logTag, "Unable to save signing certificate") + return + } + + val currentCanNumber = getCanNumber() + val key = "${resources.getString(R.string.main_settings_signing_cert_key)}_$currentCanNumber" + val editor = encryptedPrefs.edit() + + editor.remove(key).commit() + if (cert.isNotEmpty()) editor.putString(key, cert).commit() + } + fun getPhoneNo(): String = preferences.getString( resources.getString(R.string.main_settings_phone_no_key), diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt index 50932f39..cf47bbd2 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt @@ -57,6 +57,7 @@ fun WebEidFragment( webEidUri?.let { when (it.host) { "auth" -> viewModel.handleAuth(it) + "cert" -> viewModel.handleCertificate(it) "sign" -> viewModel.handleSign(it) else -> { viewModel.handleUnknown(it) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt index e9d06bcf..ea87fc4a 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.asFlow @@ -92,6 +93,10 @@ fun WebEidScreen( var webEidAuthenticateAction by remember { mutableStateOf<() -> Unit>({}) } var cancelWebEidAuthenticateAction by remember { mutableStateOf<() -> Unit>({}) } var isValidToWebEidAuthenticate by remember { mutableStateOf(false) } + + val signRequest = viewModel.signRequest.collectAsState().value + var webEidSignAction by remember { mutableStateOf<() -> Unit>({}) } + var cancelWebEidSignAction by remember { mutableStateOf<() -> Unit>({}) } var nfcSupported by remember { mutableStateOf(false) } val isSettingsMenuBottomSheetVisible = rememberSaveable { mutableStateOf(false) } @@ -224,8 +229,19 @@ fun WebEidScreen( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(MSPadding), ) { + val responseUri = signRequest?.responseUri?.lowercase() ?: "" + val isCertificateFlow = responseUri.contains("/certificate") && !responseUri.contains("/signature") + + val title = + when { + authRequest != null -> stringResource(R.string.web_eid_auth_title) + signRequest != null && isCertificateFlow -> stringResource(R.string.web_eid_certificate_title) + signRequest != null -> stringResource(R.string.web_eid_sign_title) + else -> stringResource(R.string.web_eid_auth_title) + } + Text( - text = stringResource(R.string.web_eid_auth_title), + text = title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.semantics { heading() }, @@ -236,10 +252,18 @@ fun WebEidScreen( modifier = Modifier.fillMaxWidth(), ) { Text( - text = authRequest.origin, + text = stringResource(R.string.web_eid_auth_consent_text), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + Text( + text = authRequest.origin.take(80), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) Text( text = stringResource(R.string.web_eid_requests_authentication), @@ -282,26 +306,144 @@ fun WebEidScreen( isAuthenticated = { _, _ -> }, webEidViewModel = viewModel, ) + } else if (signRequest != null) { + val responseUri = signRequest.responseUri.lowercase() + val isCertificateFlow = responseUri.contains("/certificate") && !responseUri.contains("/signature") + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.web_eid_certificate_consent_text), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = XSPadding), + ) + Text( + text = signRequest.origin.take(80), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = + if (isCertificateFlow) { + stringResource(R.string.web_eid_requests_certificate) + } else { + stringResource(R.string.web_eid_requests_signing) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + } + + if (isCertificateFlow) { + NFCView( + activity = activity, + identityAction = IdentityAction.CERTIFICATE, + isCertificate = true, + showPinField = false, + isSigning = false, + isDecrypting = false, + isWebEidAuthenticating = isWebEidAuthenticating, + onError = { + isWebEidAuthenticating = false + cancelWebEidSignAction() + }, + onSuccess = { + isWebEidAuthenticating = false + navController.navigateUp() + }, + sharedSettingsViewModel = sharedSettingsViewModel, + sharedContainerViewModel = sharedContainerViewModel, + isSupported = { supported -> nfcSupported = supported }, + isValidToWebEidAuthenticate = { isValid -> isValidToWebEidAuthenticate = isValid }, + signWebEidAction = { action -> webEidSignAction = action }, + cancelWebEidSignAction = { action -> cancelWebEidSignAction = action }, + isValidToSign = {}, + isValidToDecrypt = {}, + isAuthenticated = { _, _ -> }, + webEidViewModel = viewModel, + ) + } else { + NFCView( + activity = activity, + identityAction = IdentityAction.SIGN, + isCertificate = false, + isSigning = false, + isDecrypting = false, + isWebEidAuthenticating = isWebEidAuthenticating, + onError = { + isWebEidAuthenticating = false + cancelWebEidSignAction() + }, + onSuccess = { + isWebEidAuthenticating = false + navController.navigateUp() + }, + sharedSettingsViewModel = sharedSettingsViewModel, + sharedContainerViewModel = sharedContainerViewModel, + isSupported = { supported -> nfcSupported = supported }, + isValidToWebEidAuthenticate = { isValid -> + isValidToWebEidAuthenticate = isValid + }, + signWebEidAction = { action -> webEidSignAction = action }, + cancelWebEidSignAction = { action -> cancelWebEidSignAction = action }, + isValidToSign = {}, + isValidToDecrypt = {}, + isAuthenticated = { _, _ -> }, + webEidViewModel = viewModel, + ) + } } else { Text(noAuthLabel) } if (!isWebEidAuthenticating && nfcSupported) { - Button( - onClick = { - isWebEidAuthenticating = true - webEidAuthenticateAction() - }, - enabled = isValidToWebEidAuthenticate, - modifier = Modifier.fillMaxWidth(), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text( - text = stringResource(R.string.web_eid_authenticate), - ) + if (authRequest != null) { + Button( + onClick = { + isWebEidAuthenticating = true + webEidAuthenticateAction() + }, + enabled = isValidToWebEidAuthenticate, + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(text = stringResource(R.string.web_eid_authenticate)) + } + } else if (signRequest != null) { + val responseUri = signRequest.responseUri.lowercase() + val isCertificateFlow = responseUri.contains("/certificate") && !responseUri.contains("/signature") + + Button( + onClick = { + isWebEidAuthenticating = true + webEidSignAction() + }, + enabled = isValidToWebEidAuthenticate, + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + text = + if (isCertificateFlow) { + stringResource(R.string.web_eid_get_certificate) + } else { + stringResource(R.string.web_eid_sign) + }, + ) + } } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt index 8ebc8eec..a6c47162 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt @@ -130,6 +130,7 @@ import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.Base64 @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable @@ -140,6 +141,7 @@ fun NFCView( isSigning: Boolean = false, isDecrypting: Boolean = false, isAuthenticating: Boolean = false, + isCertificate: Boolean = false, isWebEidAuthenticating: Boolean = false, onError: () -> Unit = {}, onSuccess: () -> Unit = {}, @@ -160,6 +162,8 @@ fun NFCView( cancelDecryptAction: (() -> Unit) -> Unit = {}, authenticateWebEidAction: (() -> Unit) -> Unit = {}, cancelWebEidAuthenticateAction: (() -> Unit) -> Unit = {}, + signWebEidAction: (() -> Unit) -> Unit = {}, + cancelWebEidSignAction: (() -> Unit) -> Unit = {}, isAuthenticated: (Boolean, IdCardData) -> Unit, webEidViewModel: WebEidViewModel? = null, ) { @@ -189,14 +193,27 @@ fun NFCView( ), ) } + var signingCert by rememberSaveable { + mutableStateOf(sharedSettingsViewModel.dataStore.getSigningCertificate()) + } var errorText by remember { mutableStateOf("") } val showErrorDialog = rememberSaveable { mutableStateOf(false) } val focusManager = LocalFocusManager.current val saveFormParams = { + val previousCanNumber = sharedSettingsViewModel.dataStore.getCanNumber() + val currentCanNumber = canNumber.text + if (shouldRememberMe) { - sharedSettingsViewModel.dataStore.setCanNumber(canNumber.text) + if (previousCanNumber != currentCanNumber) { + signingCert = "" + sharedSettingsViewModel.dataStore.setSigningCertificate("") + } + + sharedSettingsViewModel.dataStore.setCanNumber(currentCanNumber) + sharedSettingsViewModel.dataStore.setSigningCertificate(signingCert) } else { sharedSettingsViewModel.dataStore.setCanNumber("") + sharedSettingsViewModel.dataStore.setSigningCertificate("") } } @@ -236,6 +253,10 @@ fun NFCView( val originString = webEidAuth?.origin ?: "" val challengeString = webEidAuth?.challenge ?: "" + val webEidSign = webEidViewModel?.signRequest?.collectAsState()?.value + val responseUriString = webEidSign?.responseUri ?: "" + val hashString = webEidSign?.hash ?: "" + BackHandler { nfcViewModel.handleBackButton() if (isSigning || isDecrypting || isAuthenticating) { @@ -327,6 +348,8 @@ fun NFCView( LaunchedEffect(nfcViewModel.webEidAuthResult) { nfcViewModel.webEidAuthResult.asFlow().collect { result -> result?.let { (authCert, signingCert, signature) -> + val encodedCert = Base64.getEncoder().encodeToString(signingCert) + sharedSettingsViewModel.dataStore.setSigningCertificate(encodedCert) webEidViewModel?.handleWebEidAuthResult(authCert, signingCert, signature) nfcViewModel.resetWebEidAuthResult() onSuccess() @@ -334,6 +357,28 @@ fun NFCView( } } + LaunchedEffect(nfcViewModel.webEidCertificateResult) { + nfcViewModel.webEidCertificateResult.asFlow().collect { result -> + result?.let { (signCert, _) -> + sharedSettingsViewModel.dataStore.setSigningCertificate(signCert) + val certBytes = Base64.getDecoder().decode(signCert) + webEidViewModel?.handleWebEidCertificateResult(certBytes) + nfcViewModel.resetWebEidCertificateResult() + onSuccess() + } + } + } + + LaunchedEffect(nfcViewModel.webEidSignResult) { + nfcViewModel.webEidSignResult.asFlow().collect { result -> + result?.let { (signCert, signature, responseUri) -> + webEidViewModel?.handleWebEidSignResult(signCert, signature, responseUri) + nfcViewModel.resetWebEidSignResult() + onSuccess() + } + } + } + LaunchedEffect(nfcViewModel.dialogError) { pinCode.value.fill(0) nfcViewModel.dialogError @@ -512,11 +557,15 @@ fun NFCView( nfcImage = R.drawable.ic_icon_nfc val isValid = - nfcViewModel.positiveButtonEnabled( - canNumber.text, - pinCode.value, - codeType, - ) + if (isCertificate) { + nfcViewModel.isCANLengthValid(canNumber.text) + } else { + nfcViewModel.positiveButtonEnabled( + canNumber.text, + pinCode.value, + codeType, + ) + } val isValidForAuthenticating = nfcViewModel.isCANLengthValid(canNumber.text) @@ -598,6 +647,35 @@ fun NFCView( ) } } + signWebEidAction { + saveFormParams() + scope.launch(IO) { + val isCertificateFlow = responseUriString.contains("/certificate", ignoreCase = true) + val cachedCert = sharedSettingsViewModel.dataStore.getSigningCertificate() + + if (isCertificateFlow) { + if (cachedCert.isNotEmpty()) { + val certBytes = Base64.getDecoder().decode(cachedCert) + webEidViewModel?.handleWebEidCertificateResult(certBytes) + onSuccess() + } else { + nfcViewModel.performNFCWebEidCertificateWorkRequest( + activity = activity, + canNumber = canNumber.text, + ) + } + } else { + nfcViewModel.performNFCWebEidSignWorkRequest( + activity = activity, + context = context, + canNumber = canNumber.text, + pin2Code = pinCode.value, + responseUri = responseUriString, + hash = hashString, + ) + } + } + } cancelAction { nfcViewModel.handleBackButton() scope.launch(IO) { @@ -612,6 +690,10 @@ fun NFCView( nfcViewModel.handleBackButton() nfcViewModel.cancelWebEidAuthWorkRequest() } + cancelWebEidSignAction { + nfcViewModel.handleBackButton() + nfcViewModel.cancelWebEidSignWorkRequest() + } } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt index a7ce4d8d..c17abb9a 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt @@ -104,6 +104,10 @@ class NFCViewModel val dialogError: LiveData = _dialogError private val _webEidAuthResult = MutableLiveData?>() val webEidAuthResult: LiveData?> = _webEidAuthResult + private val _webEidSignResult = MutableLiveData?>() + val webEidSignResult: LiveData?> = _webEidSignResult + private val _webEidCertificateResult = MutableLiveData?>() + val webEidCertificateResult: LiveData?> = _webEidCertificateResult private val dialogMessages: ImmutableMap = ImmutableMap @@ -144,6 +148,14 @@ class NFCViewModel _webEidAuthResult.postValue(null) } + fun resetWebEidSignResult() { + _webEidSignResult.postValue(null) + } + + fun resetWebEidCertificateResult() { + _webEidCertificateResult.postValue(null) + } + fun shouldShowCANNumberError(canNumber: String?): Boolean = ( !canNumber.isNullOrEmpty() && @@ -207,6 +219,10 @@ class NFCViewModel nfcSmartCardReaderManager.disableNfcReaderMode() } + fun cancelWebEidSignWorkRequest() { + nfcSmartCardReaderManager.disableNfcReaderMode() + } + suspend fun checkNFCStatus(nfcStatus: NfcStatus) { withContext(Main) { _nfcStatus.postValue(nfcStatus) @@ -735,6 +751,217 @@ class NFCViewModel ) } + suspend fun performNFCWebEidCertificateWorkRequest( + activity: Activity, + canNumber: String, + ) { + activity.requestedOrientation = activity.resources.configuration.orientation + resetValues() + + withContext(Main) { + _message.postValue(R.string.signature_update_nfc_hold) + } + + checkNFCStatus( + nfcSmartCardReaderManager.startDiscovery(activity) { nfcReader, exc -> + if ((nfcReader != null) && (exc == null)) { + try { + CoroutineScope(Main).launch { + _message.postValue(R.string.signature_update_nfc_detected) + } + + val card = TokenWithPace.create(nfcReader) + card.tunnel(canNumber) + + val signingCert = card.certificate(CertificateType.SIGNING) + val signingCertB64 = Base64.getEncoder().encodeToString(signingCert) + + CoroutineScope(Main).launch { + _webEidCertificateResult.postValue(signingCertB64 to "") + } + } catch (ex: SmartCardReaderException) { + _decryptStatus.postValue(false) + + if (ex.message?.contains("TagLostException") == true) { + _errorState.postValue(Triple(R.string.signature_update_nfc_tag_lost, null, null)) + } else if (ex is ApduResponseException) { + _errorState.postValue( + Triple(R.string.signature_update_nfc_technical_error, null, null), + ) + } else if (ex is PaceTunnelException) { + _errorState.postValue( + Triple(R.string.signature_update_nfc_wrong_can, null, null), + ) + } else { + showTechnicalError(ex) + } + + errorLog(logTag, "Exception: " + ex.message, ex) + } catch (ex: Exception) { + _decryptStatus.postValue(false) + + val message = ex.message ?: "" + + when { + message.contains("Failed to connect") || + message.contains("Failed to create connection with host") -> + showNetworkError(ex) + + message.contains( + "Failed to create proxy connection with host", + ) -> showProxyError(ex) + + message.contains("Too Many Requests") -> + setErrorState( + SessionStatusResponseProcessStatus.TOO_MANY_REQUESTS, + ) + + message.contains("OCSP response not in valid time slot") -> + setErrorState( + SessionStatusResponseProcessStatus.OCSP_INVALID_TIME_SLOT, + ) + + message.contains("No lock found with certificate key") -> + showNoLockFoundError(ex) + + else -> showTechnicalError(ex) + } + + errorLog(logTag, "Exception: " + ex.message, ex) + } finally { + nfcSmartCardReaderManager.disableNfcReaderMode() + activity.requestedOrientation = + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + }, + ) + } + + suspend fun performNFCWebEidSignWorkRequest( + activity: Activity, + context: Context, + canNumber: String, + pin2Code: ByteArray?, + responseUri: String, + hash: String, + ) { + val pinType = context.getString(R.string.signature_id_card_pin2) + activity.requestedOrientation = activity.resources.configuration.orientation + resetValues() + + withContext(Main) { + _message.postValue(R.string.signature_update_nfc_hold) + } + + checkNFCStatus( + nfcSmartCardReaderManager.startDiscovery(activity) { nfcReader, exc -> + if ((nfcReader != null) && (exc == null)) { + try { + CoroutineScope(Main).launch { + _message.postValue(R.string.signature_update_nfc_detected) + } + val card = TokenWithPace.create(nfcReader) + card.tunnel(canNumber) + val signerCert = card.certificate(CertificateType.SIGNING) + val signerCertB64 = Base64.getEncoder().encodeToString(signerCert) + val hashBytes = Base64.getDecoder().decode(hash) + val (_, signatureArray) = idCardService.sign(card, pin2Code, hashBytes) + + CoroutineScope(Main).launch { + _shouldResetPIN.postValue(true) + _signStatus.postValue(true) + _webEidSignResult.postValue( + Triple(signerCertB64, signatureArray, responseUri), + ) + } + } catch (ex: SmartCardReaderException) { + _signStatus.postValue(false) + + if (ex.message?.contains("TagLostException") == true) { + _errorState.postValue(Triple(R.string.signature_update_nfc_tag_lost, null, null)) + } else if (ex.message?.contains("PIN2 verification failed") == true && + ex.message?.contains("Retries left: 2") == true + ) { + _shouldResetPIN.postValue(true) + _errorState.postValue(Triple(R.string.id_card_sign_pin_invalid, pinType, 2)) + } else if (ex.message?.contains("PIN2 verification failed") == true && + ex.message?.contains("Retries left: 1") == true + ) { + _shouldResetPIN.postValue(true) + _errorState.postValue(Triple(R.string.id_card_sign_pin_invalid_final, pinType, null)) + } else if (ex.message?.contains("PIN2 verification failed") == true && + ex.message?.contains("Retries left: 0") == true + ) { + _shouldResetPIN.postValue(true) + _errorState.postValue( + Triple(R.string.id_card_sign_pin_locked, pinType, null), + ) + } else if (ex is ApduResponseException) { + _errorState.postValue( + Triple(R.string.signature_update_nfc_technical_error, null, null), + ) + } else if (ex is PaceTunnelException) { + _errorState.postValue( + Triple(R.string.signature_update_nfc_wrong_can, null, null), + ) + } else { + showTechnicalError(ex) + } + + errorLog(logTag, "Exception: " + ex.message, ex) + } catch (ex: Exception) { + _signStatus.postValue(false) + _shouldResetPIN.postValue(true) + + val message = ex.message ?: "" + + when { + message.contains("Failed to connect") || + message.contains("Failed to create connection with host") -> + showNetworkError(ex) + + message.contains( + "Failed to create proxy connection with host", + ) -> showProxyError(ex) + + message.contains("Too Many Requests") -> + setErrorState( + SessionStatusResponseProcessStatus.TOO_MANY_REQUESTS, + ) + + message.contains("OCSP response not in valid time slot") -> + setErrorState( + SessionStatusResponseProcessStatus.OCSP_INVALID_TIME_SLOT, + ) + + message.contains("Certificate status: revoked") -> + showRevokedCertificateError( + ex, + ) + + message.contains("Certificate status: unknown") -> + showUnknownCertificateError( + ex, + ) + + else -> showTechnicalError(ex) + } + + errorLog(logTag, "Exception: " + ex.message, ex) + } finally { + if (null != pin2Code && pin2Code.isNotEmpty()) { + Arrays.fill(pin2Code, 0.toByte()) + } + nfcSmartCardReaderManager.disableNfcReaderMode() + activity.requestedOrientation = + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + }, + ) + } + fun handleBackButton() { _shouldResetPIN.postValue(true) resetValues() diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt index b2bd9567..8d40a2a5 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt @@ -10,6 +10,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import ee.ria.DigiDoc.R import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog import ee.ria.DigiDoc.webEid.WebEidAuthService +import ee.ria.DigiDoc.webEid.WebEidSignService import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest import ee.ria.DigiDoc.webEid.domain.model.WebEidSignRequest import ee.ria.DigiDoc.webEid.exception.WebEidErrorCode @@ -30,6 +31,7 @@ class WebEidViewModel @Inject constructor( private val authService: WebEidAuthService, + private val signService: WebEidSignService, ) : ViewModel() { private val logTag = javaClass.simpleName private val _authRequest = MutableStateFlow(null) @@ -55,9 +57,23 @@ class WebEidViewModel } } - fun handleSign(uri: Uri) { + fun handleCertificate(uri: Uri) { + try { + _signRequest.value = WebEidRequestParser.parseCertificateUri(uri) + } catch (e: Exception) { + errorLog(logTag, "Unable parse Web eID certificate request: $uri", e) + _dialogError.postValue(R.string.web_eid_invalid_sign_request_error) + } + } + + suspend fun handleSign(uri: Uri) { try { _signRequest.value = WebEidRequestParser.parseSignUri(uri) + } catch (e: WebEidException) { + errorLog(logTag, "Invalid Web eID signing request: $uri", e) + val errorPayload = WebEidResponseUtil.createErrorPayload(e.errorCode, e.message) + val responseUri = WebEidResponseUtil.createResponseUri(e.responseUri, errorPayload) + _relyingPartyResponseEvents.emit(responseUri) } catch (e: Exception) { errorLog(logTag, "Unable parse Web eID signing request: $uri", e) _dialogError.postValue(R.string.web_eid_invalid_sign_request_error) @@ -84,7 +100,7 @@ class WebEidViewModel if (getSigningCertificate == true) signingCert else null, signature, ) - val payload = JSONObject().put("auth-token", token) + val payload = JSONObject().put("auth_token", token) val responseUri = WebEidResponseUtil.createResponseUri(loginUri, payload) _relyingPartyResponseEvents.emit(responseUri) } catch (e: Exception) { @@ -98,4 +114,50 @@ class WebEidViewModel _relyingPartyResponseEvents.emit(responseUri) } } + + suspend fun handleWebEidCertificateResult(signingCert: ByteArray) { + val signRequest = signRequest.value + val responseUri = signRequest?.responseUri + + if (responseUri.isNullOrBlank()) { + errorLog(logTag, "Missing responseUri in sign payload for certificate step") + return + } + + try { + val payload = signService.buildCertificatePayload(signingCert) + val response = WebEidResponseUtil.createResponseUri(responseUri, payload) + _relyingPartyResponseEvents.emit(response) + } catch (e: Exception) { + errorLog(logTag, "Unexpected error building certificate payload", e) + val errorPayload = + WebEidResponseUtil.createErrorPayload( + WebEidErrorCode.ERR_WEBEID_MOBILE_UNKNOWN_ERROR, + "Unexpected error", + ) + val errorUri = WebEidResponseUtil.createResponseUri(responseUri, errorPayload) + _relyingPartyResponseEvents.emit(errorUri) + } + } + + suspend fun handleWebEidSignResult( + signingCert: String, + signature: ByteArray, + responseUri: String, + ) { + try { + val payload = signService.buildSignPayload(signingCert, signature) + val response = WebEidResponseUtil.createResponseUri(responseUri, payload) + _relyingPartyResponseEvents.emit(response) + } catch (e: Exception) { + errorLog(logTag, "Unexpected error building sign payload", e) + val errorPayload = + WebEidResponseUtil.createErrorPayload( + WebEidErrorCode.ERR_WEBEID_MOBILE_UNKNOWN_ERROR, + "Unexpected error", + ) + val errorUri = WebEidResponseUtil.createResponseUri(responseUri, errorPayload) + _relyingPartyResponseEvents.emit(errorUri) + } + } } diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index ab62998e..2d041d8f 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -663,7 +663,15 @@ Autentimine Autentimine ID-kaardiga Autentimispäringut ei saadetud + Autentides nõustun oma nime ja isikukoodi edastamisega teenusepakkujale. + Kinnita + Vali sertifikaat + Sertifikaati valides nõustun oma nime ja isikukoodi edastamisega teenusepakkujale. + Allkirjastamine + Allkirjasta ID-kaardiga soovib autentimist + soovib sertifikaati + soovib allkirjastamist Ignoreeri Vigane Web eID päring Päringu viga diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 6007af78..8e254fdf 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -66,6 +66,7 @@ mainSettingsSignatureAddMethod mainSettingsUUID can + signingCert mainSettingsMobileNr mainSettingsPersonalCode mainSettingsSmartIdPersonalCode diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 23071167..fd1f42c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -663,7 +663,15 @@ Authenticate Authenticate with ID-card No auth payload received. + By authenticating, I agree to the transfer of my name and personal identification code to the service provider. + Confirm + Select a certificate + By choosing the certificate, I agree to the transfer of my name and personal identification code to the service provider. + Sign + Sign with ID-card requests authentication + requests certificate + requests signing Ignore Invalid Web eID request Request error diff --git a/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardService.kt b/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardService.kt index ba927284..b652c371 100644 --- a/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardService.kt +++ b/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardService.kt @@ -64,4 +64,11 @@ interface IdCardService { origin: String, challenge: String, ): Triple + + @Throws(Exception::class) + fun sign( + token: Token, + pin2: ByteArray?, + hash: ByteArray, + ): Pair } diff --git a/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImpl.kt b/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImpl.kt index e70f1781..8ca18b41 100644 --- a/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImpl.kt +++ b/id-card-lib/src/main/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImpl.kt @@ -191,4 +191,15 @@ class IdCardServiceImpl return Triple(authCert, signingCert, signature) } + + @Throws(Exception::class) + override fun sign( + token: Token, + pin2: ByteArray?, + hash: ByteArray, + ): Pair { + val signingCert = token.certificate(CertificateType.SIGNING) + val signature = token.calculateSignature(pin2, hash, false) + return Pair(signingCert, signature) + } } diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt index 9e81833f..dd5bebf6 100644 --- a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt @@ -7,8 +7,12 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest +import ee.ria.DigiDoc.webEid.domain.model.WebEidSignRequest +import ee.ria.DigiDoc.webEid.exception.WebEidErrorCode +import ee.ria.DigiDoc.webEid.exception.WebEidException import ee.ria.DigiDoc.webEid.utils.WebEidRequestParser import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Before @@ -41,6 +45,19 @@ class WebEidRequestParserTest { assertTrue(result.origin.startsWith("https://rp.example.com")) } + @Test + fun parseAuthUri_missingScheme_throwsException() { + val loginUri = "rp.example.com/auth/eid/login" + val uri = android.net.Uri.parse(createAuthUri("abc1234", loginUri, false)) + + val exception = + assertThrows(IllegalArgumentException::class.java) { + WebEidRequestParser.parseAuthUri(uri) + } + + assertEquals("Invalid response URI scheme", exception.message) + } + @Test fun parseAuthUri_invalidScheme_throwsException() { val loginUri = "http://rp.example.com/auth/eid/login" @@ -53,6 +70,19 @@ class WebEidRequestParserTest { assertEquals("Response URI must use HTTPS scheme", exception.message) } + @Test + fun parseAuthUri_emptyHost_throwsException() { + val loginUri = "https:///auth/eid/login" + val uri = android.net.Uri.parse(createAuthUri("abc1234", loginUri, false)) + + val exception = + assertThrows(IllegalArgumentException::class.java) { + WebEidRequestParser.parseAuthUri(uri) + } + + assertEquals("Invalid response URI host", exception.message) + } + @Test fun parseAuthUri_forbiddenUserInfo_throwsException() { val loginUri = "https://rp.example.com:pass@evil.example.com/auth/eid/login" @@ -65,6 +95,44 @@ class WebEidRequestParserTest { assertTrue(exception.message!!.contains("Response URI must not contain userinfo")) } + @Test + fun parseAuthUri_invalidResponseUri_throwsException() { + val loginUri = "://rp.example.com/auth/eid/login" + val uri = android.net.Uri.parse(createAuthUri("abc1234", loginUri, false)) + + val exception = + assertThrows(IllegalArgumentException::class.java) { + WebEidRequestParser.parseAuthUri(uri) + } + + assertTrue(exception.message!!.contains("Invalid response URI")) + } + + @Test + fun parseAuthUri_invalidChallengeLength_throwsWebEidException() { + val loginUri = "https://rp.example.com/auth/eid/login" + val json = + """ + { + "challenge": "abc123", + "login_uri": "$loginUri", + "get_signing_certificate": false + } + """.trimIndent() + + val encoded = Base64.getEncoder().encodeToString(json.toByteArray()) + val uri = android.net.Uri.parse("web-eid://auth#$encoded") + + val exception = + assertThrows(WebEidException::class.java) { + WebEidRequestParser.parseAuthUri(uri) + } + + assertEquals(WebEidErrorCode.ERR_WEBEID_MOBILE_INVALID_REQUEST, exception.errorCode) + assertTrue(exception.message.contains("Invalid challenge length")) + assertEquals(loginUri, exception.responseUri) + } + private fun createAuthUri( challenge: String, loginUri: String, @@ -91,4 +159,76 @@ class WebEidRequestParserTest { } assertTrue(exception.message!!.contains("Invalid URI fragment")) } + + @Test + fun parseAuthUri_originTooLong_throwsWebEidException() { + val longHost = "a".repeat(260) + val loginUri = "https://$longHost.com/auth/eid/login" + + val json = + """ + { + "challenge": "${"b".repeat(60)}", + "login_uri": "$loginUri", + "get_signing_certificate": false + } + """.trimIndent() + + val encoded = Base64.getEncoder().encodeToString(json.toByteArray()) + val uri = android.net.Uri.parse("web-eid://auth#$encoded") + + val exception = + assertThrows(WebEidException::class.java) { + WebEidRequestParser.parseAuthUri(uri) + } + + assertEquals(WebEidErrorCode.ERR_WEBEID_MOBILE_INVALID_REQUEST, exception.errorCode) + assertTrue(exception.message.contains("Invalid origin length")) + } + + @Test + fun parseSignUri_valid_withHashAndFunction_success() { + val responseUri = "https://rp.example.com/sign/response" + val uri = android.net.Uri.parse(createSignUri("abcd1234hash", "SHA-384")) + val result: WebEidSignRequest = WebEidRequestParser.parseSignUri(uri) + + assertEquals(responseUri, result.responseUri) + assertEquals("abcd1234hash", result.hash) + assertEquals("SHA-384", result.hashFunction) + } + + @Test + fun parseCertificateUri_valid_success() { + val responseUri = "https://rp.example.com/sign/response" + val uri = android.net.Uri.parse(createSignUri(null, null)) + val result: WebEidSignRequest = WebEidRequestParser.parseCertificateUri(uri) + + assertEquals(responseUri, result.responseUri) + assertNull(result.hash) + assertNull(result.hashFunction) + } + + @Test + fun parseSignUri_invalidBase64_throwsException() { + val uri = android.net.Uri.parse("web-eid://sign#%%%INVALID%%%") + val exception = + assertThrows(IllegalArgumentException::class.java) { + WebEidRequestParser.parseSignUri(uri) + } + assertTrue(exception.message!!.contains("Invalid URI fragment")) + } + + private fun createSignUri( + hash: String?, + hashFunction: String?, + ): String { + val responseUri = "https://rp.example.com/sign/response" + val sb = StringBuilder() + sb.append("{\"response_uri\":\"$responseUri\"") + if (hash != null) sb.append(",\"hash\":\"$hash\"") + if (hashFunction != null) sb.append(",\"hash_function\":\"$hashFunction\"") + sb.append("}") + val encoded = Base64.getEncoder().encodeToString(sb.toString().toByteArray()) + return "web-eid://sign#$encoded" + } } diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidSignServiceTest.kt b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidSignServiceTest.kt new file mode 100644 index 00000000..01b16185 --- /dev/null +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidSignServiceTest.kt @@ -0,0 +1,106 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Base64 + +@RunWith(AndroidJUnit4::class) +class WebEidSignServiceTest { + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + private lateinit var service: WebEidSignService + + private val signingCertBase64Raw = + """ + MIID8zCCA3mgAwIBAgIUeHSVTuHxrs0ASYMbqOjDX5yFVnswCgYIKoZIzj0EAwMwXDEYMBYGA1UEAwwPVGVzdCBFU1RFSUQyMDI1MRcwFQYDVQRh + DA5OVFJFRS0xNzA2NjA0OTEaMBgGA1UECgwRWmV0ZXMgRXN0b25pYSBPw5wxCzAJBgNVBAYTAkVFMB4XDTI0MTIxODEwMjY0MVoXDTI5MTIwOTIw + NTk0MVowfzEqMCgGA1UEAwwhSsOVRU9SRyxKQUFLLUtSSVNUSkFOLDM4MDAxMDg1NzE4MRowGAYDVQQFExFQTk9FRS0zODAwMTA4NTcxODEWMBQG + A1UEKgwNSkFBSy1LUklTVEpBTjEQMA4GA1UEBAwHSsOVRU9SRzELMAkGA1UEBhMCRUUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAR9DpcXt4J2NwqG + B3pS1RcGlBM7tcoG82OGpLwCr4xn9LZgc5QRk/oGmRoJ6Nk9/BbHgoYYvBXW8xzcTNZwKIxwz7FRI9cFF+4+4i/ywqkRV9ApH112xQ7L+p9ANCP/ + va6jggHXMIIB0zAJBgNVHRMEAjAAMB8GA1UdIwQYMBaAFO7ylT+MsvxRnoTm5l6EEX5CuiA2MHAGCCsGAQUFBwEBBGQwYjA4BggrBgEFBQcwAoYs + aHR0cDovL2NydC10ZXN0LmVpZHBraS5lZS90ZXN0RVNURUlEMjAyNS5jcnQwJgYIKwYBBQUHMAGGGmh0dHA6Ly9vY3NwLXRlc3QuZWlkcGtpLmVl + MFcGA1UdIARQME4wCQYHBACL7EABAjBBBg6INwEDBgEEAYORIQIBATAvMC0GCCsGAQUFBwIBFiFodHRwczovL3JlcG9zaXRvcnktdGVzdC5laWRw + a2kuZWUwbAYIKwYBBQUHAQMEYDBeMAgGBgQAjkYBATAIBgYEAI5GAQQwEwYGBACORgEGMAkGBwQAjkYBBgEwMwYGBACORgEFMCkwJxYhaHR0cHM6 + Ly9yZXBvc2l0b3J5LXRlc3QuZWlkcGtpLmVlEwJlbjA9BgNVHR8ENjA0MDKgMKAuhixodHRwOi8vY3JsLXRlc3QuZWlkcGtpLmVlL3Rlc3RFU1RF + SUQyMDI1LmNybDAdBgNVHQ4EFgQUH6IlbFh9H8w0BIsDCgq01rqaFVUwDgYDVR0PAQH/BAQDAgZAMAoGCCqGSM49BAMDA2gAMGUCMQDGeR+QV6MF + sWnB7LoXrpOfPQFTT366CLbdmQQMbIzJtysZTrOSQ95yxpulvpxOKsoCMAsT41AJ3de5JSrW89S5x5zgvi1K7PG1zhzSGgUuMElzDZPJSyp4TE8k + FvCDizwjaQ== + """.trimIndent() + + private val signingCertBase64 = signingCertBase64Raw.replace("\\s+".toRegex(), "") + + @Before + fun setup() { + service = WebEidSignServiceImpl() + } + + @Test + fun buildCertificatePayload_withValidCert_returnsExpectedJson() { + val signingCertBytes = Base64.getMimeDecoder().decode(signingCertBase64) + val result = service.buildCertificatePayload(signingCertBytes) + + assertTrue(result.has("certificate")) + assertTrue(result.has("supportedSignatureAlgorithms")) + assertEquals( + Base64.getEncoder().encodeToString(signingCertBytes), + result.getString("certificate"), + ) + + val algorithms = result.getJSONArray("supportedSignatureAlgorithms") + assertTrue(algorithms.length() > 0) + val firstAlgo = algorithms.getJSONObject(0) + assertEquals("ECC", firstAlgo.getString("cryptoAlgorithm")) + assertEquals("NONE", firstAlgo.getString("paddingScheme")) + } + + @Test + fun buildSignPayload_withValidInputs_returnsExpectedJson() { + val signatureBytes = byteArrayOf(11, 22, 33, 44, 55) + val result = service.buildSignPayload(signingCertBase64, signatureBytes) + + assertTrue(result.has("signature")) + assertTrue(result.has("signatureAlgorithm")) + assertTrue(result.has("signingCertificate")) + assertEquals(signingCertBase64, result.getString("signingCertificate")) + + val expectedSignature = Base64.getEncoder().encodeToString(signatureBytes) + assertEquals(expectedSignature, result.getString("signature")) + + val algorithm = result.getString("signatureAlgorithm") + assertTrue(algorithm.startsWith("ES")) + } + + @Test + fun buildSignPayload_differentSignatures_produceDifferentJson() { + val sig1 = byteArrayOf(1, 2, 3) + val sig2 = byteArrayOf(4, 5, 6) + val result1 = service.buildSignPayload(signingCertBase64, sig1) + val result2 = service.buildSignPayload(signingCertBase64, sig2) + + assertNotEquals( + result1.getString("signature"), + result2.getString("signature"), + ) + } + + @Test + fun buildCertificatePayload_invalidCert_throwsException() { + val invalidBytes = "not-a-real-cert".toByteArray() + val exception = + assertThrows(Exception::class.java) { + service.buildCertificatePayload(invalidBytes) + } + assertTrue(exception.message!!.contains("certificate") || exception.message!!.contains("Certificate")) + } +} diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/di/AppModulesTest.kt b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/di/AppModulesTest.kt new file mode 100644 index 00000000..4ba35dd9 --- /dev/null +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/di/AppModulesTest.kt @@ -0,0 +1,36 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.di + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import ee.ria.DigiDoc.webEid.WebEidAuthServiceImpl +import ee.ria.DigiDoc.webEid.WebEidSignServiceImpl +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AppModulesTest { + private lateinit var modules: AppModules + + @Before + fun setup() { + modules = AppModules() + } + + @Test + fun provideWebEidAuthService_returnsCorrectImpl() { + val service = modules.provideWebEidAuthService() + assertNotNull(service) + assertTrue(service is WebEidAuthServiceImpl) + } + + @Test + fun provideWebEidSignService_returnsCorrectImpl() { + val service = modules.provideWebEidSignService() + assertNotNull(service) + assertTrue(service is WebEidSignServiceImpl) + } +} diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/exception/WebEidExceptionTest.kt b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/exception/WebEidExceptionTest.kt new file mode 100644 index 00000000..a850e670 --- /dev/null +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/exception/WebEidExceptionTest.kt @@ -0,0 +1,27 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.exception + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class WebEidExceptionTest { + @Test + fun constructor_and_getters_workCorrectly() { + val exception = + WebEidException( + WebEidErrorCode.ERR_WEBEID_MOBILE_INVALID_REQUEST, + "Test message", + "https://example.com/error", + ) + + assertEquals(WebEidErrorCode.ERR_WEBEID_MOBILE_INVALID_REQUEST, exception.errorCode) + assertEquals("Test message", exception.message) + assertEquals("https://example.com/error", exception.responseUri) + assertNotNull(exception.localizedMessage) + } +} diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtilTest.kt b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtilTest.kt new file mode 100644 index 00000000..7a56b1c4 --- /dev/null +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtilTest.kt @@ -0,0 +1,97 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.utils + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.json.JSONArray +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.security.KeyPairGenerator +import java.security.interfaces.ECPublicKey + +@RunWith(AndroidJUnit4::class) +class WebEidAlgorithmUtilTest { + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + private lateinit var context: Context + private lateinit var ecPublicKey256: ECPublicKey + private lateinit var ecPublicKey384: ECPublicKey + + @Before + fun setup() { + context = InstrumentationRegistry.getInstrumentation().targetContext + + val keyGen256 = + KeyPairGenerator.getInstance("EC").apply { + initialize(256) + } + ecPublicKey256 = keyGen256.generateKeyPair().public as ECPublicKey + + val keyGen384 = + KeyPairGenerator.getInstance("EC").apply { + initialize(384) + } + ecPublicKey384 = keyGen384.generateKeyPair().public as ECPublicKey + } + + @Test + fun buildSupportedSignatureAlgorithms_returnsAllSupportedHashFunctions() { + val result: JSONArray = WebEidAlgorithmUtil.buildSupportedSignatureAlgorithms(ecPublicKey256) + assertEquals(8, result.length()) + val first = result.getJSONObject(0) + assertEquals("ECC", first.getString("cryptoAlgorithm")) + assertEquals("NONE", first.getString("paddingScheme")) + assertTrue(first.has("hashFunction")) + } + + @Test + fun getAlgorithm_returnsCorrectAlgorithmForKeyLength() { + val alg256 = WebEidAlgorithmUtil.getAlgorithm(ecPublicKey256) + val alg384 = WebEidAlgorithmUtil.getAlgorithm(ecPublicKey384) + assertEquals("ES256", alg256) + assertEquals("ES384", alg384) + } + + @Test + fun buildSupportedSignatureAlgorithms_unsupportedKeyType_throwsException() { + val rsaKey = + KeyPairGenerator + .getInstance("RSA") + .apply { + initialize(2048) + }.generateKeyPair() + .public + + val exception = + assertThrows(IllegalArgumentException::class.java) { + WebEidAlgorithmUtil.buildSupportedSignatureAlgorithms(rsaKey) + } + assertTrue(exception.message!!.contains("Unsupported key type")) + } + + @Test + fun getAlgorithm_unsupportedKeyType_throwsException() { + val rsaKey = + KeyPairGenerator + .getInstance("RSA") + .apply { + initialize(2048) + }.generateKeyPair() + .public + + val exception = + assertThrows(IllegalArgumentException::class.java) { + WebEidAlgorithmUtil.getAlgorithm(rsaKey) + } + assertTrue(exception.message!!.contains("Unsupported key type")) + } +} diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt index 9185682e..f26e304f 100644 --- a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt @@ -2,7 +2,9 @@ package ee.ria.DigiDoc.webEid.utils +import android.util.Base64 import androidx.test.ext.junit.runners.AndroidJUnit4 +import ee.ria.DigiDoc.webEid.exception.WebEidErrorCode import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -22,7 +24,7 @@ class WebEidResponseUtilTest { val resultUri = WebEidResponseUtil.createResponseUri(loginUri, payload) val fragment = resultUri.fragment - val decodedJson = String(android.util.Base64.decode(fragment, android.util.Base64.URL_SAFE)) + val decodedJson = String(Base64.decode(fragment, Base64.URL_SAFE)) val json = JSONObject(decodedJson) assertEquals("ERR_CUSTOM", json.getString("code")) @@ -40,7 +42,7 @@ class WebEidResponseUtilTest { val resultUri = WebEidResponseUtil.createResponseUri(loginUri, payload) val fragment = resultUri.fragment - val decodedJson = String(android.util.Base64.decode(fragment, android.util.Base64.URL_SAFE)) + val decodedJson = String(Base64.decode(fragment, Base64.URL_SAFE)) val json = JSONObject(decodedJson) assertEquals("sample-token", json.getString("auth-token")) @@ -56,4 +58,22 @@ class WebEidResponseUtilTest { assertTrue(resultUri.toString().startsWith(loginUri)) } + + @Test + fun createErrorPayload_and_createResponseUri_areCovered() { + val loginUri = "https://rp.example.com/auth/eid/login" + + val errorPayload = + WebEidResponseUtil.createErrorPayload( + WebEidErrorCode.ERR_WEBEID_MOBILE_INVALID_REQUEST, + "Invalid request", + ) + + val resultUri = WebEidResponseUtil.createResponseUri(loginUri, errorPayload) + val decodedJson = String(Base64.decode(resultUri.fragment, Base64.URL_SAFE)) + val json = JSONObject(decodedJson) + + assertTrue(json.getBoolean("error")) + assertEquals("Invalid request", json.getString("message")) + } } diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt index 91458033..856aa080 100644 --- a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt @@ -2,12 +2,11 @@ package ee.ria.DigiDoc.webEid -import org.json.JSONArray +import ee.ria.DigiDoc.webEid.utils.WebEidAlgorithmUtil.buildSupportedSignatureAlgorithms +import ee.ria.DigiDoc.webEid.utils.WebEidAlgorithmUtil.getAlgorithm import org.json.JSONObject -import java.security.PublicKey import java.security.cert.CertificateFactory import java.security.cert.X509Certificate -import java.security.interfaces.ECPublicKey import java.util.Base64 import javax.inject.Inject import javax.inject.Singleton @@ -16,14 +15,6 @@ import javax.inject.Singleton class WebEidAuthServiceImpl @Inject constructor() : WebEidAuthService { - - companion object { - val SUPPORTED_HASH_FUNCTIONS = listOf( - "SHA-224", "SHA-256", "SHA-384", "SHA-512", - "SHA3-224", "SHA3-256", "SHA3-384", "SHA3-512" - ) - } - override fun buildAuthToken( authCert: ByteArray, signingCert: ByteArray?, @@ -53,37 +44,4 @@ class WebEidAuthServiceImpl } } } - - private fun getAlgorithm(publicKey: PublicKey): String = - when (publicKey) { - is ECPublicKey -> { - when (publicKey.params.curve.field.fieldSize) { - 256 -> "ES256" - 384 -> "ES384" - 521 -> "ES512" - else -> throw IllegalArgumentException("Unsupported EC key length") - } - } - - else -> throw IllegalArgumentException("Unsupported key type") - } - - private fun buildSupportedSignatureAlgorithms(publicKey: PublicKey): JSONArray = - JSONArray().apply { - when (publicKey) { - is ECPublicKey -> { - SUPPORTED_HASH_FUNCTIONS.forEach { hashFunction -> - put( - JSONObject().apply { - put("cryptoAlgorithm", "ECC") - put("hashFunction", hashFunction) - put("paddingScheme", "NONE") - }, - ) - } - } - - else -> throw IllegalArgumentException("Unsupported key type") - } - } } diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidSignService.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidSignService.kt new file mode 100644 index 00000000..47d3bc9b --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidSignService.kt @@ -0,0 +1,14 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid + +import org.json.JSONObject + +interface WebEidSignService { + fun buildCertificatePayload(signingCert: ByteArray): JSONObject + + fun buildSignPayload( + signingCert: String, + signature: ByteArray, + ): JSONObject +} diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidSignServiceImpl.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidSignServiceImpl.kt new file mode 100644 index 00000000..1befd219 --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidSignServiceImpl.kt @@ -0,0 +1,51 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid + +import ee.ria.DigiDoc.webEid.utils.WebEidAlgorithmUtil.buildSupportedSignatureAlgorithms +import ee.ria.DigiDoc.webEid.utils.WebEidAlgorithmUtil.getAlgorithm +import org.json.JSONObject +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.Base64 +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WebEidSignServiceImpl + @Inject + constructor() : WebEidSignService { + override fun buildCertificatePayload(signingCert: ByteArray): JSONObject { + val cert = + CertificateFactory + .getInstance("X.509") + .generateCertificate(signingCert.inputStream()) as X509Certificate + val publicKey = cert.publicKey + val supportedSignatureAlgorithms = buildSupportedSignatureAlgorithms(publicKey) + + return JSONObject().apply { + put("certificate", Base64.getEncoder().encodeToString(signingCert)) + put("supportedSignatureAlgorithms", supportedSignatureAlgorithms) + } + } + + override fun buildSignPayload( + signingCert: String, + signature: ByteArray, + ): JSONObject { + val certBytes = Base64.getDecoder().decode(signingCert) + val cert = + CertificateFactory + .getInstance("X.509") + .generateCertificate(certBytes.inputStream()) as X509Certificate + + val publicKey = cert.publicKey + val algorithm = getAlgorithm(publicKey) + + return JSONObject().apply { + put("signature", Base64.getEncoder().encodeToString(signature)) + put("signatureAlgorithm", algorithm) + put("signingCertificate", signingCert) + } + } + } diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/di/AppModules.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/di/AppModules.kt index c3144f36..1ec7b9b1 100644 --- a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/di/AppModules.kt +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/di/AppModules.kt @@ -8,10 +8,15 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import ee.ria.DigiDoc.webEid.WebEidAuthService import ee.ria.DigiDoc.webEid.WebEidAuthServiceImpl +import ee.ria.DigiDoc.webEid.WebEidSignService +import ee.ria.DigiDoc.webEid.WebEidSignServiceImpl @Module @InstallIn(SingletonComponent::class) class AppModules { @Provides fun provideWebEidAuthService(): WebEidAuthService = WebEidAuthServiceImpl() + + @Provides + fun provideWebEidSignService(): WebEidSignService = WebEidSignServiceImpl() } diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt index b5f3431d..fdc491a8 100644 --- a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt @@ -4,7 +4,7 @@ package ee.ria.DigiDoc.webEid.domain.model data class WebEidSignRequest( val responseUri: String, - val signCertificate: String, - val hash: String, - val hashFunction: String, + val origin: String, + val hash: String?, + val hashFunction: String?, ) diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtil.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtil.kt new file mode 100644 index 00000000..4e092afb --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtil.kt @@ -0,0 +1,55 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.utils + +import org.json.JSONArray +import org.json.JSONObject +import java.security.PublicKey +import java.security.interfaces.ECPublicKey + +object WebEidAlgorithmUtil { + private val SUPPORTED_HASH_FUNCTIONS = + listOf( + "SHA-224", + "SHA-256", + "SHA-384", + "SHA-512", + "SHA3-224", + "SHA3-256", + "SHA3-384", + "SHA3-512", + ) + + fun buildSupportedSignatureAlgorithms(publicKey: PublicKey): JSONArray = + JSONArray().apply { + when (publicKey) { + is ECPublicKey -> { + SUPPORTED_HASH_FUNCTIONS.forEach { hashFunction -> + put( + JSONObject().apply { + put("cryptoAlgorithm", "ECC") + put("hashFunction", hashFunction) + put("paddingScheme", "NONE") + }, + ) + } + } + + else -> throw IllegalArgumentException("Unsupported key type") + } + } + + fun getAlgorithm(publicKey: PublicKey): String = + when (publicKey) { + is ECPublicKey -> { + when (publicKey.params.curve.field.fieldSize) { + 256 -> "ES256" + 384 -> "ES384" + 521 -> "ES512" + else -> throw IllegalArgumentException("Unsupported EC key length") + } + } + + else -> throw IllegalArgumentException("Unsupported key type") + } +} diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt index 329af3ab..81f888ca 100644 --- a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt @@ -21,7 +21,6 @@ object WebEidRequestParser { val request = decodeUriFragment(authUri) val challenge = request.getString("challenge") val responseUri = validateResponseUri(request.getString("login_uri")) - val origin = parseOrigin(responseUri) if (challenge.isNullOrBlank() || challenge.length < MIN_CHALLENGE_LENGTH || challenge.length > MAX_CHALLENGE_LENGTH @@ -37,19 +36,41 @@ object WebEidRequestParser { challenge = challenge, loginUri = responseUri.toString(), getSigningCertificate = request.optBoolean("get_signing_certificate", false), - origin = origin, + origin = parseOrigin(responseUri), + ) + } + + fun parseCertificateUri(uri: Uri): WebEidSignRequest { + val request = decodeUriFragment(uri) + val responseUri = validateResponseUri(request.optString("response_uri", "")) + + return WebEidSignRequest( + responseUri = responseUri.toString(), + origin = parseOrigin(responseUri), + hash = null, + hashFunction = null, ) } fun parseSignUri(uri: Uri): WebEidSignRequest { val request = decodeUriFragment(uri) - val responseUri = validateResponseUri(request.getString("response_uri")) + val responseUri = validateResponseUri(request.optString("response_uri", "")) + val hash = request.optString("hash", "") + val hashFunction = request.optString("hash_function", "") + + if (hash.isBlank() || hashFunction.isBlank()) { + throw WebEidException( + ERR_WEBEID_MOBILE_INVALID_REQUEST, + "Invalid signing request: missing hash or hash_function", + responseUri.toString(), + ) + } return WebEidSignRequest( responseUri = responseUri.toString(), - signCertificate = request.getString("sign_certificate"), - hash = request.getString("hash"), - hashFunction = request.getString("hash_function"), + origin = parseOrigin(responseUri), + hash = hash, + hashFunction = hashFunction, ) } From 03ff37322470366d1e1b58d834272378a5546d63 Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Tue, 23 Dec 2025 14:49:20 +0200 Subject: [PATCH 04/10] NFC-83 Auth view improvements --- .../DigiDoc/fragment/screen/WebEidScreen.kt | 113 ++++++++++++++---- app/src/main/res/values-et/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 3 files changed, 94 insertions(+), 25 deletions(-) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt index ea87fc4a..66ad445e 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt @@ -10,8 +10,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth @@ -46,9 +48,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.asFlow import androidx.navigation.NavHostController @@ -56,6 +60,7 @@ import androidx.navigation.compose.rememberNavController import ee.ria.DigiDoc.R import ee.ria.DigiDoc.domain.model.IdentityAction import ee.ria.DigiDoc.ui.component.menu.SettingsMenuBottomSheet +import ee.ria.DigiDoc.ui.component.settings.SettingsSwitchItem import ee.ria.DigiDoc.ui.component.shared.DynamicText import ee.ria.DigiDoc.ui.component.shared.InvisibleElement import ee.ria.DigiDoc.ui.component.shared.TopBar @@ -70,6 +75,7 @@ import ee.ria.DigiDoc.viewmodel.WebEidViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedContainerViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedMenuViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedSettingsViewModel +import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNotNull @@ -105,6 +111,7 @@ fun WebEidScreen( val messages by SnackBarManager.messages.collectAsState(emptyList()) val dialogError by viewModel.dialogError.asFlow().collectAsState(0) val showErrorDialog = rememberSaveable { mutableStateOf(false) } + var rememberMe by rememberSaveable { mutableStateOf(true) } LaunchedEffect(messages) { messages.forEach { message -> @@ -247,35 +254,12 @@ fun WebEidScreen( modifier = Modifier.semantics { heading() }, ) if (authRequest != null) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.web_eid_auth_consent_text), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - ) - Text( - text = authRequest.origin.take(80), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = stringResource(R.string.web_eid_requests_authentication), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - ) - } + WebEidAuthInfo(authRequest = authRequest) NFCView( activity = activity, identityAction = IdentityAction.AUTH, + rememberMe = rememberMe, isSigning = false, isDecrypting = false, isWebEidAuthenticating = isWebEidAuthenticating, @@ -306,6 +290,11 @@ fun WebEidScreen( isAuthenticated = { _, _ -> }, webEidViewModel = viewModel, ) + + WebEidRememberMe( + rememberMe = rememberMe, + onRememberMeChange = { rememberMe = it }, + ) } else if (signRequest != null) { val responseUri = signRequest.responseUri.lowercase() val isCertificateFlow = responseUri.contains("/certificate") && !responseUri.contains("/signature") @@ -466,6 +455,80 @@ fun WebEidScreen( } } +@Composable +private fun WebEidAuthInfo( + authRequest: WebEidAuthRequest, +) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.web_eid_auth_request_from), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Left, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = authRequest.origin.take(80), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Left, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.web_eid_auth_details_forwarded), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Left, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = "NAME, PERSONAL IDENTIFICATION CODE", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Left, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.web_eid_auth_consent_text), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Left, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun WebEidRememberMe( + rememberMe: Boolean, + onRememberMeChange: (Boolean) -> Unit, +) { + val rememberMeText = stringResource(R.string.signature_update_remember_me) + + SettingsSwitchItem( + checked = rememberMe, + onCheckedChange = onRememberMeChange, + title = rememberMeText, + contentDescription = rememberMeText, + testTag = "webEidRememberMeSwitch", + ) + + if (rememberMe) { + Text( + text = stringResource(R.string.web_eid_remember_me_message), + ) + } +} + @Preview(showBackground = true) @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 2d041d8f..614d1a47 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -664,6 +664,9 @@ Autentimine ID-kaardiga Autentimispäringut ei saadetud Autentides nõustun oma nime ja isikukoodi edastamisega teenusepakkujale. + Autentimispäring: + Edastatavad andmed: + Järgmisel kasutamisel on andmeväljad eeltäidetud. Kinnita Vali sertifikaat Sertifikaati valides nõustun oma nime ja isikukoodi edastamisega teenusepakkujale. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd1f42c4..445227fe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -664,6 +664,9 @@ Authenticate with ID-card No auth payload received. By authenticating, I agree to the transfer of my name and personal identification code to the service provider. + Authentication request from: + Details forwarded: + The entered data will be filled the next time you authenticate. Confirm Select a certificate By choosing the certificate, I agree to the transfer of my name and personal identification code to the service provider. From 675aec881d3c784c335129402c106fb7933c7198 Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Tue, 23 Dec 2025 17:42:48 +0200 Subject: [PATCH 05/10] NFC-83 Auth NFC read view improvements --- .../DigiDoc/fragment/screen/WebEidScreen.kt | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt index 66ad445e..a869df35 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt @@ -53,7 +53,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.asFlow import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController @@ -254,7 +254,9 @@ fun WebEidScreen( modifier = Modifier.semantics { heading() }, ) if (authRequest != null) { - WebEidAuthInfo(authRequest = authRequest) + if (!isWebEidAuthenticating) { + WebEidAuthInfo(authRequest = authRequest) + } NFCView( activity = activity, @@ -291,10 +293,14 @@ fun WebEidScreen( webEidViewModel = viewModel, ) - WebEidRememberMe( - rememberMe = rememberMe, - onRememberMeChange = { rememberMe = it }, - ) + if (!isWebEidAuthenticating) { + Spacer(modifier = Modifier.height(SPadding)) + + WebEidRememberMe( + rememberMe = rememberMe, + onRememberMeChange = { rememberMe = it }, + ) + } } else if (signRequest != null) { val responseUri = signRequest.responseUri.lowercase() val isCertificateFlow = responseUri.contains("/certificate") && !responseUri.contains("/signature") @@ -456,9 +462,7 @@ fun WebEidScreen( } @Composable -private fun WebEidAuthInfo( - authRequest: WebEidAuthRequest, -) { +private fun WebEidAuthInfo(authRequest: WebEidAuthRequest) { Column( modifier = Modifier.fillMaxWidth(), ) { From e51219e6b3279374da6e5981714482431c67f8e3 Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Tue, 23 Dec 2025 19:32:41 +0200 Subject: [PATCH 06/10] NFC-83 Cert and Sign view improvements --- .../DigiDoc/fragment/screen/WebEidScreen.kt | 95 +++++++++++++------ .../DigiDoc/ui/component/signing/NFCView.kt | 5 +- app/src/main/res/values-et/strings.xml | 7 +- app/src/main/res/values/strings.xml | 7 +- 4 files changed, 75 insertions(+), 39 deletions(-) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt index a869df35..8a120aeb 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt @@ -304,35 +304,11 @@ fun WebEidScreen( } else if (signRequest != null) { val responseUri = signRequest.responseUri.lowercase() val isCertificateFlow = responseUri.contains("/certificate") && !responseUri.contains("/signature") - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.web_eid_certificate_consent_text), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f), - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = XSPadding), - ) - Text( - text = signRequest.origin.take(80), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = - if (isCertificateFlow) { - stringResource(R.string.web_eid_requests_certificate) - } else { - stringResource(R.string.web_eid_requests_signing) - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, + + if (!isWebEidAuthenticating) { + WebEidSignOrCertificateInfo( + origin = signRequest.origin, + isCertificateFlow = isCertificateFlow, ) } @@ -372,6 +348,7 @@ fun WebEidScreen( isSigning = false, isDecrypting = false, isWebEidAuthenticating = isWebEidAuthenticating, + canNumberReadOnly = true, onError = { isWebEidAuthenticating = false cancelWebEidSignAction() @@ -486,7 +463,7 @@ private fun WebEidAuthInfo(authRequest: WebEidAuthRequest) { Spacer(modifier = Modifier.height(16.dp)) Text( - text = stringResource(R.string.web_eid_auth_details_forwarded), + text = stringResource(R.string.web_eid_details_forwarded), style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Left, ) @@ -511,6 +488,64 @@ private fun WebEidAuthInfo(authRequest: WebEidAuthRequest) { } } +@Composable +private fun WebEidSignOrCertificateInfo( + origin: String, + isCertificateFlow: Boolean, +) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = + if (isCertificateFlow) { + stringResource(R.string.web_eid_cert_request_from) + } else { + stringResource(R.string.web_eid_sign_request_from) + }, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Left, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = origin.take(80), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Left, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.web_eid_details_forwarded), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Left, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = "NAME, PERSONAL IDENTIFICATION CODE", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Left, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.web_eid_certificate_consent_text), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Left, + modifier = Modifier.fillMaxWidth(), + ) + } +} + @Composable private fun WebEidRememberMe( rememberMe: Boolean, diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt index a6c47162..b705cae0 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt @@ -166,6 +166,7 @@ fun NFCView( cancelWebEidSignAction: (() -> Unit) -> Unit = {}, isAuthenticated: (Boolean, IdCardData) -> Unit, webEidViewModel: WebEidViewModel? = null, + canNumberReadOnly: Boolean = false, ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -757,6 +758,8 @@ fun NFCView( ) } }, + readOnly = canNumberReadOnly, + enabled = !canNumberReadOnly, modifier = modifier .focusRequester(canNumberFocusRequester) @@ -774,7 +777,7 @@ fun NFCView( contentDescription = canNumberLocationText }.testTag("signatureUpdateNFCCAN"), trailingIcon = { - if (!isTalkBackEnabled(context) && canNumber.text.isNotEmpty()) { + if (!isTalkBackEnabled(context) && canNumber.text.isNotEmpty() && !canNumberReadOnly) { IconButton(onClick = { canNumber = TextFieldValue("") }) { diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 614d1a47..d22cad03 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -665,16 +665,15 @@ Autentimispäringut ei saadetud Autentides nõustun oma nime ja isikukoodi edastamisega teenusepakkujale. Autentimispäring: - Edastatavad andmed: + Edastatavad andmed: Järgmisel kasutamisel on andmeväljad eeltäidetud. Kinnita Vali sertifikaat Sertifikaati valides nõustun oma nime ja isikukoodi edastamisega teenusepakkujale. Allkirjastamine Allkirjasta ID-kaardiga - soovib autentimist - soovib sertifikaati - soovib allkirjastamist + Sertifikaadipäring: + Allirjastamispäring: Ignoreeri Vigane Web eID päring Päringu viga diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 445227fe..c4f0173b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -665,16 +665,15 @@ No auth payload received. By authenticating, I agree to the transfer of my name and personal identification code to the service provider. Authentication request from: - Details forwarded: + Details forwarded: The entered data will be filled the next time you authenticate. Confirm Select a certificate By choosing the certificate, I agree to the transfer of my name and personal identification code to the service provider. Sign Sign with ID-card - requests authentication - requests certificate - requests signing + Certificate request from: + Signing request from: Ignore Invalid Web eID request Request error From 7d8f1c59332c81b6813e925992d0ce34481e9fb6 Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Tue, 30 Dec 2025 15:30:20 +0200 Subject: [PATCH 07/10] NFC-83 Add readonly when remember me is on, if remember me is off then readonly is no applicable for signing --- .../ria/DigiDoc/fragment/screen/WebEidScreen.kt | 17 ++++++++++++++--- app/src/main/res/values-et/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt index 8a120aeb..e0a526c3 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt @@ -112,6 +112,7 @@ fun WebEidScreen( val dialogError by viewModel.dialogError.asFlow().collectAsState(0) val showErrorDialog = rememberSaveable { mutableStateOf(false) } var rememberMe by rememberSaveable { mutableStateOf(true) } + val hasStoredCanNumber = sharedSettingsViewModel.dataStore.getCanNumber().isNotEmpty() LaunchedEffect(messages) { messages.forEach { message -> @@ -316,6 +317,7 @@ fun WebEidScreen( NFCView( activity = activity, identityAction = IdentityAction.CERTIFICATE, + rememberMe = rememberMe, isCertificate = true, showPinField = false, isSigning = false, @@ -340,6 +342,15 @@ fun WebEidScreen( isAuthenticated = { _, _ -> }, webEidViewModel = viewModel, ) + + if (!isWebEidAuthenticating) { + Spacer(modifier = Modifier.height(SPadding)) + + WebEidRememberMe( + rememberMe = rememberMe, + onRememberMeChange = { rememberMe = it }, + ) + } } else { NFCView( activity = activity, @@ -348,7 +359,7 @@ fun WebEidScreen( isSigning = false, isDecrypting = false, isWebEidAuthenticating = isWebEidAuthenticating, - canNumberReadOnly = true, + canNumberReadOnly = hasStoredCanNumber, onError = { isWebEidAuthenticating = false cancelWebEidSignAction() @@ -471,7 +482,7 @@ private fun WebEidAuthInfo(authRequest: WebEidAuthRequest) { Spacer(modifier = Modifier.height(2.dp)) Text( - text = "NAME, PERSONAL IDENTIFICATION CODE", + text = stringResource(R.string.web_eid_name_personal_identification_code), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Left, ) @@ -529,7 +540,7 @@ private fun WebEidSignOrCertificateInfo( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "NAME, PERSONAL IDENTIFICATION CODE", + text = stringResource(R.string.web_eid_name_personal_identification_code), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Left, ) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index d22cad03..e4055053 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -666,6 +666,7 @@ Autentides nõustun oma nime ja isikukoodi edastamisega teenusepakkujale. Autentimispäring: Edastatavad andmed: + NIMI, ISIKUKOOD Järgmisel kasutamisel on andmeväljad eeltäidetud. Kinnita Vali sertifikaat diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c4f0173b..13dbd8b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -666,6 +666,7 @@ By authenticating, I agree to the transfer of my name and personal identification code to the service provider. Authentication request from: Details forwarded: + NAME, PERSONAL IDENTIFICATION CODE The entered data will be filled the next time you authenticate. Confirm Select a certificate From 93ebfc64e35b538e1f8fe60ae20d2660fd443b02 Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Tue, 6 Jan 2026 15:29:35 +0200 Subject: [PATCH 08/10] NFC-83 Fix remember me when its off so it is off also for next time accessing app --- .../kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt index b705cae0..1f4e2020 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt @@ -649,7 +649,9 @@ fun NFCView( } } signWebEidAction { - saveFormParams() + if (sharedSettingsViewModel.dataStore.getCanNumber().isNotEmpty()) { + saveFormParams() + } scope.launch(IO) { val isCertificateFlow = responseUriString.contains("/certificate", ignoreCase = true) val cachedCert = sharedSettingsViewModel.dataStore.getSigningCertificate() From 3f5c7a6c1cb8fd0a57c8a7b351d8606fc0cba5ed Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Thu, 8 Jan 2026 11:09:35 +0200 Subject: [PATCH 09/10] NFC-83 Fix remember me for certificate --- .../kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt index 1f4e2020..d6898591 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/signing/NFCView.kt @@ -649,14 +649,12 @@ fun NFCView( } } signWebEidAction { - if (sharedSettingsViewModel.dataStore.getCanNumber().isNotEmpty()) { - saveFormParams() - } scope.launch(IO) { val isCertificateFlow = responseUriString.contains("/certificate", ignoreCase = true) val cachedCert = sharedSettingsViewModel.dataStore.getSigningCertificate() if (isCertificateFlow) { + saveFormParams() if (cachedCert.isNotEmpty()) { val certBytes = Base64.getDecoder().decode(cachedCert) webEidViewModel?.handleWebEidCertificateResult(certBytes) @@ -668,6 +666,9 @@ fun NFCView( ) } } else { + if (sharedSettingsViewModel.dataStore.getCanNumber().isNotEmpty()) { + saveFormParams() + } nfcViewModel.performNFCWebEidSignWorkRequest( activity = activity, context = context, From 15eab1f28c8a884f58d9ef5672833f09f9ceee25 Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Fri, 9 Jan 2026 14:19:54 +0200 Subject: [PATCH 10/10] NFC-83 Fix view and ignore to cancel --- .../kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt | 5 ++--- app/src/main/res/values-et/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt index e0a526c3..ebc93791 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt @@ -65,7 +65,6 @@ import ee.ria.DigiDoc.ui.component.shared.DynamicText import ee.ria.DigiDoc.ui.component.shared.InvisibleElement import ee.ria.DigiDoc.ui.component.shared.TopBar import ee.ria.DigiDoc.ui.component.signing.NFCView -import ee.ria.DigiDoc.ui.theme.Dimensions.MSPadding import ee.ria.DigiDoc.ui.theme.Dimensions.SPadding import ee.ria.DigiDoc.ui.theme.Dimensions.XSPadding import ee.ria.DigiDoc.ui.theme.RIADigiDocTheme @@ -235,7 +234,7 @@ fun WebEidScreen( .padding(paddingValues) .padding(SPadding) .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(MSPadding), + verticalArrangement = Arrangement.spacedBy(XSPadding), ) { val responseUri = signRequest?.responseUri?.lowercase() ?: "" val isCertificateFlow = responseUri.contains("/certificate") && !responseUri.contains("/signature") @@ -442,7 +441,7 @@ fun WebEidScreen( ), ) { Text( - text = stringResource(R.string.web_eid_ignore), + text = stringResource(R.string.web_eid_cancel), ) } } diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index e4055053..1d9cc720 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -675,7 +675,7 @@ Allkirjasta ID-kaardiga Sertifikaadipäring: Allirjastamispäring: - Ignoreeri + Tühista Vigane Web eID päring Päringu viga Vigane autentimispäring diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 13dbd8b1..829fd87a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -675,7 +675,7 @@ Sign with ID-card Certificate request from: Signing request from: - Ignore + Cancel Invalid Web eID request Request error Invalid authentication request