From c5e70bb0047903923afa19290275446aa0b7d516 Mon Sep 17 00:00:00 2001 From: "Sander.Kondratjev" Date: Thu, 24 Jul 2025 14:14:39 +0300 Subject: [PATCH 01/28] NFC-50 Add custom URI scheme intent filter for web-eid-mobile:// in AndroidManifest.xml --- app/src/main/AndroidManifest.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 841ac54a7..6bc844f59 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,6 +58,14 @@ + + + + + + + + Date: Thu, 24 Jul 2025 14:26:04 +0300 Subject: [PATCH 02/28] NFC-50 Handle web-eid-mobile intent and skip file parsing in MainActivity --- app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt | 14 ++++++++++++-- .../ee/ria/DigiDoc/RIADigiDocAppNavigation.kt | 5 +++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt b/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt index b3adfbe8a..f2d98b3a7 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt @@ -120,8 +120,15 @@ class MainActivity : val componentClassName = this.javaClass.name - val externalFileUris = getExternalFileUris(intent) 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) + } + localeUtil.updateLocale(applicationContext, locale) // Observe if activity needs to be recreated for changes to take effect (eg. Settings) @@ -163,7 +170,10 @@ class MainActivity : setContent { RIADigiDocTheme(darkTheme = useDarkMode) { - RIADigiDocAppScreen(externalFileUris) + RIADigiDocAppScreen( + externalFileUris = externalFileUris, + webEidUri = webEidUri + ) } } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt b/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt index 81f356ac6..2894ed539 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt @@ -73,7 +73,7 @@ import ee.ria.DigiDoc.viewmodel.shared.SharedSettingsViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedSignatureViewModel @Composable -fun RIADigiDocAppScreen(externalFileUris: List) { +fun RIADigiDocAppScreen(externalFileUris: List, webEidUri: Uri? = null) { val navController = rememberNavController() val sharedMenuViewModel: SharedMenuViewModel = hiltViewModel() val sharedContainerViewModel: SharedContainerViewModel = hiltViewModel() @@ -365,6 +365,7 @@ fun RIADigiDocAppScreen(externalFileUris: List) { @Composable fun RIADigiDocAppScreenPreview() { RIADigiDocTheme { - RIADigiDocAppScreen(listOf()) + RIADigiDocAppScreen(listOf(), + webEidUri = null) } } From d74149b5056604e71b97cd6868f4e345ba74c6ff Mon Sep 17 00:00:00 2001 From: "Sander.Kondratjev" Date: Thu, 24 Jul 2025 15:16:08 +0300 Subject: [PATCH 03/28] NFC-50 Add WebEID navigation route to Constant.kt and Route.kt --- app/src/main/kotlin/ee/ria/DigiDoc/utils/Constant.kt | 1 + app/src/main/kotlin/ee/ria/DigiDoc/utils/Route.kt | 3 +++ 2 files changed, 4 insertions(+) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/utils/Constant.kt b/app/src/main/kotlin/ee/ria/DigiDoc/utils/Constant.kt index fb08e9940..adeb7d00a 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/utils/Constant.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/utils/Constant.kt @@ -65,5 +65,6 @@ object Constant { const val MYEID_IDENTIFICATION_SCREEN = "myeid_identification_route" const val MYEID_SCREEN = "myeid_screen_route" const val MYEID_PIN_SCREEN = "myeid_pin_screen_route" + const val WEB_EID_SCREEN = "web_eid_screen_route" } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/utils/Route.kt b/app/src/main/kotlin/ee/ria/DigiDoc/utils/Route.kt index 710146a06..b4f70b448 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/utils/Route.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/utils/Route.kt @@ -56,6 +56,7 @@ import ee.ria.DigiDoc.utils.Constant.Routes.SIGNING_FILE_CHOOSING_SCREEN import ee.ria.DigiDoc.utils.Constant.Routes.SIGNING_SCREEN import ee.ria.DigiDoc.utils.Constant.Routes.SIGNING_SERVICES_SCREEN import ee.ria.DigiDoc.utils.Constant.Routes.VALIDATION_SERVICES_SCREEN +import ee.ria.DigiDoc.utils.Constant.Routes.WEB_EID_SCREEN sealed class Route( val route: String, @@ -129,4 +130,6 @@ sealed class Route( data object MyEidScreen : Route(MYEID_SCREEN) data object MyEidPinScreen : Route(MYEID_PIN_SCREEN) + + data object WebEidScreen : Route(WEB_EID_SCREEN) } From 28503c4280112be07d754385c93649f7c25b145b Mon Sep 17 00:00:00 2001 From: "Sander.Kondratjev" Date: Thu, 24 Jul 2025 15:21:54 +0300 Subject: [PATCH 04/28] NFC-50 Start app from WebEID screen if launched via web-eid-mobile URI --- .../main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt b/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt index 2894ed539..4ec89330f 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt @@ -85,9 +85,10 @@ fun RIADigiDocAppScreen(externalFileUris: List, webEidUri: Uri? = null) { sharedContainerViewModel.setExternalFileUris(externalFileUris) - var startDestination = Route.Init.route - if (sharedSettingsViewModel.dataStore.getLocale() != null) { - startDestination = Route.Home.route + val startDestination = when { + webEidUri != null -> Route.WebEidScreen.route + sharedSettingsViewModel.dataStore.getLocale() != null -> Route.Home.route + else -> Route.Init.route } NavHost( From fbf9152fd638485604497602a99f931ef554b8bf Mon Sep 17 00:00:00 2001 From: "Sander.Kondratjev" Date: Fri, 25 Jul 2025 10:23:47 +0300 Subject: [PATCH 05/28] NFC-50 Add WebEidFragment using consistent fragment structure --- .../ee/ria/DigiDoc/fragment/WebEidFragment.kt | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt new file mode 100644 index 000000000..77c5a5602 --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt @@ -0,0 +1,56 @@ +@file:Suppress("PackageName", "FunctionName") + +package ee.ria.DigiDoc.fragment + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.tooling.preview.Preview +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.viewmodel.WebEidViewModel + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun WebEidFragment( + modifier: Modifier = Modifier, + navController: NavHostController, + viewModel: WebEidViewModel = hiltViewModel(), +) { + Surface( + modifier = + modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .semantics { testTagsAsResourceId = true } + .testTag("webEidFragment"), + color = MaterialTheme.colorScheme.background, + ) { + WebEidScreen( + modifier = modifier, + navController = navController, + viewModel = viewModel, + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun WebEidFragmentPreview() { + RIADigiDocTheme { + WebEidFragment( + navController = rememberNavController(), + ) + } +} \ No newline at end of file From 8858f4928d82b12f284873cba6cac858d6e4c503 Mon Sep 17 00:00:00 2001 From: "Sander.Kondratjev" Date: Fri, 25 Jul 2025 10:26:44 +0300 Subject: [PATCH 06/28] NFC-50 Add WebEidFragment to navigation graph in RIADigiDocAppScreen --- .../main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt b/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt index 4ec89330f..296db8b68 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt @@ -58,6 +58,7 @@ import ee.ria.DigiDoc.fragment.SigningFragment import ee.ria.DigiDoc.fragment.SigningServicesSettingsFragment import ee.ria.DigiDoc.fragment.ThemeChooserFragment import ee.ria.DigiDoc.fragment.ValidationServicesSettingsFragment +import ee.ria.DigiDoc.fragment.WebEidFragment import ee.ria.DigiDoc.ui.component.crypto.recipient.RecipientDetailsView import ee.ria.DigiDoc.ui.component.signing.certificate.CertificateDetailsView import ee.ria.DigiDoc.ui.component.signing.certificate.SignerDetailsView @@ -358,6 +359,12 @@ fun RIADigiDocAppScreen(externalFileUris: List, webEidUri: Uri? = null) { sharedMyEidViewModel = sharedMyEidViewModel, ) } + composable(route = Route.WebEidScreen.route) { + WebEidFragment( + modifier = Modifier.safeDrawingPadding(), + navController = navController, + ) + } } } From 680bf6d434ceffd42076bef204b03007a1837843 Mon Sep 17 00:00:00 2001 From: "Sander.Kondratjev" Date: Fri, 25 Jul 2025 14:16:55 +0300 Subject: [PATCH 07/28] NFC-50 Add minimal implementation of WebEidScreen component --- .../ee/ria/DigiDoc/fragment/WebEidFragment.kt | 1 + .../DigiDoc/fragment/screen/WebEidScreen.kt | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt 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 77c5a5602..24869ead1 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import ee.ria.DigiDoc.fragment.screen.WebEidScreen 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 new file mode 100644 index 000000000..5b241b55b --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt @@ -0,0 +1,55 @@ +@file:Suppress("PackageName", "FunctionName") + +package ee.ria.DigiDoc.fragment.screen + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import ee.ria.DigiDoc.ui.theme.RIADigiDocTheme +import ee.ria.DigiDoc.viewmodel.WebEidViewModel + +@Composable +fun WebEidScreen( + modifier: Modifier = Modifier, + navController: NavHostController, + viewModel: WebEidViewModel, +) { + Surface( + modifier = + modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .semantics { testTagsAsResourceId = true } + .testTag("webEidScreen"), + color = MaterialTheme.colorScheme.background, + ) { + Box(modifier = Modifier.fillMaxSize()) { + Text("Web eID Screen placeholder") + } + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun WebEidScreenPreview() { + RIADigiDocTheme { + WebEidScreen( + navController = rememberNavController(), + viewModel = hiltViewModel(), + ) + } +} \ No newline at end of file From 6359f9da8caca44f0fd17d003c7002a2614012cf Mon Sep 17 00:00:00 2001 From: "Sander.Kondratjev" Date: Fri, 25 Jul 2025 14:17:22 +0300 Subject: [PATCH 08/28] NFC-50 Add minimal implementation of WebEidViewModel component --- .../ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt new file mode 100644 index 000000000..01778ba05 --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt @@ -0,0 +1,14 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.viewmodel + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class WebEidViewModel +@Inject +constructor() : ViewModel() { + +} \ No newline at end of file From 2b8f5711950f3f0fccfd453b9b66b41c06f6eb1e Mon Sep 17 00:00:00 2001 From: "Sander.Kondratjev" Date: Fri, 25 Jul 2025 16:11:11 +0300 Subject: [PATCH 09/28] NFC-50 Display parsed Web EID auth payload in WebEidScreen after URI handling --- .../ee/ria/DigiDoc/RIADigiDocAppNavigation.kt | 1 + .../ee/ria/DigiDoc/fragment/WebEidFragment.kt | 9 +++++ .../DigiDoc/fragment/screen/WebEidScreen.kt | 19 +++++++++- .../ria/DigiDoc/viewmodel/WebEidViewModel.kt | 37 +++++++++++++++++++ 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt b/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt index 296db8b68..f44336c40 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt @@ -363,6 +363,7 @@ fun RIADigiDocAppScreen(externalFileUris: List, webEidUri: Uri? = null) { WebEidFragment( modifier = Modifier.safeDrawingPadding(), navController = navController, + webEidUri = webEidUri, ) } } 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 24869ead1..66b897d80 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt @@ -3,11 +3,13 @@ package ee.ria.DigiDoc.fragment import android.content.res.Configuration +import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -26,8 +28,14 @@ import ee.ria.DigiDoc.viewmodel.WebEidViewModel fun WebEidFragment( modifier: Modifier = Modifier, navController: NavHostController, + webEidUri: Uri?, viewModel: WebEidViewModel = hiltViewModel(), ) { + LaunchedEffect(webEidUri) { + println("DEBUG: WebEidFragment got URI = $webEidUri") + webEidUri?.let { viewModel.handleAuth(it) } + } + Surface( modifier = modifier @@ -52,6 +60,7 @@ fun WebEidFragmentPreview() { RIADigiDocTheme { WebEidFragment( navController = rememberNavController(), + 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 5b241b55b..dd3bd84fc 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 @@ -4,17 +4,22 @@ package ee.ria.DigiDoc.fragment.screen import android.content.res.Configuration 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.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag 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 @@ -27,6 +32,8 @@ fun WebEidScreen( navController: NavHostController, viewModel: WebEidViewModel, ) { + val auth = viewModel.authPayload.collectAsState().value + Surface( modifier = modifier @@ -36,8 +43,16 @@ fun WebEidScreen( .testTag("webEidScreen"), color = MaterialTheme.colorScheme.background, ) { - Box(modifier = Modifier.fillMaxSize()) { - Text("Web eID Screen placeholder") + 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}") + } + } else { + Text("No auth payload received.") + } } } } 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 01778ba05..0bfccc18b 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt @@ -2,8 +2,13 @@ package ee.ria.DigiDoc.viewmodel +import android.net.Uri import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.json.JSONObject +import java.util.Base64 import javax.inject.Inject @HiltViewModel @@ -11,4 +16,36 @@ 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) { + _authPayload.value = null + } + } + + private fun decodeBase64(encoded: String): String { + return String(Base64.getDecoder().decode(encoded)) + } + + data class AuthRequest( + val challenge: String, + val loginUri: String, + val getSigningCertificate: Boolean = false + ) } \ No newline at end of file From 6bbbb342ac3db76c8b6819d6204ff11a0f7b2bf2 Mon Sep 17 00:00:00 2001 From: "Sander.Kondratjev" Date: Mon, 28 Jul 2025 14:00:04 +0300 Subject: [PATCH 10/28] NFC-50 Add unit tests for WebEidViewModel handleAuth() with base64 and JSON validation --- .../DigiDoc/viewmodel/WebEidViewModelTest.kt | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.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 new file mode 100644 index 000000000..4b2f12c64 --- /dev/null +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt @@ -0,0 +1,81 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.viewmodel + +import android.net.Uri +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.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class WebEidViewModelTest { + + private lateinit var viewModel: WebEidViewModel + + @Before + fun setUp() { + viewModel = WebEidViewModel() + } + + @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") + + viewModel.handleAuth(uri) + + val result = viewModel.authPayload.value + assertEquals("abc123", result?.challenge) + assertEquals("https://example.com/auth/login", result?.loginUri) + assertEquals(true, result?.getSigningCertificate) + } + + @Test + fun handleAuth_missingFragment_setsNullPayload() = runTest { + val uri = Uri.parse("web-eid-mobile://auth") + + viewModel.handleAuth(uri) + + assertNull(viewModel.authPayload.value) + } + + @Test + fun handleAuth_invalidBase64_setsNullPayload() = runTest { + val uri = Uri.parse("web-eid-mobile://auth#invalid-base64!!") + + viewModel.handleAuth(uri) + + assertNull(viewModel.authPayload.value) + } + + @Test + fun handleAuth_missingOptionalField_defaultsToFalse() = runTest { + val json = """ + { + "challenge": "xyz456", + "login_uri": "https://rp.example.com/login" + } + """.trimIndent() + + val encoded = java.util.Base64.getEncoder().encodeToString(json.toByteArray()) + val uri = Uri.parse("web-eid-mobile://auth#$encoded") + + viewModel.handleAuth(uri) + + val result = viewModel.authPayload.value + assertEquals("xyz456", result?.challenge) + assertEquals("https://rp.example.com/login", result?.loginUri) + assertEquals(false, result?.getSigningCertificate) + } +} From 9b44dfee9da95d380d2e115713cdcfdb1dd49c62 Mon Sep 17 00:00:00 2001 From: "Sander.Kondratjev" Date: Mon, 28 Jul 2025 14:07:04 +0300 Subject: [PATCH 11/28] NFC-50 Log auth payload parsing errors in handleAuth(); retain navController in WebEidScreen for future use --- .../kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt | 4 ++-- .../main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt | 1 + 2 files changed, 3 insertions(+), 2 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 dd3bd84fc..b95ebab14 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 @@ -29,7 +29,7 @@ import ee.ria.DigiDoc.viewmodel.WebEidViewModel @Composable fun WebEidScreen( modifier: Modifier = Modifier, - navController: NavHostController, + // navController: NavHostController, // navController is not yet used; reserved for navigation after auth completes viewModel: WebEidViewModel, ) { val auth = viewModel.authPayload.collectAsState().value @@ -63,7 +63,7 @@ fun WebEidScreen( fun WebEidScreenPreview() { RIADigiDocTheme { WebEidScreen( - navController = rememberNavController(), + // navController = rememberNavController(), viewModel = hiltViewModel(), ) } 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 0bfccc18b..2d5859e79 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt @@ -35,6 +35,7 @@ constructor() : ViewModel() { getSigningCertificate = getSigningCertificate ) } catch (e: Exception) { + println("Failed to authenticate: ${e.message}") _authPayload.value = null } } From 14f69086f7ef4b3c5e8b41b2b9c5c90e33905f7f Mon Sep 17 00:00:00 2001 From: "Sander.Kondratjev" Date: Mon, 28 Jul 2025 14:08:03 +0300 Subject: [PATCH 12/28] NFC-50 Retain navController in WebEidScreen for future use --- .../kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 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 b95ebab14..abfd7aaae 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 @@ -29,7 +29,7 @@ import ee.ria.DigiDoc.viewmodel.WebEidViewModel @Composable fun WebEidScreen( modifier: Modifier = Modifier, - // navController: NavHostController, // navController is not yet used; reserved for navigation after auth completes + navController: NavHostController, // navController is not yet used; reserved for navigation after auth completes viewModel: WebEidViewModel, ) { val auth = viewModel.authPayload.collectAsState().value @@ -63,7 +63,7 @@ fun WebEidScreen( fun WebEidScreenPreview() { RIADigiDocTheme { WebEidScreen( - // navController = rememberNavController(), + navController = rememberNavController(), viewModel = hiltViewModel(), ) } From 56b2b425eae397eb32557ed6c56cf18d0f902762 Mon Sep 17 00:00:00 2001 From: SanderKondratjevNortal Date: Fri, 6 Feb 2026 12:40:58 +0200 Subject: [PATCH 13/28] Implement certificate and signing logic (#305) * Implement initial Web eID mobile authentication flow (#242) * NFC-57 Fix and improve authentication flow * NFC-83 Implement certificate and signing logic --- app/build.gradle.kts | 1 + .../DigiDoc/viewmodel/WebEidViewModelTest.kt | 524 ++++++++++++++-- .../kotlin/ee/ria/DigiDoc/MainActivity.kt | 13 +- .../ee/ria/DigiDoc/RIADigiDocAppNavigation.kt | 22 +- .../DigiDoc/domain/model/IdentityAction.kt | 1 + .../DigiDoc/domain/preferences/DataStore.kt | 29 + .../ee/ria/DigiDoc/fragment/WebEidFragment.kt | 62 +- .../DigiDoc/fragment/screen/WebEidScreen.kt | 569 +++++++++++++++++- .../ria/DigiDoc/ui/component/shared/TopBar.kt | 45 +- .../DigiDoc/ui/component/signing/NFCView.kt | 145 ++++- .../ee/ria/DigiDoc/viewmodel/NFCViewModel.kt | 529 ++++++++++------ .../ria/DigiDoc/viewmodel/WebEidViewModel.kt | 209 +++++-- app/src/main/res/values-et/strings.xml | 20 + app/src/main/res/values/donottranslate.xml | 1 + app/src/main/res/values/strings.xml | 22 +- .../DigiDoc/domain/service/IdCardService.kt | 15 + .../domain/service/IdCardServiceImpl.kt | 52 ++ settings.gradle.kts | 1 + .../extensions/ByteArrayExtensions.kt | 7 + .../extensions/ByteArrayExtensionsTest.kt | 21 + web-eid-lib/.gitignore | 1 + web-eid-lib/build.gradle.kts | 66 ++ web-eid-lib/proguard-rules.pro | 21 + .../DigiDoc/webEid/WebEidAuthServiceTest.kt | 119 ++++ .../DigiDoc/webEid/WebEidRequestParserTest.kt | 285 +++++++++ .../DigiDoc/webEid/WebEidSignServiceTest.kt | 130 ++++ .../ria/DigiDoc/webEid/di/AppModulesTest.kt | 55 ++ .../webEid/exception/WebEidExceptionTest.kt | 46 ++ .../webEid/utils/WebEidAlgorithmUtilTest.kt | 152 +++++ .../webEid/utils/WebEidResponseUtilTest.kt | 98 +++ web-eid-lib/src/main/AndroidManifest.xml | 4 + .../ria/DigiDoc/webEid/WebEidAuthService.kt | 32 + .../DigiDoc/webEid/WebEidAuthServiceImpl.kt | 66 ++ .../ria/DigiDoc/webEid/WebEidSignService.kt | 34 ++ .../DigiDoc/webEid/WebEidSignServiceImpl.kt | 66 ++ .../webEid/domain/model/WebEidAuthRequest.kt | 29 + .../domain/model/WebEidCertificateRequest.kt | 27 + .../webEid/domain/model/WebEidSignRequest.kt | 32 + .../webEid/exception/WebEidErrorCode.kt | 27 + .../webEid/exception/WebEidException.kt | 28 + .../webEid/utils/WebEidAlgorithmUtil.kt | 110 ++++ .../webEid/utils/WebEidRequestParser.kt | 211 +++++++ .../webEid/utils/WebEidResponseUtil.kt | 55 ++ .../ee/ria/DigiDoc/webEid/di/AppModules.kt | 44 ++ 44 files changed, 3696 insertions(+), 330 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/WebEidAuthServiceTest.kt create mode 100644 web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt 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/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/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/domain/model/WebEidAuthRequest.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidCertificateRequest.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/exception/WebEidErrorCode.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/exception/WebEidException.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtil.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt create mode 100644 web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/di/AppModules.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a9038cdea..f57d6b401 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 4b2f12c64..d6bc8a895 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt @@ -1,81 +1,523 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + @file:Suppress("PackageName") package ee.ria.DigiDoc.viewmodel 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 +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import org.json.JSONObject import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull +import org.junit.Assert.assertNotNull import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.Base64 -@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) class WebEidViewModelTest { + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + @Mock + private lateinit var authService: WebEidAuthService + + @Mock + private lateinit var signService: WebEidSignService private lateinit var viewModel: WebEidViewModel + 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() { - viewModel = WebEidViewModel() + fun setup() { + MockitoAnnotations.openMocks(this) + viewModel = WebEidViewModel(authService, signService) } @Test - fun handleAuth_validUri_setsAuthPayload() = runTest { - val json = """ - { - "challenge": "abc123", - "login_uri": "https://example.com/auth/login", - "get_signing_certificate": true - } - """.trimIndent() + 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 webEidViewModel_handleAuth_emitErrorResponseEventWhenChallengeMinLength() { + val uri = + Uri.parse( + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwibG9naW5fdXJpIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZXNwb25zZSIsImdldF9zaWduaW5nX2NlcnRpZmljYXRlIjp0cnVlfQ", + ) + webEidViewModel_handleAuth_emitErrorResponseEventWhenInvalidChallenge(uri) + } + + @Test + fun webEidViewModel_handleAuth_emitErrorResponseEventWhenChallengeMaxLength() { + val uri = + Uri.parse( + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLCJsb2dpbl91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwiZ2V0X3NpZ25pbmdfY2VydGlmaWNhdGUiOnRydWV9", + ) + webEidViewModel_handleAuth_emitErrorResponseEventWhenInvalidChallenge(uri) + } - val encoded = java.util.Base64.getEncoder().encodeToString(json.toByteArray()) - val uri = Uri.parse("web-eid-mobile://auth#$encoded") + @OptIn(ExperimentalCoroutinesApi::class) + private fun webEidViewModel_handleAuth_emitErrorResponseEventWhenInvalidChallenge(uri: Uri) { + runTest(UnconfinedTestDispatcher()) { + val deferred = + async { + viewModel.relyingPartyResponseEvents.first() + } - viewModel.handleAuth(uri) + viewModel.handleAuth(uri) - val result = viewModel.authPayload.value - assertEquals("abc123", result?.challenge) - assertEquals("https://example.com/auth/login", result?.loginUri) - assertEquals(true, result?.getSigningCertificate) + 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 handleAuth_missingFragment_setsNullPayload() = runTest { - val uri = Uri.parse("web-eid-mobile://auth") + @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) + viewModel.handleAuth(uri) - assertNull(viewModel.authPayload.value) + 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 handleAuth_invalidBase64_setsNullPayload() = runTest { - val uri = Uri.parse("web-eid-mobile://auth#invalid-base64!!") + @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) + } + } - viewModel.handleAuth(uri) + @Test + @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) - assertNull(viewModel.authPayload.value) + 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 handleAuth_missingOptionalField_defaultsToFalse() = runTest { - val json = """ - { - "challenge": "xyz456", - "login_uri": "https://rp.example.com/login" - } - """.trimIndent() + @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")) + } + } + + @Test + @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")) + } + } + + @Test + fun webEidViewModel_handleCertificate_parsesCertificateUriAndSetsStateFlow() { + runTest { + val uri = + Uri.parse( + "web-eid-mobile://cert#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIn0", + ) + viewModel.handleCertificate(uri) + val authRequest = viewModel.authRequest.value + val certificateRequest = viewModel.certificateRequest.value + val signRequest = viewModel.signRequest.value + assert(authRequest == null) + assert(certificateRequest != null) + assert(signRequest == null) + assertEquals("https://example.com/response", certificateRequest?.responseUri) + } + } + + @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_request_error, + viewModel.dialogError.value, + ) + } + } + + @Test + fun webEidViewModel_handleSign_parsesSignUriAndSetsStateFlow() { + runTest { + val uri = Uri.parse(createSignUri(signingCertBase64)) + viewModel.handleSign(uri) + val authRequest = viewModel.authRequest.value + val signRequest = viewModel.signRequest.value + assert(authRequest == null) + assert(signRequest != null) + assertEquals("https://rp.example.com/sign/response", signRequest?.responseUri) + assertNotNull(signRequest?.hash) + assertEquals("SHA-384", 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() + } - val encoded = java.util.Base64.getEncoder().encodeToString(json.toByteArray()) - val uri = Uri.parse("web-eid-mobile://auth#$encoded") + viewModel.handleSign(uri) - 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 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_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_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.handleCertificate(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.handleCertificate(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" + val hashFunction = "SHA-384" + + whenever(signService.buildSignPayload(signingCert, signature, hashFunction)) + .thenReturn(JSONObject().put("signature", "mock-signature")) + + val deferred = + async { + viewModel.relyingPartyResponseEvents.first() + } + + viewModel.handleSign(Uri.parse(createSignUri(signingCertBase64))) + viewModel.handleWebEidSignResult(signingCert, signature, responseUri) + + verify(signService).buildSignPayload(signingCert, signature, hashFunction) + 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" + val hashFunction = "SHA-384" + + whenever(signService.buildSignPayload(signingCert, signature, hashFunction)) + .thenThrow(RuntimeException("Test exception")) + + val deferred = + async { + viewModel.relyingPartyResponseEvents.first() + } + viewModel.handleSign(Uri.parse(createSignUri(signingCertBase64))) + viewModel.handleWebEidSignResult(signingCert, signature, responseUri) + + verify(signService).buildSignPayload(signingCert, signature, hashFunction) + 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")) + } + } + + private fun createSignUri(signingCertificate: String? = null): String { + val hash = validSha384Base64() + val hashFunction = "SHA-384" + val responseUri = "https://rp.example.com/sign/response" + val sb = StringBuilder() + sb.append("{\"response_uri\":\"$responseUri\"") + sb.append(",\"hash\":\"$hash\"") + sb.append(",\"hash_function\":\"$hashFunction\"") + if (signingCertificate != null) { + sb.append(",\"signing_certificate\":\"$signingCertificate\"") + } + sb.append("}") + val encoded = Base64.getEncoder().encodeToString(sb.toString().toByteArray()) + return "web-eid-mobile://sign#$encoded" + } - val result = viewModel.authPayload.value - assertEquals("xyz456", result?.challenge) - assertEquals("https://rp.example.com/login", result?.loginUri) - assertEquals(false, result?.getSigningCertificate) + private fun validSha384Base64(): String { + val digest = java.security.MessageDigest.getInstance("SHA-384") + val hash = digest.digest("test-data".toByteArray()) + return Base64.getEncoder().encodeToString(hash) } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt b/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt index f2d98b3a7..139cf8000 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/RIADigiDocAppNavigation.kt b/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt index f44336c40..12c966c38 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt @@ -74,7 +74,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() @@ -86,11 +89,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, @@ -374,7 +378,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/domain/model/IdentityAction.kt b/app/src/main/kotlin/ee/ria/DigiDoc/domain/model/IdentityAction.kt index fed43743d..2db818650 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 f9b142dc7..80a9161a3 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 @@ -113,6 +114,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 66b897d80..eb5dcf0f1 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt @@ -1,9 +1,31 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + @file:Suppress("PackageName", "FunctionName") 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 @@ -16,12 +38,15 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.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 +55,34 @@ fun WebEidFragment( navController: NavHostController, webEidUri: Uri?, viewModel: WebEidViewModel = hiltViewModel(), + sharedSettingsViewModel: SharedSettingsViewModel = hiltViewModel(), + 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) { - println("DEBUG: WebEidFragment got URI = $webEidUri") - webEidUri?.let { viewModel.handleAuth(it) } + webEidUri?.let { + when (it.host) { + "auth" -> viewModel.handleAuth(it) + "cert" -> viewModel.handleCertificate(it) + "sign" -> viewModel.handleSign(it) + else -> { + viewModel.handleUnknown(it) + } + } + } } Surface( @@ -49,6 +98,9 @@ fun WebEidFragment( modifier = modifier, navController = navController, viewModel = viewModel, + sharedSettingsViewModel = sharedSettingsViewModel, + sharedContainerViewModel = sharedContainerViewModel, + sharedMenuViewModel = sharedMenuViewModel, ) } } @@ -60,7 +112,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 abfd7aaae..b0af98325 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 @@ -1,59 +1,578 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + @file:Suppress("PackageName", "FunctionName") 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.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 +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 +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.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.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.hilt.lifecycle.viewmodel.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.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 +import ee.ria.DigiDoc.ui.component.signing.NFCView +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 ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest +import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3Api::class) @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 - - 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 noAuthLabel = stringResource(id = R.string.web_eid_auth_no_payload) + val activity = LocalActivity.current as Activity + val authRequest = viewModel.authRequest.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) } + + val certificateRequest = viewModel.certificateRequest.collectAsState().value + val isCertificateFlow = certificateRequest != null + 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) } + val snackBarHostState = remember { SnackbarHostState() } + val snackBarScope = rememberCoroutineScope() + val messages by SnackBarManager.messages.collectAsState(emptyList()) + val dialogError by viewModel.dialogError.collectAsState() + var rememberMe by rememberSaveable { mutableStateOf(true) } + val hasStoredCanNumber = sharedSettingsViewModel.dataStore.getCanNumber().isNotEmpty() + + 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, + showNavigationIcon = false, + onLeftButtonClick = {}, + onRightSecondaryButtonClick = { + isSettingsMenuBottomSheetVisible.value = true + }, + ) + }, + ) { paddingValues -> + SettingsMenuBottomSheet( + navController = navController, + isBottomSheetVisible = isSettingsMenuBottomSheetVisible, + ) + + if (dialogError != 0) { + 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) } - } else { - Text("No auth payload received.") } } + + if (dialogError == 0) { + Column( + modifier = + modifier + .fillMaxSize() + .padding(paddingValues) + .padding(SPadding) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(XSPadding), + ) { + val title = + when { + authRequest != null -> stringResource(R.string.web_eid_auth_title) + 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 = title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.semantics { heading() }, + ) + if (authRequest != null) { + if (!isWebEidAuthenticating) { + WebEidAuthInfo(authRequest = authRequest) + } + + NFCView( + activity = activity, + identityAction = IdentityAction.AUTH, + rememberMe = rememberMe, + 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, + ) + + if (!isWebEidAuthenticating) { + WebEidRememberMe( + rememberMe = rememberMe, + onRememberMeChange = { rememberMe = it }, + ) + } + } else if (isCertificateFlow || signRequest != null) { + if (!isWebEidAuthenticating) { + val origin = + when { + isCertificateFlow -> certificateRequest.origin + signRequest != null -> signRequest.origin + else -> "" + } + WebEidSignOrCertificateInfo( + origin = origin, + isCertificateFlow = isCertificateFlow, + ) + } + + if (isCertificateFlow) { + NFCView( + activity = activity, + identityAction = IdentityAction.CERTIFICATE, + rememberMe = rememberMe, + 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, + ) + + if (!isWebEidAuthenticating) { + WebEidRememberMe( + rememberMe = rememberMe, + onRememberMeChange = { rememberMe = it }, + ) + } + } else { + NFCView( + activity = activity, + identityAction = IdentityAction.SIGN, + isCertificate = false, + isSigning = false, + isDecrypting = false, + isWebEidAuthenticating = isWebEidAuthenticating, + canNumberReadOnly = hasStoredCanNumber, + 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) { + 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 (isCertificateFlow || signRequest != null) { + 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) + }, + ) + } + } + } + + OutlinedButton( + onClick = { + isWebEidAuthenticating = false + activity.finishAndRemoveTask() + }, + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onBackground, + ), + ) { + Text( + text = stringResource(R.string.web_eid_cancel), + ) + } + } + } + } +} + +@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_details_forwarded), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Left, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = stringResource(R.string.web_eid_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 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 = stringResource(R.string.web_eid_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, + 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), + ) } } @@ -64,7 +583,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/shared/TopBar.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/TopBar.kt index 99105611e..dc4f3c943 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 837b820b6..99cf22e7c 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 @@ -106,6 +106,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 @@ -114,6 +115,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 @@ -124,6 +126,8 @@ fun NFCView( isSigning: Boolean = false, isDecrypting: Boolean = false, isAuthenticating: Boolean = false, + isCertificate: Boolean = false, + isWebEidAuthenticating: Boolean = false, onError: () -> Unit = {}, onSuccess: () -> Unit = {}, isAddingRoleAndAddress: Boolean = false, @@ -134,13 +138,20 @@ fun NFCView( isSupported: (Boolean) -> Unit = {}, isValidToSign: (Boolean) -> Unit = {}, isValidToDecrypt: (Boolean) -> Unit = {}, + isValidToWebEidAuthenticate: (Boolean) -> Unit = {}, showPinField: Boolean = true, isValidToAuthenticate: (Boolean) -> Unit = {}, signAction: (() -> Unit) -> Unit = {}, decryptAction: (() -> Unit) -> Unit = {}, cancelAction: (() -> Unit) -> Unit = {}, cancelDecryptAction: (() -> Unit) -> Unit = {}, + authenticateWebEidAction: (() -> Unit) -> Unit = {}, + cancelWebEidAuthenticateAction: (() -> Unit) -> Unit = {}, + signWebEidAction: (() -> Unit) -> Unit = {}, + cancelWebEidSignAction: (() -> Unit) -> Unit = {}, isAuthenticated: (Boolean, IdCardData) -> Unit = { _, _ -> }, + webEidViewModel: WebEidViewModel? = null, + canNumberReadOnly: Boolean = false, ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -168,14 +179,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("") } } @@ -211,6 +235,15 @@ fun NFCView( CodeType.PIN1 } + val webEidAuth = webEidViewModel?.authRequest?.collectAsState()?.value + val originString = webEidAuth?.origin ?: "" + val challengeString = webEidAuth?.challenge ?: "" + + val webEidCertificate = webEidViewModel?.certificateRequest?.collectAsState()?.value + val webEidSign = webEidViewModel?.signRequest?.collectAsState()?.value + val responseUriString = webEidSign?.responseUri ?: webEidCertificate?.responseUri ?: "" + val hashString = webEidSign?.hash ?: "" + BackHandler { nfcViewModel.handleBackButton() if (isSigning || isDecrypting || isAuthenticating) { @@ -299,6 +332,40 @@ 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() + } + } + } + + 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 @@ -431,7 +498,7 @@ fun NFCView( ) { if (isAddingRoleAndAddress) { RoleDataView(modifier, sharedSettingsViewModel) - } else if (isSigning || isAuthenticating || isDecrypting) { + } else if (isSigning || isWebEidAuthenticating || isAuthenticating || isDecrypting) { NFCSignatureUpdateContainer( nfcViewModel = nfcViewModel, onError = onError, @@ -477,11 +544,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) @@ -489,6 +560,7 @@ fun NFCView( LaunchedEffect(isValid) { isValidToSign(isValid) isValidToDecrypt(isValid) + isValidToWebEidAuthenticate(isValid) } LaunchedEffect(Unit, rememberMe) { @@ -549,6 +621,51 @@ fun NFCView( ) } } + authenticateWebEidAction { + saveFormParams() + scope.launch(IO) { + nfcViewModel.performNFCWebEidAuthWorkRequest( + activity = activity, + context = context, + canNumber = canNumber.text, + pin1Code = pinCode.value, + origin = originString, + challenge = challengeString, + ) + } + } + signWebEidAction { + scope.launch(IO) { + val isCertificateFlow = webEidCertificate != null + val cachedCert = sharedSettingsViewModel.dataStore.getSigningCertificate() + + if (isCertificateFlow) { + saveFormParams() + if (cachedCert.isNotEmpty()) { + val certBytes = Base64.getDecoder().decode(cachedCert) + webEidViewModel.handleWebEidCertificateResult(certBytes) + onSuccess() + } else { + nfcViewModel.performNFCWebEidCertificateWorkRequest( + activity = activity, + canNumber = canNumber.text, + ) + } + } else { + if (sharedSettingsViewModel.dataStore.getCanNumber().isNotEmpty()) { + saveFormParams() + } + nfcViewModel.performNFCWebEidSignWorkRequest( + activity = activity, + context = context, + canNumber = canNumber.text, + pin2Code = pinCode.value, + responseUri = responseUriString, + hash = hashString, + ) + } + } + } cancelAction { nfcViewModel.handleBackButton() scope.launch(IO) { @@ -557,7 +674,17 @@ fun NFCView( } cancelDecryptAction { nfcViewModel.handleBackButton() - nfcViewModel.cancelNFCDecryptWorkRequest() + nfcViewModel.cancelNfcOperation() + } + + cancelWebEidAuthenticateAction { + nfcViewModel.handleBackButton() + nfcViewModel.cancelNfcOperation() + } + + cancelWebEidSignAction { + nfcViewModel.handleBackButton() + nfcViewModel.cancelNfcOperation() } } } @@ -606,6 +733,8 @@ fun NFCView( TextFieldValue(removeInvisibleElement(it.text)) } }, + readOnly = canNumberReadOnly, + enabled = !canNumberReadOnly, singleLine = true, label = canNumberLabel, readDigitByDigit = true, 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 ca4f9f520..15f4f7a9b 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt @@ -27,7 +27,6 @@ import android.content.pm.ActivityInfo import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.google.common.collect.ImmutableMap import dagger.hilt.android.lifecycle.HiltViewModel import ee.ria.DigiDoc.R @@ -54,12 +53,11 @@ import ee.ria.DigiDoc.smartcardreader.SmartCardReaderException import ee.ria.DigiDoc.smartcardreader.nfc.NfcSmartCardReaderManager import ee.ria.DigiDoc.smartcardreader.nfc.NfcSmartCardReaderManager.NfcStatus import ee.ria.DigiDoc.utils.pin.PinCodeUtil.isPINLengthValid +import ee.ria.DigiDoc.utilsLib.extensions.clearSensitive import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog import ee.ria.libdigidocpp.ExternalSigner -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.Main -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.bouncycastle.util.encoders.Hex @@ -102,6 +100,12 @@ 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 _webEidSignResult = MutableLiveData?>() + val webEidSignResult: LiveData?> = _webEidSignResult + private val _webEidCertificateResult = MutableLiveData() + val webEidCertificateResult: LiveData = _webEidCertificateResult private val dialogMessages: ImmutableMap = ImmutableMap @@ -138,6 +142,18 @@ class NFCViewModel _shouldResetPIN.postValue(false) } + fun resetWebEidAuthResult() { + _webEidAuthResult.postValue(null) + } + + fun resetWebEidSignResult() { + _webEidSignResult.postValue(null) + } + + fun resetWebEidCertificateResult() { + _webEidCertificateResult.postValue(null) + } + fun shouldShowCANNumberError(canNumber: String?): Boolean = ( !canNumber.isNullOrEmpty() && @@ -193,7 +209,7 @@ class NFCViewModel nfcSmartCardReaderManager.disableNfcReaderMode() } - fun cancelNFCDecryptWorkRequest() { + fun cancelNfcOperation() { nfcSmartCardReaderManager.disableNfcReaderMode() } @@ -229,9 +245,8 @@ class NFCViewModel nfcSmartCardReaderManager.startDiscovery(activity) { nfcReader, exc -> if ((nfcReader != null) && (exc == null)) { try { - CoroutineScope(Main).launch { - _message.postValue(R.string.signature_update_nfc_detected) - } + _message.postValue(R.string.signature_update_nfc_detected) + val card = TokenWithPace.create(nfcReader) card.tunnel(canNumber) val signerCert = card.certificate(CertificateType.SIGNING) @@ -249,9 +264,7 @@ class NFCViewModel val signatureArray = card.calculateSignature(pin2Code, dataToSignBytes, true) - if (null != pin2Code && pin2Code.isNotEmpty()) { - Arrays.fill(pin2Code, 0.toByte()) - } + pin2Code.clearSensitive() debugLog(logTag, "Signature: " + Hex.toHexString(signatureArray)) containerWrapper.finalizeSignature( @@ -260,97 +273,32 @@ class NFCViewModel signatureArray, ) - CoroutineScope(Main).launch { - _shouldResetPIN.postValue(true) - _signStatus.postValue(true) - _signedContainer.postValue(container) - } + _shouldResetPIN.postValue(true) + _signStatus.postValue(true) + _signedContainer.postValue(container) } 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 has not been changed") == true) { - _dialogError.postValue(R.string.sign_blocked_pin2_unchanged_message) - } 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) + handleSmartCardReaderException(ex, CodeType.PIN2, pinType) } catch (ex: Exception) { _signStatus.postValue(false) _shouldResetPIN.postValue(true) - val message = ex.message ?: "" + val message = ex.message.orEmpty() 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) - } + message.contains("Certificate status: revoked") -> + showRevokedCertificateError(ex) - errorLog(logTag, "Exception: " + ex.message, ex) - } finally { - if (null != pin2Code && pin2Code.isNotEmpty()) { - Arrays.fill(pin2Code, 0.toByte()) + message.contains("Certificate status: unknown") -> + showUnknownCertificateError(ex) + + handleGeneralException(ex) -> + Unit + + else -> + showTechnicalError(ex) } + } finally { + pin2Code.clearSensitive() nfcSmartCardReaderManager.disableNfcReaderMode() activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED @@ -388,9 +336,8 @@ class NFCViewModel nfcSmartCardReaderManager.startDiscovery(activity) { nfcReader, exc -> if ((nfcReader != null) && (exc == null)) { try { - CoroutineScope(Main).launch { - _message.postValue(R.string.signature_update_nfc_detected) - } + _message.postValue(R.string.signature_update_nfc_detected) + val card = TokenWithPace.create(nfcReader) card.tunnel(canNumber) @@ -411,103 +358,38 @@ class NFCViewModel cdoc2Settings, configurationRepository, ) - if (pin1Code.isNotEmpty()) { - Arrays.fill(pin1Code, 0.toByte()) - } - CoroutineScope(Main).launch { - _shouldResetPIN.postValue(true) - _decryptStatus.postValue(true) - _cryptoContainer.postValue(decryptedContainer) - } + pin1Code.clearSensitive() + + _shouldResetPIN.postValue(true) + _decryptStatus.postValue(true) + _cryptoContainer.postValue(decryptedContainer) } 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) + handleSmartCardReaderException(ex, CodeType.PIN1, pinType) } catch (ex: Exception) { _decryptStatus.postValue(false) _shouldResetPIN.postValue(true) - val message = ex.message ?: "" + val message = ex.message.orEmpty() 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) - message.contains("Certificate status: revoked") -> showRevokedCertificateError(ex) - message.contains("Certificate status: unknown") -> showUnknownCertificateError(ex) + message.contains("Certificate status: revoked") -> + showRevokedCertificateError(ex) - else -> showTechnicalError(ex) - } + message.contains("Certificate status: unknown") -> + showUnknownCertificateError(ex) - errorLog(logTag, "Exception: " + ex.message, ex) - } finally { - if (pin1Code.isNotEmpty()) { - Arrays.fill(pin1Code, 0.toByte()) + handleGeneralException(ex) -> + Unit + + else -> + showTechnicalError(ex) } + } finally { + pin1Code.clearSensitive() nfcSmartCardReaderManager.disableNfcReaderMode() activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED @@ -535,9 +417,7 @@ class NFCViewModel checkNFCStatus( nfcSmartCardReaderManager.startDiscovery(activity) { nfcReader, exc -> if ((nfcReader != null) && (exc == null)) { - viewModelScope.launch { - _message.postValue(R.string.signature_update_nfc_detected) - } + _message.postValue(R.string.signature_update_nfc_detected) try { val card = TokenWithPace.create(nfcReader) card.tunnel(canNumber) @@ -588,6 +468,202 @@ 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 { + _message.postValue(R.string.signature_update_nfc_detected) + + val card = TokenWithPace.create(nfcReader) + card.tunnel(canNumber) + + val (authCert, signingCert, signatureArray) = + idCardService.authenticate( + token = card, + pin1 = pin1Code, + origin = origin, + challenge = challenge, + ) + + pin1Code.clearSensitive() + + _shouldResetPIN.postValue(true) + _webEidAuthResult.postValue(Triple(authCert, signingCert, signatureArray)) + } catch (ex: SmartCardReaderException) { + handleSmartCardReaderException(ex, CodeType.PIN1, pinType) + } catch (ex: Exception) { + _shouldResetPIN.postValue(true) + + val message = ex.message.orEmpty() + + when { + message.contains("No lock found with certificate key") -> + showNoLockFoundError(ex) + + handleGeneralException(ex) -> + Unit + + else -> + showTechnicalError(ex) + } + } finally { + if (pin1Code.isNotEmpty()) { + Arrays.fill(pin1Code, 0.toByte()) + } + nfcSmartCardReaderManager.disableNfcReaderMode() + activity.requestedOrientation = + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + }, + ) + } + + 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 { + _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) + + _webEidCertificateResult.postValue(signingCertB64) + } catch (ex: SmartCardReaderException) { + 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) { + val message = ex.message.orEmpty() + + when { + message.contains("No lock found with certificate key") -> + showNoLockFoundError(ex) + + handleGeneralException(ex) -> + Unit + + else -> + showTechnicalError(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 { + _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) + + _shouldResetPIN.postValue(true) + _signStatus.postValue(true) + _webEidSignResult.postValue( + Triple(signerCertB64, signatureArray, responseUri), + ) + } catch (ex: SmartCardReaderException) { + handleSmartCardReaderException(ex, CodeType.PIN2, pinType) + } catch (ex: Exception) { + _signStatus.postValue(false) + _shouldResetPIN.postValue(true) + + val message = ex.message.orEmpty() + + when { + message.contains("Certificate status: revoked") -> + showRevokedCertificateError(ex) + + message.contains("Certificate status: unknown") -> + showUnknownCertificateError(ex) + + handleGeneralException(ex) -> + Unit + + else -> + showTechnicalError(ex) + } + } finally { + pin2Code.clearSensitive() + nfcSmartCardReaderManager.disableNfcReaderMode() + activity.requestedOrientation = + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + }, + ) + } + fun handleBackButton() { _shouldResetPIN.postValue(true) resetValues() @@ -654,4 +730,95 @@ class NFCViewModel _errorState.postValue(Triple(R.string.signature_update_nfc_technical_error, null, null)) errorLog(logTag, "Unable to perform with NFC: ${e.message}", e) } + + private fun handleSmartCardReaderException( + ex: SmartCardReaderException, + codeType: CodeType, + pinType: String, + ) { + val pinName = codeType.name + val isSigning = codeType == CodeType.PIN2 + + if (isSigning) { + _signStatus.postValue(false) + } + + when { + ex.message?.contains("TagLostException") == true -> { + _errorState.postValue(Triple(R.string.signature_update_nfc_tag_lost, null, null)) + } + + isSigning && ex.message?.contains("PIN2 has not been changed") == true -> { + _dialogError.postValue(R.string.sign_blocked_pin2_unchanged_message) + } + + ex.message?.contains("$pinName 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)) + } + + ex.message?.contains("$pinName 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)) + } + + ex.message?.contains("$pinName 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)) + } + + ex is ApduResponseException -> { + _errorState.postValue(Triple(R.string.signature_update_nfc_technical_error, null, null)) + } + + ex is PaceTunnelException -> { + _errorState.postValue(Triple(R.string.signature_update_nfc_wrong_can, null, null)) + } + + else -> { + showTechnicalError(ex) + } + } + + errorLog(logTag, "Exception: ${ex.message}", ex) + } + + private fun handleGeneralException(ex: Exception): Boolean { + val message = ex.message.orEmpty() + + return when { + message.contains("Failed to connect") || + message.contains("Failed to create connection with host") -> { + showNetworkError(ex) + true + } + + message.contains("Failed to create proxy connection with host") -> { + showProxyError(ex) + true + } + + message.contains("Too Many Requests") -> { + setErrorState(SessionStatusResponseProcessStatus.TOO_MANY_REQUESTS) + true + } + + message.contains("OCSP response not in valid time slot") -> { + setErrorState(SessionStatusResponseProcessStatus.OCSP_INVALID_TIME_SLOT) + true + } + + else -> false + }.also { + errorLog(logTag, "Exception: ${ex.message}", ex) + } + } + + override fun onCleared() { + super.onCleared() + nfcSmartCardReaderManager.disableNfcReaderMode() + } } 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 2d5859e79..b501f681f 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt @@ -1,3 +1,22 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + @file:Suppress("PackageName") package ee.ria.DigiDoc.viewmodel @@ -5,48 +24,168 @@ package ee.ria.DigiDoc.viewmodel import android.net.Uri import androidx.lifecycle.ViewModel 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.WebEidCertificateRequest +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 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, + private val signService: WebEidSignService, + ) : ViewModel() { + private val logTag = javaClass.simpleName + private val _authRequest = MutableStateFlow(null) + val authRequest: StateFlow = _authRequest.asStateFlow() + private val _certificateRequest = MutableStateFlow(null) + val certificateRequest: StateFlow = _certificateRequest.asStateFlow() + private val _signRequest = MutableStateFlow(null) + val signRequest: StateFlow = _signRequest.asStateFlow() + private val _relyingPartyResponseEvents = MutableSharedFlow() + val relyingPartyResponseEvents: SharedFlow = _relyingPartyResponseEvents.asSharedFlow() + private val _dialogError = MutableStateFlow(0) + val dialogError: StateFlow = _dialogError + + 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.value = R.string.web_eid_invalid_auth_request_error + } } - } - private fun decodeBase64(encoded: String): String { - return String(Base64.getDecoder().decode(encoded)) - } + fun handleCertificate(uri: Uri) { + try { + _certificateRequest.value = WebEidRequestParser.parseCertificateUri(uri) + } catch (e: Exception) { + errorLog(logTag, "Unable parse Web eID certificate request: $uri", e) + _dialogError.value = R.string.web_eid_invalid_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.value = R.string.web_eid_invalid_request_error + } + } + + fun handleUnknown(uri: Uri) { + errorLog(logTag, "Unable parse Web eID request: $uri") + _dialogError.value = R.string.web_eid_invalid_request_error + } + + suspend fun handleWebEidAuthResult( + authCert: ByteArray, + signingCert: ByteArray, + signature: ByteArray, + ) { + val loginUri = authRequest.value?.loginUri!! + val getSigningCertificate = authRequest.value?.getSigningCertificate - data class AuthRequest( - val challenge: String, - val loginUri: String, - val getSigningCertificate: Boolean = false - ) -} \ No newline at end of file + try { + val token = + authService.buildAuthToken( + authCert, + if (getSigningCertificate == true) signingCert else null, + signature, + ) + val payload = JSONObject().put("auth_token", token) + val responseUri = WebEidResponseUtil.createResponseUri(loginUri, payload) + _relyingPartyResponseEvents.emit(responseUri) + } catch (e: Exception) { + 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) + } + } + + suspend fun handleWebEidCertificateResult(signingCert: ByteArray) { + val responseUri = certificateRequest.value?.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 hashFunction = + signRequest.value?.hashFunction + ?: throw IllegalStateException("Missing signRequest") + + val payload = + signService.buildSignPayload( + signingCert, + signature, + hashFunction, + ) + 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 fe8481bab..b60e78bee 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -660,4 +660,24 @@ Muuda + + Autentimine + Autentimine ID-kaardiga + Autentimispäringut ei saadetud + 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 + Sertifikaati valides nõustun oma nime ja isikukoodi edastamisega teenusepakkujale. + Allkirjastamine + Allkirjasta ID-kaardiga + Sertifikaadipäring: + Allkirjastamispäring: + Tühista + Vigane Web eID päring + Päringu viga + Vigane autentimispä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 6007af785..8e254fdff 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 802e6abc5..ec3d4c6d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -336,7 +336,7 @@ Technical error Wrong CAN number Hold your phone near the ID-card - Authenticating with ID-card + Establishing connection with ID-card Sign Next @@ -660,4 +660,24 @@ Edit + + 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. + Authentication request from: + Details forwarded: + NAME, PERSONAL IDENTIFICATION CODE + 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 + Certificate request from: + Signing request from: + Cancel + Invalid Web eID request + Request error + Invalid authentication 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 c220c5367..23b1af900 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 @@ -46,4 +46,19 @@ interface IdCardService { currentPuk: ByteArray, newPin: ByteArray, ): IdCardData + + @Throws(Exception::class) + fun authenticate( + token: Token, + pin1: ByteArray, + 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 fd22cc473..8433f5328 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 @@ -31,6 +31,9 @@ import ee.ria.DigiDoc.idcard.Token import ee.ria.DigiDoc.smartcardreader.SmartCardReaderException 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 @@ -88,4 +91,53 @@ class IdCardServiceImpl token.unblockAndChangeCode(currentPuk, codeType, newPin) return data(token) } + + @Throws(Exception::class) + override fun authenticate( + token: Token, + pin1: ByteArray, + origin: String, + challenge: String, + ): Triple { + val authCert = token.certificate(CertificateType.AUTHENTICATION) + val signingCert = token.certificate(CertificateType.SIGNING) + + val cert = + CertificateFactory + .getInstance("X.509") + .generateCertificate(authCert.inputStream()) + val publicKey = cert.publicKey + + val hashAlg = + when (publicKey) { + is ECPublicKey -> + when (publicKey.params.curve.field.fieldSize) { + 256 -> "SHA-256" + 384 -> "SHA-384" + 521 -> "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 = md.digest(signedData) + val signature = token.authenticate(pin1, tbsHash) + + 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/settings.gradle.kts b/settings.gradle.kts index 19b9123d8..49431513d 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/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/extensions/ByteArrayExtensions.kt b/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/extensions/ByteArrayExtensions.kt index 5392f1ae8..d2853991d 100644 --- a/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/extensions/ByteArrayExtensions.kt +++ b/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/extensions/ByteArrayExtensions.kt @@ -27,6 +27,7 @@ import java.io.ByteArrayInputStream import java.security.cert.CertificateException import java.security.cert.CertificateFactory import java.security.cert.X509Certificate +import java.util.Arrays fun ByteArray.hexString(): String { val hexString = Hex.toHexString(this) @@ -43,3 +44,9 @@ fun ByteArray.x509Certificate(): X509Certificate? = } catch (ce: CertificateException) { null } + +fun ByteArray?.clearSensitive() { + if (this != null && this.isNotEmpty()) { + Arrays.fill(this, 0.toByte()) + } +} diff --git a/utils-lib/src/test/kotlin/ee/ria/DigiDoc/utilsLib/extensions/ByteArrayExtensionsTest.kt b/utils-lib/src/test/kotlin/ee/ria/DigiDoc/utilsLib/extensions/ByteArrayExtensionsTest.kt index 4c0c3c56e..8a0eff54c 100644 --- a/utils-lib/src/test/kotlin/ee/ria/DigiDoc/utilsLib/extensions/ByteArrayExtensionsTest.kt +++ b/utils-lib/src/test/kotlin/ee/ria/DigiDoc/utilsLib/extensions/ByteArrayExtensionsTest.kt @@ -88,4 +88,25 @@ class ByteArrayExtensionsTest { assertNull(result) } + + @Test + fun byteArrayExtensions_clearSensitive_clearsNonEmptyArray() { + val byteArray = byteArrayOf(1, 2, 3, 4) + byteArray.clearSensitive() + assertTrue(byteArray.all { it == 0.toByte() }) + } + + @Test + fun byteArrayExtensions_clearSensitive_doesNothingForEmptyArray() { + val byteArray = byteArrayOf() + byteArray.clearSensitive() + assertTrue(byteArray.isEmpty()) + } + + @Test + fun byteArrayExtensions_clearSensitive_doesNotThrowForNull() { + val byteArray: ByteArray? = null + byteArray.clearSensitive() + assertNull(byteArray) + } } diff --git a/web-eid-lib/.gitignore b/web-eid-lib/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /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 000000000..00cbeddb1 --- /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 = "ee.ria.DigiDoc.webEid" + 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 000000000..481bb4348 --- /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/WebEidAuthServiceTest.kt b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt new file mode 100644 index 000000000..d807e53ca --- /dev/null +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@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.assertFalse +import org.junit.Assert.assertNotEquals +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 service: WebEidAuthService + 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() { + service = WebEidAuthServiceImpl() + } + + @Test + fun buildAuthToken_withValidInputs_returnsValidJson() { + val authCertBytes = Base64.getMimeDecoder().decode(authCertBase64) + val signingCertBytes = Base64.getMimeDecoder().decode(signingCertBase64) + val signature = byteArrayOf(1, 2, 3, 4, 5) + + val token = service.buildAuthToken(authCertBytes, signingCertBytes, signature) + + assertEquals("web-eid:1.1", token.getString("format")) + 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", + ) + } + + @Test + fun buildAuthToken_withoutSigningCertificate_returnsV1Format() { + val authCertBytes = Base64.getMimeDecoder().decode(authCertBase64) + val signature = byteArrayOf(1, 2, 3, 4, 5) + + 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 000000000..7331f348e --- /dev/null +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt @@ -0,0 +1,285 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid + +import android.content.Context +import android.net.Uri +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.WebEidCertificateRequest +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.assertNotNull +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 + + 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() { + context = InstrumentationRegistry.getInstrumentation().targetContext + } + + @Test + fun parseAuthUri_validUri_success() { + val loginUri = "https://rp.example.com/auth/eid/login" + val uri = 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_missingScheme_throwsException() { + val loginUri = "rp.example.com/auth/eid/login" + val uri = 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" + val uri = 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_emptyHost_throwsException() { + val loginUri = "https:///auth/eid/login" + val uri = 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" + val uri = 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")) + } + + @Test + fun parseAuthUri_invalidResponseUri_throwsException() { + val loginUri = "://rp.example.com/auth/eid/login" + val uri = 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 = 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, + 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 = Uri.parse("web-eid://auth#%%%INVALID%%%") + val exception = + assertThrows(IllegalArgumentException::class.java) { + WebEidRequestParser.parseAuthUri(uri) + } + 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 = 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 hash = validSha384Base64() + val uri = Uri.parse(createSignUri(hash, "SHA-384", signingCertBase64)) + val result: WebEidSignRequest = WebEidRequestParser.parseSignUri(uri) + + assertEquals(responseUri, result.responseUri) + assertEquals(hash, result.hash) + assertEquals("SHA-384", result.hashFunction) + assertNotNull(result.signingCertificate) + } + + @Test + fun parseCertificateUri_valid_success() { + val responseUri = "https://rp.example.com/sign/response" + val uri = Uri.parse(createSignUri(null, null)) + val result: WebEidCertificateRequest = WebEidRequestParser.parseCertificateUri(uri) + + assertEquals(responseUri, result.responseUri) + assertNotNull(result.origin) + } + + @Test + fun parseSignUri_invalidBase64_throwsException() { + val uri = 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?, + signingCertificate: String? = null, + ): 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\"") + if (signingCertificate != null) { + sb.append(",\"signing_certificate\":\"$signingCertificate\"") + } + sb.append("}") + val encoded = Base64.getEncoder().encodeToString(sb.toString().toByteArray()) + return "web-eid://sign#$encoded" + } + + private fun validSha384Base64(): String { + val digest = java.security.MessageDigest.getInstance("SHA-384") + val hash = digest.digest("test-data".toByteArray()) + return Base64.getEncoder().encodeToString(hash) + } +} 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 000000000..a0b0d8232 --- /dev/null +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidSignServiceTest.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@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 hashFunction = "SHA-384" + val result = service.buildSignPayload(signingCertBase64, signatureBytes, hashFunction) + + assertEquals( + setOf("signature", "signatureAlgorithm"), + result.keys().asSequence().toSet(), + ) + + val expectedSignature = Base64.getEncoder().encodeToString(signatureBytes) + assertEquals(expectedSignature, result.getString("signature")) + + val signatureAlgorithm = result.getJSONObject("signatureAlgorithm") + + assertEquals("ECC", signatureAlgorithm.getString("cryptoAlgorithm")) + assertEquals("NONE", signatureAlgorithm.getString("paddingScheme")) + assertTrue(signatureAlgorithm.getString("hashFunction").startsWith("SHA-")) + } + + @Test + fun buildSignPayload_differentSignatures_produceDifferentJson() { + val sig1 = byteArrayOf(1, 2, 3) + val sig2 = byteArrayOf(4, 5, 6) + val hashFunction = "SHA-384" + val result1 = service.buildSignPayload(signingCertBase64, sig1, hashFunction) + val result2 = service.buildSignPayload(signingCertBase64, sig2, hashFunction) + + 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 000000000..c99404d44 --- /dev/null +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/di/AppModulesTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@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 000000000..715cbb33c --- /dev/null +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/exception/WebEidExceptionTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@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 000000000..68902af73 --- /dev/null +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtilTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@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")) + } + + @Test + fun buildSignatureAlgorithm_sha256_returnsCorrectAlgorithmObject() { + val result = + WebEidAlgorithmUtil.buildSignatureAlgorithm(ecPublicKey256, "SHA-256") + + assertEquals("ECC", result.getString("cryptoAlgorithm")) + assertEquals("SHA-256", result.getString("hashFunction")) + assertEquals("NONE", result.getString("paddingScheme")) + } + + @Test + fun buildSignatureAlgorithm_sha384_returnsCorrectAlgorithmObject() { + val result = + WebEidAlgorithmUtil.buildSignatureAlgorithm(ecPublicKey384, "SHA-384") + + assertEquals("ECC", result.getString("cryptoAlgorithm")) + assertEquals("SHA-384", result.getString("hashFunction")) + assertEquals("NONE", result.getString("paddingScheme")) + } + + @Test + fun buildSignatureAlgorithm_rsaKey_returnsRsaAlgorithmObject() { + val rsaKey = + KeyPairGenerator + .getInstance("RSA") + .apply { initialize(2048) } + .generateKeyPair() + .public + + val result = WebEidAlgorithmUtil.buildSignatureAlgorithm(rsaKey, "SHA-256") + + assertEquals("RSA", result.getString("cryptoAlgorithm")) + assertEquals("SHA-256", result.getString("hashFunction")) + assertEquals("PKCS1.5", result.getString("paddingScheme")) + } +} 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 000000000..29a1e8068 --- /dev/null +++ b/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +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 +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class WebEidResponseUtilTest { + @Test + fun createRedirect_withCustomPayload_encodesAndAppendsCorrectly() { + val loginUri = "https://rp.example.com/auth/eid/login" + val payload = + JSONObject() + .put("code", "ERR_CUSTOM") + .put("message", "Custom error message") + + val resultUri = WebEidResponseUtil.createResponseUri(loginUri, payload) + + val fragment = resultUri.fragment + val decodedJson = String(Base64.decode(fragment, Base64.URL_SAFE)) + val json = JSONObject(decodedJson) + + assertEquals("ERR_CUSTOM", json.getString("code")) + assertEquals("Custom error message", json.getString("message")) + } + + @Test + 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.createResponseUri(loginUri, payload) + + val fragment = resultUri.fragment + val decodedJson = String(Base64.decode(fragment, Base64.URL_SAFE)) + val json = JSONObject(decodedJson) + + assertEquals("sample-token", json.getString("auth-token")) + assertEquals("abc123", json.getString("challenge")) + } + + @Test + fun appendFragment_keepsBaseUriIntact() { + val loginUri = "https://rp.example.com/auth/eid/login" + val payload = JSONObject().put("foo", "bar") + + val resultUri = WebEidResponseUtil.createResponseUri(loginUri, payload) + + 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/AndroidManifest.xml b/web-eid-lib/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /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 000000000..79419db77 --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthService.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid + +import org.json.JSONObject + +interface WebEidAuthService { + fun buildAuthToken( + authCert: ByteArray, + signingCert: ByteArray?, + signature: ByteArray, + ): 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 000000000..6624258d3 --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@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 WebEidAuthServiceImpl + @Inject + constructor() : WebEidAuthService { + override fun buildAuthToken( + authCert: ByteArray, + signingCert: ByteArray?, + signature: ByteArray, + ): JSONObject { + val cert = + CertificateFactory + .getInstance("X.509") + .generateCertificate(authCert.inputStream()) as X509Certificate + + val publicKey = cert.publicKey + val algorithm = getAlgorithm(publicKey) + + 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)) + + 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") + } + } + } + } 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 000000000..3d4cda43a --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidSignService.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@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, + hashFunction: String, + ): 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 000000000..e03a95b6f --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidSignServiceImpl.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid + +import ee.ria.DigiDoc.webEid.utils.WebEidAlgorithmUtil.buildSignatureAlgorithm +import ee.ria.DigiDoc.webEid.utils.WebEidAlgorithmUtil.buildSupportedSignatureAlgorithms +import ee.ria.DigiDoc.webEid.utils.WebEidAlgorithmUtil.parseCertificate +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, + hashFunction: String, + ): JSONObject { + val publicKey = parseCertificate(signingCert).publicKey + return JSONObject().apply { + put("signature", Base64.getEncoder().encodeToString(signature)) + put( + "signatureAlgorithm", + buildSignatureAlgorithm(publicKey, hashFunction), + ) + } + } + } 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 000000000..3211f62f0 --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthRequest.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@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/WebEidCertificateRequest.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidCertificateRequest.kt new file mode 100644 index 000000000..d787cfcbf --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidCertificateRequest.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.domain.model + +data class WebEidCertificateRequest( + val responseUri: String, + 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 000000000..46b4aea9b --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.domain.model + +import java.security.cert.X509Certificate + +data class WebEidSignRequest( + val responseUri: String, + val origin: String, + val signingCertificate: X509Certificate, + val hash: String?, + val hashFunction: String?, +) 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 000000000..5ab432b10 --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/exception/WebEidErrorCode.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@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 000000000..4f15bb810 --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/exception/WebEidException.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@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/WebEidAlgorithmUtil.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtil.kt new file mode 100644 index 000000000..e1b896db4 --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtil.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.utils + +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.security.interfaces.RSAPublicKey +import java.util.Base64 + +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 (getEcKeySize(publicKey)) { + 256 -> "ES256" + 384 -> "ES384" + 521 -> "ES512" + else -> throw IllegalArgumentException("Unsupported EC key length") + } + + fun buildSignatureAlgorithm( + publicKey: PublicKey, + hashFunction: String, + ): JSONObject = + when (publicKey) { + is ECPublicKey -> + JSONObject().apply { + put("cryptoAlgorithm", "ECC") + put("hashFunction", hashFunction) + put("paddingScheme", "NONE") + } + + is RSAPublicKey -> + JSONObject().apply { + put("cryptoAlgorithm", "RSA") + put("hashFunction", hashFunction) + put("paddingScheme", "PKCS1.5") + } + + else -> + throw IllegalArgumentException( + "Unsupported key type: ${publicKey.algorithm}", + ) + } + + fun parseCertificate(signingCertBase64: String): X509Certificate { + val certBytes = Base64.getDecoder().decode(signingCertBase64) + return CertificateFactory + .getInstance("X.509") + .generateCertificate(certBytes.inputStream()) as X509Certificate + } + + private fun getEcKeySize(publicKey: PublicKey): Int = + when (publicKey) { + is ECPublicKey -> publicKey.params.curve.field.fieldSize + 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 new file mode 100644 index 000000000..c576cbd4d --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt @@ -0,0 +1,211 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.utils + +import android.net.Uri +import ee.ria.DigiDoc.utilsLib.signing.CertificateUtil +import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest +import ee.ria.DigiDoc.webEid.domain.model.WebEidCertificateRequest +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")) + 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 = parseOrigin(responseUri), + ) + } + + fun parseCertificateUri(uri: Uri): WebEidCertificateRequest { + val request = decodeUriFragment(uri) + val responseUri = validateResponseUri(request.optString("response_uri", "")) + + return WebEidCertificateRequest( + responseUri = responseUri.toString(), + origin = parseOrigin(responseUri), + ) + } + + fun parseSignUri(uri: Uri): WebEidSignRequest { + val request = decodeUriFragment(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(), + ) + } + + validateAndDecodeHash( + hashBase64 = hash, + hashFunction = hashFunction, + responseUri = responseUri.toString(), + ) + + val signingCertificatePem = request.optString("signing_certificate", "") + if (signingCertificatePem.isBlank()) { + throw WebEidException( + ERR_WEBEID_MOBILE_INVALID_REQUEST, + "Invalid signing request: missing signing_certificate", + responseUri.toString(), + ) + } + + val signingCertificateDerBytes = Base64.getDecoder().decode(signingCertificatePem) + val signingCertificate = CertificateUtil.x509Certificate(signingCertificateDerBytes) + + return WebEidSignRequest( + responseUri = responseUri.toString(), + origin = parseOrigin(responseUri), + signingCertificate, + hash = hash, + hashFunction = hashFunction, + ) + } + + 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 + } + + private fun validateAndDecodeHash( + hashBase64: String, + hashFunction: String, + responseUri: String, + ): ByteArray { + val hashBytes = + try { + Base64.getDecoder().decode(hashBase64) + } catch (_: IllegalArgumentException) { + throw WebEidException( + ERR_WEBEID_MOBILE_INVALID_REQUEST, + "Invalid hash encoding", + responseUri, + ) + } + + if (hashFunction.length > 8) { + throw WebEidException( + ERR_WEBEID_MOBILE_INVALID_REQUEST, + "hashFunction value is invalid", + responseUri, + ) + } + + val expectedLength = + try { + when (hashFunction.uppercase()) { + "SHA-224", "SHA3-224" -> 28 + "SHA-256", "SHA3-256" -> 32 + "SHA-384", "SHA3-384" -> 48 + "SHA-512", "SHA3-512" -> 64 + else -> throw IllegalArgumentException() + } + } catch (_: Exception) { + throw WebEidException( + ERR_WEBEID_MOBILE_INVALID_REQUEST, + "Unsupported hashFunction: $hashFunction", + responseUri, + ) + } + + if (hashBytes.size != expectedLength) { + throw WebEidException( + ERR_WEBEID_MOBILE_INVALID_REQUEST, + "$hashFunction hash must be $expectedLength bytes long, but is ${hashBytes.size}", + responseUri, + ) + } + + return hashBytes + } +} 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 000000000..da294de0c --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +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 + +object WebEidResponseUtil { + 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() + } +} diff --git a/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/di/AppModules.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/di/AppModules.kt new file mode 100644 index 000000000..24664eed1 --- /dev/null +++ b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/di/AppModules.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@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.WebEidSignService +import ee.ria.DigiDoc.webEid.WebEidSignServiceImpl +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class AppModules { + @Provides + @Singleton + fun provideWebEidAuthService(): WebEidAuthService = WebEidAuthServiceImpl() + + @Provides + @Singleton + fun provideWebEidSignService(): WebEidSignService = WebEidSignServiceImpl() +} From 4f805c7837477eea2f21aa5a381ace7a6b8fe552 Mon Sep 17 00:00:00 2001 From: SanderKondratjevNortal Date: Mon, 16 Feb 2026 09:04:27 +0200 Subject: [PATCH 14/28] Fix Web eID signing flow: CAN handling, certificate checks, and consent wording (#319) * NFC-117 Fix Web eID signing flow: CAN handling, certificate checks, and consent wording --------- Co-authored-by: Mart Aarma --- .../domain/preferences/DataStoreTest.kt | 104 ++++++++++++++++++ .../DigiDoc/viewmodel/WebEidViewModelTest.kt | 8 +- app/src/main/AndroidManifest.xml | 5 + .../kotlin/ee/ria/DigiDoc/MainActivity.kt | 10 +- .../DigiDoc/domain/preferences/DataStore.kt | 54 +++++++++ .../ee/ria/DigiDoc/fragment/WebEidFragment.kt | 4 + .../DigiDoc/fragment/screen/WebEidScreen.kt | 66 +++++++++-- .../DigiDoc/ui/component/signing/NFCView.kt | 72 +++++++++--- .../ee/ria/DigiDoc/viewmodel/NFCViewModel.kt | 32 ++++++ .../ria/DigiDoc/viewmodel/WebEidViewModel.kt | 5 + app/src/main/res/values-et/strings.xml | 3 + app/src/main/res/values/donottranslate.xml | 1 + app/src/main/res/values/strings.xml | 3 + .../webEid/domain/model/WebEidPersonalData.kt | 28 +++++ .../webEid/domain/model/WebEidSignRequest.kt | 1 + .../webEid/utils/WebEidRequestParser.kt | 32 ++++++ 16 files changed, 402 insertions(+), 26 deletions(-) create mode 100644 web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidPersonalData.kt diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt index 84f048b2f..7c01ae5ec 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt @@ -40,6 +40,7 @@ import ee.ria.DigiDoc.network.siva.SivaSetting import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.BeforeClass @@ -614,4 +615,107 @@ class DataStoreTest { assertFalse(result) } + + @Test + fun dataStore_getWebEidBrowserPackage_defaultNull() { + val result = dataStore.getWebEidBrowserPackage() + + assertNull(result) + } + + @Test + fun dataStore_setWebEidBrowserPackage_success() { + dataStore.setWebEidBrowserPackage("com.android.chrome") + + val result = dataStore.getWebEidBrowserPackage() + + assertEquals("com.android.chrome", result) + } + + @Test + fun dataStore_setWebEidBrowserPackage_nullClearsValue() { + dataStore.setWebEidBrowserPackage("com.android.chrome") + dataStore.setWebEidBrowserPackage(null) + + val result = dataStore.getWebEidBrowserPackage() + + assertNull(result) + } + + @Test + fun dataStore_getTemporaryCanNumber_defaultEmpty() { + val result = dataStore.getTemporaryCanNumber() + + assertEquals("", result) + } + + @Test + fun dataStore_setTemporaryCanNumber_success() { + dataStore.setTemporaryCanNumber("123456") + + val result = dataStore.getTemporaryCanNumber() + + assertEquals("123456", result) + } + + @Test + fun dataStore_clearTemporaryCanNumber_success() { + dataStore.setTemporaryCanNumber("123456") + dataStore.clearTemporaryCanNumber() + + val result = dataStore.getTemporaryCanNumber() + + assertEquals("", result) + } + + @Test + fun dataStore_getWebEidRememberMe_defaultTrue() { + val result = dataStore.getWebEidRememberMe() + + assertTrue(result) + } + + @Test + fun dataStore_setWebEidRememberMe_successWithFalse() { + dataStore.setWebEidRememberMe(false) + + val result = dataStore.getWebEidRememberMe() + + assertFalse(result) + } + + @Test + fun dataStore_setWebEidRememberMe_successWithTrue() { + dataStore.setWebEidRememberMe(true) + + val result = dataStore.getWebEidRememberMe() + + assertTrue(result) + } + + @Test + fun dataStore_isWebEidSessionActive_defaultFalse() { + val result = dataStore.isWebEidSessionActive() + + assertFalse(result) + } + + @Test + fun dataStore_setWebEidSessionActive_successWithTrue() { + dataStore.setWebEidSessionActive(true) + + val result = dataStore.isWebEidSessionActive() + + assertTrue(result) + } + + @Test + fun dataStore_setWebEidSessionActive_successWithFalse() { + dataStore.setWebEidSessionActive(true) + dataStore.setWebEidSessionActive(false) + + val result = dataStore.isWebEidSessionActive() + + assertFalse(result) + } } 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 d6bc8a895..952609e00 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt @@ -25,7 +25,9 @@ import android.net.Uri import android.util.Base64.URL_SAFE import android.util.Base64.decode import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.platform.app.InstrumentationRegistry import ee.ria.DigiDoc.R +import ee.ria.DigiDoc.domain.preferences.DataStore import ee.ria.DigiDoc.webEid.WebEidAuthService import ee.ria.DigiDoc.webEid.WebEidSignService import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -58,6 +60,8 @@ class WebEidViewModelTest { @Mock private lateinit var signService: WebEidSignService + private lateinit var dataStore: DataStore + private lateinit var viewModel: WebEidViewModel private val signingCertBase64Raw = @@ -82,7 +86,9 @@ class WebEidViewModelTest { @Before fun setup() { MockitoAnnotations.openMocks(this) - viewModel = WebEidViewModel(authService, signService) + val context = InstrumentationRegistry.getInstrumentation().targetContext + dataStore = DataStore(context) + viewModel = WebEidViewModel(authService, signService, dataStore) } @Test diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6bc844f59..03ca01545 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -156,6 +156,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt b/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt index 139cf8000..d9ca96469 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt @@ -121,7 +121,15 @@ class MainActivity : val componentClassName = this.javaClass.name val locale = dataStore.getLocale() ?: getLocale("en") - val webEidUri = intent?.data?.takeIf { it.scheme == "web-eid-mobile" } + val webEidUri = intent.data?.takeIf { it.scheme == "web-eid-mobile" } + + if (webEidUri != null) { + val browserPackage = + intent + .getStringExtra("com.android.browser.application_id") + ?.takeIf { it.isNotEmpty() } + dataStore.setWebEidBrowserPackage(browserPackage) + } val externalFileUris = if (webEidUri != null) { 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 80a9161a3..3fe3aeb0e 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 @@ -142,6 +142,60 @@ class DataStore if (cert.isNotEmpty()) editor.putString(key, cert).commit() } + fun getTemporaryCanNumber(): String { + val encryptedPrefs = getEncryptedPreferences(context) + return encryptedPrefs?.getString( + resources.getString(R.string.main_settings_temporary_can_key), + "", + ) ?: "" + } + + fun setTemporaryCanNumber(can: String) { + val encryptedPrefs = getEncryptedPreferences(context) + encryptedPrefs?.edit { + putString(resources.getString(R.string.main_settings_temporary_can_key), can) + } + } + + fun clearTemporaryCanNumber() { + val encryptedPrefs = getEncryptedPreferences(context) + encryptedPrefs?.edit { + remove(resources.getString(R.string.main_settings_temporary_can_key)) + } + } + + fun setWebEidRememberMe(value: Boolean) { + preferences.edit { + putBoolean("web_eid_remember_me", value) + } + } + + fun getWebEidRememberMe(): Boolean = preferences.getBoolean("web_eid_remember_me", true) + + fun setWebEidBrowserPackage(packageName: String?) { + preferences.edit { + if (packageName.isNullOrEmpty()) { + remove("web_eid_browser_package") + } else { + putString("web_eid_browser_package", packageName) + } + } + } + + fun getWebEidBrowserPackage(): String? = preferences.getString("web_eid_browser_package", null) + + fun isWebEidSessionActive(): Boolean { + val prefs = getEncryptedPreferences(context) + return prefs?.getBoolean("web_eid_session_active", false) ?: false + } + + fun setWebEidSessionActive(active: Boolean) { + val prefs = getEncryptedPreferences(context) + prefs?.edit { + putBoolean("web_eid_session_active", active) + } + } + 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 eb5dcf0f1..0f5a84540 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt @@ -63,9 +63,13 @@ fun WebEidFragment( LaunchedEffect(viewModel) { viewModel.relyingPartyResponseEvents.collect { responseUri -> + val browserPackage = viewModel.getWebEidBrowserPackage() val browserIntent = Intent(Intent.ACTION_VIEW, responseUri).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + if (!browserPackage.isNullOrEmpty()) { + setPackage(browserPackage) + } } activity.startActivity(browserIntent) activity.finishAndRemoveTask() 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 b0af98325..384622e1a 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 @@ -22,6 +22,7 @@ package ee.ria.DigiDoc.fragment.screen import android.app.Activity +import android.content.Intent import android.content.res.Configuration import androidx.activity.compose.LocalActivity import androidx.compose.foundation.background @@ -125,8 +126,12 @@ fun WebEidScreen( val snackBarScope = rememberCoroutineScope() val messages by SnackBarManager.messages.collectAsState(emptyList()) val dialogError by viewModel.dialogError.collectAsState() - var rememberMe by rememberSaveable { mutableStateOf(true) } - val hasStoredCanNumber = sharedSettingsViewModel.dataStore.getCanNumber().isNotEmpty() + var rememberMe by rememberSaveable { + mutableStateOf(sharedSettingsViewModel.dataStore.getWebEidRememberMe()) + } + val hasStoredCanNumber = + sharedSettingsViewModel.dataStore.getCanNumber().isNotEmpty() || + sharedSettingsViewModel.dataStore.getTemporaryCanNumber().isNotEmpty() LaunchedEffect(messages) { messages.forEach { message -> @@ -137,6 +142,15 @@ fun WebEidScreen( } } + LaunchedEffect(authRequest, certificateRequest) { + if (authRequest != null || certificateRequest != null) { + if (!sharedSettingsViewModel.dataStore.isWebEidSessionActive()) { + sharedSettingsViewModel.dataStore.clearTemporaryCanNumber() + } + sharedSettingsViewModel.dataStore.setWebEidSessionActive(true) + } + } + Scaffold( snackbarHost = { SnackbarHost( @@ -297,7 +311,10 @@ fun WebEidScreen( if (!isWebEidAuthenticating) { WebEidRememberMe( rememberMe = rememberMe, - onRememberMeChange = { rememberMe = it }, + onRememberMeChange = { + rememberMe = it + sharedSettingsViewModel.dataStore.setWebEidRememberMe(it) + }, ) } } else if (isCertificateFlow || signRequest != null) { @@ -308,9 +325,14 @@ fun WebEidScreen( signRequest != null -> signRequest.origin else -> "" } + val signingPersonInfo = + signRequest?.personalData?.let { + "${it.givenNames} ${it.surname}, ${it.personalCode}" + } WebEidSignOrCertificateInfo( origin = origin, isCertificateFlow = isCertificateFlow, + signingPersonInfo = signingPersonInfo, ) } @@ -347,7 +369,10 @@ fun WebEidScreen( if (!isWebEidAuthenticating) { WebEidRememberMe( rememberMe = rememberMe, - onRememberMeChange = { rememberMe = it }, + onRememberMeChange = { + rememberMe = it + sharedSettingsViewModel.dataStore.setWebEidRememberMe(it) + }, ) } } else { @@ -364,6 +389,8 @@ fun WebEidScreen( cancelWebEidSignAction() }, onSuccess = { + sharedSettingsViewModel.dataStore.clearTemporaryCanNumber() + sharedSettingsViewModel.dataStore.setWebEidSessionActive(false) isWebEidAuthenticating = false navController.navigateUp() }, @@ -429,6 +456,15 @@ fun WebEidScreen( OutlinedButton( onClick = { isWebEidAuthenticating = false + val browserPackage = viewModel.getWebEidBrowserPackage() + if (!browserPackage.isNullOrEmpty()) { + val launchIntent = + activity.packageManager.getLaunchIntentForPackage(browserPackage) + if (launchIntent != null) { + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + activity.startActivity(launchIntent) + } + } activity.finishAndRemoveTask() }, modifier = Modifier.fillMaxWidth(), @@ -500,6 +536,7 @@ private fun WebEidAuthInfo(authRequest: WebEidAuthRequest) { private fun WebEidSignOrCertificateInfo( origin: String, isCertificateFlow: Boolean, + signingPersonInfo: String? = null, ) { Column( modifier = Modifier.fillMaxWidth(), @@ -529,7 +566,12 @@ private fun WebEidSignOrCertificateInfo( Spacer(modifier = Modifier.height(16.dp)) Text( - text = stringResource(R.string.web_eid_details_forwarded), + text = + if (isCertificateFlow) { + stringResource(R.string.web_eid_details_forwarded) + } else { + stringResource(R.string.web_eid_details) + }, style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Left, ) @@ -537,7 +579,12 @@ private fun WebEidSignOrCertificateInfo( Spacer(modifier = Modifier.height(2.dp)) Text( - text = stringResource(R.string.web_eid_name_personal_identification_code), + text = + if (!isCertificateFlow && !signingPersonInfo.isNullOrBlank()) { + signingPersonInfo + } else { + stringResource(R.string.web_eid_name_personal_identification_code) + }, style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Left, ) @@ -545,7 +592,12 @@ private fun WebEidSignOrCertificateInfo( Spacer(modifier = Modifier.height(16.dp)) Text( - text = stringResource(R.string.web_eid_certificate_consent_text), + text = + if (isCertificateFlow) { + stringResource(R.string.web_eid_certificate_consent_text) + } else { + stringResource(R.string.web_eid_signature_consent_text) + }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Left, 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 99cf22e7c..76680814d 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 @@ -46,6 +46,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -172,10 +173,24 @@ fun NFCView( var shouldRememberMe by rememberSaveable { mutableStateOf(rememberMe) } var canNumber by rememberSaveable(stateSaver = textFieldValueSaver) { + val storedCan = sharedSettingsViewModel.dataStore.getCanNumber() + val tempCan = sharedSettingsViewModel.dataStore.getTemporaryCanNumber() + + val initialCan = + when { + identityAction == IdentityAction.CERTIFICATE && storedCan.isNotEmpty() -> storedCan + identityAction == IdentityAction.CERTIFICATE -> "" + identityAction == IdentityAction.SIGN && tempCan.isNotEmpty() -> tempCan + + storedCan.isNotEmpty() -> storedCan + tempCan.isNotEmpty() && isWebEidAuthenticating -> tempCan + else -> "" + } + mutableStateOf( TextFieldValue( - text = sharedSettingsViewModel.dataStore.getCanNumber(), - selection = TextRange(sharedSettingsViewModel.dataStore.getCanNumber().length), + text = initialCan, + selection = TextRange(initialCan.length), ), ) } @@ -186,20 +201,27 @@ fun NFCView( val showErrorDialog = rememberSaveable { mutableStateOf(false) } val focusManager = LocalFocusManager.current val saveFormParams = { - val previousCanNumber = sharedSettingsViewModel.dataStore.getCanNumber() - val currentCanNumber = canNumber.text - - if (shouldRememberMe) { - if (previousCanNumber != currentCanNumber) { - signingCert = "" - sharedSettingsViewModel.dataStore.setSigningCertificate("") + val currentCan = canNumber.text + + when { + ( + identityAction == IdentityAction.AUTH || + identityAction == IdentityAction.CERTIFICATE + ) && + shouldRememberMe -> { + sharedSettingsViewModel.dataStore.setCanNumber(currentCan) + sharedSettingsViewModel.dataStore.setSigningCertificate(signingCert) + sharedSettingsViewModel.dataStore.clearTemporaryCanNumber() } - sharedSettingsViewModel.dataStore.setCanNumber(currentCanNumber) - sharedSettingsViewModel.dataStore.setSigningCertificate(signingCert) - } else { - sharedSettingsViewModel.dataStore.setCanNumber("") - sharedSettingsViewModel.dataStore.setSigningCertificate("") + ( + identityAction == IdentityAction.AUTH || + identityAction == IdentityAction.CERTIFICATE + ) && + !shouldRememberMe -> { + sharedSettingsViewModel.dataStore.setCanNumber("") + sharedSettingsViewModel.dataStore.setTemporaryCanNumber(currentCan) + } } } @@ -246,6 +268,8 @@ fun NFCView( BackHandler { nfcViewModel.handleBackButton() + sharedSettingsViewModel.dataStore.clearTemporaryCanNumber() + sharedSettingsViewModel.dataStore.setWebEidSessionActive(false) if (isSigning || isDecrypting || isAuthenticating) { onError() } else { @@ -253,6 +277,16 @@ fun NFCView( } } + DisposableEffect(Unit) { + onDispose { + val webEidActive = sharedSettingsViewModel.dataStore.isWebEidSessionActive() + + if (!shouldRememberMe && !webEidActive) { + sharedSettingsViewModel.dataStore.clearTemporaryCanNumber() + } + } + } + LaunchedEffect(nfcViewModel.shouldResetPIN) { nfcViewModel.shouldResetPIN.asFlow().collect { bool -> bool.let { @@ -335,6 +369,7 @@ fun NFCView( LaunchedEffect(nfcViewModel.webEidAuthResult) { nfcViewModel.webEidAuthResult.asFlow().collect { result -> result?.let { (authCert, signingCert, signature) -> + nfcViewModel.setExpectedWebEidSigningCertificate(signingCert) val encodedCert = Base64.getEncoder().encodeToString(signingCert) sharedSettingsViewModel.dataStore.setSigningCertificate(encodedCert) webEidViewModel?.handleWebEidAuthResult(authCert, signingCert, signature) @@ -349,6 +384,7 @@ fun NFCView( result?.let { signCert -> sharedSettingsViewModel.dataStore.setSigningCertificate(signCert) val certBytes = Base64.getDecoder().decode(signCert) + nfcViewModel.setExpectedWebEidSigningCertificate(certBytes) webEidViewModel?.handleWebEidCertificateResult(certBytes) nfcViewModel.resetWebEidCertificateResult() onSuccess() @@ -652,9 +688,10 @@ fun NFCView( ) } } else { - if (sharedSettingsViewModel.dataStore.getCanNumber().isNotEmpty()) { - saveFormParams() - } + saveFormParams() + val expectedSigningCertBase64 = + sharedSettingsViewModel.dataStore + .getSigningCertificate() nfcViewModel.performNFCWebEidSignWorkRequest( activity = activity, context = context, @@ -662,6 +699,7 @@ fun NFCView( pin2Code = pinCode.value, responseUri = responseUriString, hash = hashString, + expectedSigningCertBase64 = expectedSigningCertBase64, ) } } 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 15f4f7a9b..1a47ac743 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt @@ -106,6 +106,7 @@ class NFCViewModel val webEidSignResult: LiveData?> = _webEidSignResult private val _webEidCertificateResult = MutableLiveData() val webEidCertificateResult: LiveData = _webEidCertificateResult + private var expectedWebEidSigningCert: ByteArray? = null private val dialogMessages: ImmutableMap = ImmutableMap @@ -176,6 +177,10 @@ class NFCViewModel fun getNFCStatus(activity: Activity): NfcStatus = NfcStatus.NFC_ACTIVE + fun setExpectedWebEidSigningCertificate(cert: ByteArray) { + expectedWebEidSigningCert = cert + } + private fun resetValues() { _errorState.postValue(null) _message.postValue(null) @@ -605,6 +610,7 @@ class NFCViewModel pin2Code: ByteArray?, responseUri: String, hash: String, + expectedSigningCertBase64: String?, ) { val pinType = context.getString(R.string.signature_id_card_pin2) activity.requestedOrientation = activity.resources.configuration.orientation @@ -623,6 +629,15 @@ class NFCViewModel val card = TokenWithPace.create(nfcReader) card.tunnel(canNumber) val signerCert = card.certificate(CertificateType.SIGNING) + expectedSigningCertBase64 + ?.takeIf { it.isNotEmpty() } + ?.let { + val expectedCert = Base64.getDecoder().decode(it) + if (!expectedCert.contentEquals(signerCert)) { + throw IllegalStateException("Web eID signing certificate mismatch") + } + } + val signerCertB64 = Base64.getEncoder().encodeToString(signerCert) val hashBytes = Base64.getDecoder().decode(hash) val (_, signatureArray) = idCardService.sign(card, pin2Code, hashBytes) @@ -654,6 +669,7 @@ class NFCViewModel showTechnicalError(ex) } } finally { + expectedWebEidSigningCert = null pin2Code.clearSensitive() nfcSmartCardReaderManager.disableNfcReaderMode() activity.requestedOrientation = @@ -665,6 +681,7 @@ class NFCViewModel } fun handleBackButton() { + expectedWebEidSigningCert = null _shouldResetPIN.postValue(true) resetValues() } @@ -726,6 +743,15 @@ class NFCViewModel errorLog(logTag, "Unable to sign with NFC - Certificate status: unknown", e) } + private fun showWebEidSigningCertificateMismatchError(e: Exception) { + _errorState.postValue(Triple(R.string.signature_update_nfc_wrong_certificate, null, null)) + errorLog( + logTag, + "Web eID signing failed - signing certificate does not match previously used certificate", + e, + ) + } + private fun showTechnicalError(e: Exception) { _errorState.postValue(Triple(R.string.signature_update_nfc_technical_error, null, null)) errorLog(logTag, "Unable to perform with NFC: ${e.message}", e) @@ -811,6 +837,11 @@ class NFCViewModel true } + message.contains("Web eID signing certificate mismatch") -> { + showWebEidSigningCertificateMismatchError(ex) + true + } + else -> false }.also { errorLog(logTag, "Exception: ${ex.message}", ex) @@ -819,6 +850,7 @@ class NFCViewModel override fun onCleared() { super.onCleared() + expectedWebEidSigningCert = null nfcSmartCardReaderManager.disableNfcReaderMode() } } 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 b501f681f..2d4218dac 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt @@ -25,6 +25,7 @@ import android.net.Uri import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import ee.ria.DigiDoc.R +import ee.ria.DigiDoc.domain.preferences.DataStore import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog import ee.ria.DigiDoc.webEid.WebEidAuthService import ee.ria.DigiDoc.webEid.WebEidSignService @@ -50,8 +51,12 @@ class WebEidViewModel constructor( private val authService: WebEidAuthService, private val signService: WebEidSignService, + private val dataStore: DataStore, ) : ViewModel() { private val logTag = javaClass.simpleName + + fun getWebEidBrowserPackage(): String? = dataStore.getWebEidBrowserPackage() + private val _authRequest = MutableStateFlow(null) val authRequest: StateFlow = _authRequest.asStateFlow() private val _certificateRequest = MutableStateFlow(null) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index b60e78bee..c9c0d3378 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -290,6 +290,7 @@ https://www.id.ee/artikkel/id-kaardi-pin-ja-puk-koodide-muutmine/ Sertifikaat on kehtetu Sertifikaadi staatus on teadmata + Valitud ID-kaart ei vasta varem kasutatud sertifikaadile. Palun kasuta sama ID-kaarti, millega autentisid. ID-kaardi Mobiil-ID Smart-ID @@ -667,11 +668,13 @@ Autentides nõustun oma nime ja isikukoodi edastamisega teenusepakkujale. Autentimispäring: Edastatavad andmed: + Andmed: NIMI, ISIKUKOOD Järgmisel kasutamisel on andmeväljad eeltäidetud. Kinnita Vali sertifikaat Sertifikaati valides nõustun oma nime ja isikukoodi edastamisega teenusepakkujale. + PIN2 koodi sisestamisega annad omakäelise digiallkirja. Allkirjastamine Allkirjasta ID-kaardiga Sertifikaadipäring: diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 8e254fdff..c8a34638b 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -67,6 +67,7 @@ mainSettingsUUID can signingCert + temporaryCanNumber mainSettingsMobileNr mainSettingsPersonalCode mainSettingsSmartIdPersonalCode diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ec3d4c6d8..8366d3a03 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -290,6 +290,7 @@ https://www.id.ee/en/article/changing-id-card-pin-codes-and-puk-code/ Certificate status revoked Certificate status unknown + The selected ID card does not match the previously used certificate. Please use the same ID card you authenticated with. ID-card\'s Mobile-ID Smart-ID @@ -667,11 +668,13 @@ By authenticating, I agree to the transfer of my name and personal identification code to the service provider. Authentication request from: Details forwarded: + Details: NAME, PERSONAL IDENTIFICATION CODE 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. + By entering your PIN2, you give a handwritten-equivalent digital signature. Sign Sign with ID-card Certificate request from: diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidPersonalData.kt b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidPersonalData.kt new file mode 100644 index 000000000..187f75bbd --- /dev/null +++ b/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidPersonalData.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.webEid.domain.model + +data class WebEidPersonalData( + val givenNames: String, + val surname: String, + val personalCode: 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 index 46b4aea9b..5a4bab827 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 @@ -29,4 +29,5 @@ data class WebEidSignRequest( val signingCertificate: X509Certificate, val hash: String?, val hashFunction: String?, + val personalData: WebEidPersonalData?, ) 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 c576cbd4d..7c748a9a5 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 @@ -25,12 +25,17 @@ import android.net.Uri import ee.ria.DigiDoc.utilsLib.signing.CertificateUtil import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest import ee.ria.DigiDoc.webEid.domain.model.WebEidCertificateRequest +import ee.ria.DigiDoc.webEid.domain.model.WebEidPersonalData 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.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x500.style.BCStyle +import org.bouncycastle.asn1.x500.style.IETFUtils import org.json.JSONObject import java.net.URI import java.net.URISyntaxException +import java.security.cert.X509Certificate import java.util.Base64 object WebEidRequestParser { @@ -109,6 +114,7 @@ object WebEidRequestParser { signingCertificate, hash = hash, hashFunction = hashFunction, + personalData = extractPersonalData(signingCertificate), ) } @@ -208,4 +214,30 @@ object WebEidRequestParser { return hashBytes } + + private fun extractPersonalData(cert: X509Certificate): WebEidPersonalData { + val x500Name = X500Name.getInstance(cert.subjectX500Principal.encoded) + val cnRDNs = x500Name.getRDNs(BCStyle.CN) + + require(cnRDNs.isNotEmpty()) { + "Signing certificate CN missing" + } + + val cn = + IETFUtils + .valueToString(cnRDNs.first().first.value) + .replace("\\,", ",") + .replace("\\ ", " ") + val parts = cn.split(",").map { it.trim() } + + require(parts.size >= 3) { + "Unexpected signing certificate CN format: $cn" + } + + return WebEidPersonalData( + surname = parts[0], + givenNames = parts[1], + personalCode = parts[2], + ) + } } From d04d0cda4e422ad61c6745d9b729ef6b02813d5a Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Mon, 2 Mar 2026 13:06:09 +0200 Subject: [PATCH 15/28] NFC-126 UX improvements --- .../DigiDoc/viewmodel/WebEidViewModelTest.kt | 109 ++++++++ .../DigiDoc/fragment/screen/WebEidScreen.kt | 38 +-- .../DigiDoc/ui/component/signing/NFCView.kt | 91 ++++--- .../ee/ria/DigiDoc/viewmodel/NFCViewModel.kt | 131 +++++++-- .../ria/DigiDoc/viewmodel/WebEidViewModel.kt | 27 ++ app/src/main/res/values-et/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../certificate/CertificateParsingUtilTest.kt | 255 ++++++++++++++++++ .../certificate/CertificateParsingUtil.kt | 103 +++++++ .../certificate/CertificateServiceImpl.kt | 55 +--- .../DigiDoc/webEid/WebEidAuthServiceTest.kt | 0 .../DigiDoc/webEid/WebEidRequestParserTest.kt | 3 +- .../DigiDoc/webEid/WebEidSignServiceTest.kt | 0 .../ria/DigiDoc/webEid/di/AppModulesTest.kt | 0 .../webEid/exception/WebEidExceptionTest.kt | 0 .../webEid/utils/WebEidAlgorithmUtilTest.kt | 0 .../webEid/utils/WebEidResponseUtilTest.kt | 0 .../ria/DigiDoc/webEid/WebEidAuthService.kt | 0 .../DigiDoc/webEid/WebEidAuthServiceImpl.kt | 0 .../ria/DigiDoc/webEid/WebEidSignService.kt | 0 .../DigiDoc/webEid/WebEidSignServiceImpl.kt | 0 .../webEid/domain/model/WebEidAuthRequest.kt | 0 .../domain/model/WebEidCertificateRequest.kt | 0 .../webEid/domain/model/WebEidPersonalData.kt | 0 .../webEid/domain/model/WebEidSignRequest.kt | 0 .../webEid/exception/WebEidErrorCode.kt | 1 + .../webEid/exception/WebEidException.kt | 0 .../webEid/utils/WebEidAlgorithmUtil.kt | 0 .../webEid/utils/WebEidRequestParser.kt | 39 +-- .../webEid/utils/WebEidResponseUtil.kt | 0 30 files changed, 692 insertions(+), 162 deletions(-) create mode 100644 commons-lib/src/androidTest/kotlin/ee/ria/DigiDoc/common/certificate/CertificateParsingUtilTest.kt create mode 100644 commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/certificate/CertificateParsingUtil.kt rename web-eid-lib/src/androidTest/{java => kotlin}/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt (100%) rename web-eid-lib/src/androidTest/{java => kotlin}/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt (99%) rename web-eid-lib/src/androidTest/{java => kotlin}/ee/ria/DigiDoc/webEid/WebEidSignServiceTest.kt (100%) rename web-eid-lib/src/androidTest/{java => kotlin}/ee/ria/DigiDoc/webEid/di/AppModulesTest.kt (100%) rename web-eid-lib/src/androidTest/{java => kotlin}/ee/ria/DigiDoc/webEid/exception/WebEidExceptionTest.kt (100%) rename web-eid-lib/src/androidTest/{java => kotlin}/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtilTest.kt (100%) rename web-eid-lib/src/androidTest/{java => kotlin}/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt (100%) rename web-eid-lib/src/main/{java => kotlin}/ee/ria/DigiDoc/webEid/WebEidAuthService.kt (100%) rename web-eid-lib/src/main/{java => kotlin}/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt (100%) rename web-eid-lib/src/main/{java => kotlin}/ee/ria/DigiDoc/webEid/WebEidSignService.kt (100%) rename web-eid-lib/src/main/{java => kotlin}/ee/ria/DigiDoc/webEid/WebEidSignServiceImpl.kt (100%) rename web-eid-lib/src/main/{java => kotlin}/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthRequest.kt (100%) rename web-eid-lib/src/main/{java => kotlin}/ee/ria/DigiDoc/webEid/domain/model/WebEidCertificateRequest.kt (100%) rename web-eid-lib/src/main/{java => kotlin}/ee/ria/DigiDoc/webEid/domain/model/WebEidPersonalData.kt (100%) rename web-eid-lib/src/main/{java => kotlin}/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt (100%) rename web-eid-lib/src/main/{java => kotlin}/ee/ria/DigiDoc/webEid/exception/WebEidErrorCode.kt (96%) rename web-eid-lib/src/main/{java => kotlin}/ee/ria/DigiDoc/webEid/exception/WebEidException.kt (100%) rename web-eid-lib/src/main/{java => kotlin}/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtil.kt (100%) rename web-eid-lib/src/main/{java => kotlin}/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt (88%) rename web-eid-lib/src/main/{java => kotlin}/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt (100%) 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 952609e00..ae8618b60 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt @@ -505,6 +505,115 @@ class WebEidViewModelTest { } } + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleUserCancelled_authFlow_emitsCancelError() { + runTest(UnconfinedTestDispatcher()) { + val uri = + Uri.parse( + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6dHJ1ZX0", + ) + + viewModel.handleAuth(uri) + + val deferred = + async { + viewModel.relyingPartyResponseEvents.first() + } + + viewModel.handleUserCancelled() + + val emittedUri = deferred.await() + + assert(emittedUri.toString().startsWith("https://example.com/response#")) + assertNotNull(emittedUri.fragment) + + val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) + val jsonPayload = JSONObject(decodedPayload) + + assertEquals("ERR_WEBEID_USER_CANCELLED", jsonPayload.getString("code")) + assertEquals("User cancelled", jsonPayload.getString("message")) + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleUserCancelled_certificateFlow_emitsCancelError() { + runTest(UnconfinedTestDispatcher()) { + val uri = + Uri.parse( + "web-eid-mobile://cert#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIn0", + ) + + viewModel.handleCertificate(uri) + + val deferred = + async { + viewModel.relyingPartyResponseEvents.first() + } + + viewModel.handleUserCancelled() + + val emittedUri = deferred.await() + + assert(emittedUri.toString().startsWith("https://example.com/response#")) + assertNotNull(emittedUri.fragment) + + val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) + val jsonPayload = JSONObject(decodedPayload) + + assertEquals("ERR_WEBEID_USER_CANCELLED", jsonPayload.getString("code")) + assertEquals("User cancelled", jsonPayload.getString("message")) + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleUserCancelled_signFlow_emitsCancelError() { + runTest(UnconfinedTestDispatcher()) { + val uri = Uri.parse(createSignUri(signingCertBase64)) + + viewModel.handleSign(uri) + + val deferred = + async { + viewModel.relyingPartyResponseEvents.first() + } + + viewModel.handleUserCancelled() + + val emittedUri = deferred.await() + + assert(emittedUri.toString().startsWith("https://rp.example.com/sign/response#")) + assertNotNull(emittedUri.fragment) + + val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE)) + val jsonPayload = JSONObject(decodedPayload) + + assertEquals("ERR_WEBEID_USER_CANCELLED", jsonPayload.getString("code")) + assertEquals("User cancelled", jsonPayload.getString("message")) + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleUserCancelled_noActiveRequest_doesNotEmit() { + runTest(UnconfinedTestDispatcher()) { + var emitted = false + + val job = + async { + viewModel.relyingPartyResponseEvents.first() + emitted = true + } + + viewModel.handleUserCancelled() + job.cancel() + + assertEquals(false, emitted) + } + } + private fun createSignUri(signingCertificate: String? = null): String { val hash = validSha384Base64() val hashFunction = "SHA-384" 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 384622e1a..ce6eae4ef 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 @@ -22,7 +22,6 @@ package ee.ria.DigiDoc.fragment.screen import android.app.Activity -import android.content.Intent import android.content.res.Configuration import androidx.activity.compose.LocalActivity import androidx.compose.foundation.background @@ -123,7 +122,7 @@ fun WebEidScreen( val isSettingsMenuBottomSheetVisible = rememberSaveable { mutableStateOf(false) } val snackBarHostState = remember { SnackbarHostState() } - val snackBarScope = rememberCoroutineScope() + val scope = rememberCoroutineScope() val messages by SnackBarManager.messages.collectAsState(emptyList()) val dialogError by viewModel.dialogError.collectAsState() var rememberMe by rememberSaveable { @@ -135,7 +134,7 @@ fun WebEidScreen( LaunchedEffect(messages) { messages.forEach { message -> - snackBarScope.launch { + scope.launch { snackBarHostState.showSnackbar(message) } SnackBarManager.removeMessage(message) @@ -311,9 +310,12 @@ fun WebEidScreen( if (!isWebEidAuthenticating) { WebEidRememberMe( rememberMe = rememberMe, - onRememberMeChange = { - rememberMe = it - sharedSettingsViewModel.dataStore.setWebEidRememberMe(it) + onRememberMeChange = { isRememberMeEnabled -> + rememberMe = isRememberMeEnabled + sharedSettingsViewModel.dataStore.setWebEidRememberMe(isRememberMeEnabled) + if (!isRememberMeEnabled) { + sharedSettingsViewModel.dataStore.setSigningCertificate("") + } }, ) } @@ -369,9 +371,12 @@ fun WebEidScreen( if (!isWebEidAuthenticating) { WebEidRememberMe( rememberMe = rememberMe, - onRememberMeChange = { - rememberMe = it - sharedSettingsViewModel.dataStore.setWebEidRememberMe(it) + onRememberMeChange = { isRememberMeEnabled -> + rememberMe = isRememberMeEnabled + sharedSettingsViewModel.dataStore.setWebEidRememberMe(isRememberMeEnabled) + if (!isRememberMeEnabled) { + sharedSettingsViewModel.dataStore.setSigningCertificate("") + } }, ) } @@ -379,11 +384,12 @@ fun WebEidScreen( NFCView( activity = activity, identityAction = IdentityAction.SIGN, + rememberMe = rememberMe, isCertificate = false, isSigning = false, isDecrypting = false, isWebEidAuthenticating = isWebEidAuthenticating, - canNumberReadOnly = hasStoredCanNumber, + isCanNumberReadOnly = hasStoredCanNumber, onError = { isWebEidAuthenticating = false cancelWebEidSignAction() @@ -391,6 +397,7 @@ fun WebEidScreen( onSuccess = { sharedSettingsViewModel.dataStore.clearTemporaryCanNumber() sharedSettingsViewModel.dataStore.setWebEidSessionActive(false) + if (!rememberMe) sharedSettingsViewModel.dataStore.setSigningCertificate("") isWebEidAuthenticating = false navController.navigateUp() }, @@ -456,16 +463,9 @@ fun WebEidScreen( OutlinedButton( onClick = { isWebEidAuthenticating = false - val browserPackage = viewModel.getWebEidBrowserPackage() - if (!browserPackage.isNullOrEmpty()) { - val launchIntent = - activity.packageManager.getLaunchIntentForPackage(browserPackage) - if (launchIntent != null) { - launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - activity.startActivity(launchIntent) - } + scope.launch { + viewModel.handleUserCancelled() } - activity.finishAndRemoveTask() }, modifier = Modifier.fillMaxWidth(), colors = 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 76680814d..b7283e507 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 @@ -152,7 +152,7 @@ fun NFCView( cancelWebEidSignAction: (() -> Unit) -> Unit = {}, isAuthenticated: (Boolean, IdCardData) -> Unit = { _, _ -> }, webEidViewModel: WebEidViewModel? = null, - canNumberReadOnly: Boolean = false, + isCanNumberReadOnly: Boolean = false, ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -194,34 +194,18 @@ 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 currentCan = canNumber.text - when { - ( - identityAction == IdentityAction.AUTH || - identityAction == IdentityAction.CERTIFICATE - ) && - shouldRememberMe -> { - sharedSettingsViewModel.dataStore.setCanNumber(currentCan) - sharedSettingsViewModel.dataStore.setSigningCertificate(signingCert) - sharedSettingsViewModel.dataStore.clearTemporaryCanNumber() - } - - ( - identityAction == IdentityAction.AUTH || - identityAction == IdentityAction.CERTIFICATE - ) && - !shouldRememberMe -> { - sharedSettingsViewModel.dataStore.setCanNumber("") - sharedSettingsViewModel.dataStore.setTemporaryCanNumber(currentCan) - } + if (shouldRememberMe) { + sharedSettingsViewModel.dataStore.setCanNumber(currentCan) + sharedSettingsViewModel.dataStore.clearTemporaryCanNumber() + } else { + sharedSettingsViewModel.dataStore.setCanNumber("") + sharedSettingsViewModel.dataStore.setTemporaryCanNumber(currentCan) } } @@ -265,6 +249,15 @@ fun NFCView( val webEidSign = webEidViewModel?.signRequest?.collectAsState()?.value val responseUriString = webEidSign?.responseUri ?: webEidCertificate?.responseUri ?: "" val hashString = webEidSign?.hash ?: "" + val requestSigningCertificateBase64 = + webEidSign?.signingCertificate?.encoded?.let( + Base64.getEncoder()::encodeToString, + ) + val isAuthPin2UnchangedDialog = + dialogError == R.string.sign_blocked_pin2_unchanged_message && + identityAction == IdentityAction.AUTH + + var isCanNumberReadOnly by remember { mutableStateOf(isCanNumberReadOnly) } BackHandler { nfcViewModel.handleBackButton() @@ -369,7 +362,6 @@ fun NFCView( LaunchedEffect(nfcViewModel.webEidAuthResult) { nfcViewModel.webEidAuthResult.asFlow().collect { result -> result?.let { (authCert, signingCert, signature) -> - nfcViewModel.setExpectedWebEidSigningCertificate(signingCert) val encodedCert = Base64.getEncoder().encodeToString(signingCert) sharedSettingsViewModel.dataStore.setSigningCertificate(encodedCert) webEidViewModel?.handleWebEidAuthResult(authCert, signingCert, signature) @@ -384,7 +376,6 @@ fun NFCView( result?.let { signCert -> sharedSettingsViewModel.dataStore.setSigningCertificate(signCert) val certBytes = Base64.getDecoder().decode(signCert) - nfcViewModel.setExpectedWebEidSigningCertificate(certBytes) webEidViewModel?.handleWebEidCertificateResult(certBytes) nfcViewModel.resetWebEidCertificateResult() onSuccess() @@ -440,9 +431,28 @@ fun NFCView( } } - if (errorText.isNotEmpty()) { - showMessage(errorText) - errorText = "" + LaunchedEffect(nfcViewModel.certMismatch) { + nfcViewModel.certMismatch.asFlow().collect { mismatch -> + if (mismatch) { + isCanNumberReadOnly = false + canNumber = TextFieldValue("") + nfcViewModel.resetCertificateMismatch() + } + } + } + + LaunchedEffect(webEidSign?.signingCertificate) { + nfcViewModel.checkWebEidSigningCertificateMismatch( + cachedCert = sharedSettingsViewModel.dataStore.getSigningCertificate(), + requestSigningCert = requestSigningCertificateBase64, + ) + } + + LaunchedEffect(errorText) { + if (errorText.isNotEmpty()) { + showMessage(errorText) + errorText = "" + } } if (showErrorDialog.value) { @@ -462,7 +472,9 @@ fun NFCView( linkUrl = R.string.sign_blocked_pin2_unchanged_url } Box(modifier = modifier.fillMaxSize()) { - onError() + if (!isAuthPin2UnchangedDialog) { + onError() + } BasicAlertDialog( modifier = modifier @@ -505,6 +517,7 @@ fun NFCView( okButtonClick = { showErrorDialog.value = false nfcViewModel.resetDialogErrorState() + nfcViewModel.continuePendingWebEidAuth() }, cancelButtonTitle = R.string.cancel_button, okButtonTitle = R.string.ok_button, @@ -643,6 +656,7 @@ fun NFCView( canNumber = canNumber.text, roleData = roleDataRequest, ) + sharedSettingsViewModel.dataStore.clearTemporaryCanNumber() } } decryptAction { @@ -655,6 +669,7 @@ fun NFCView( pin1Code = pinCode.value, canNumber = canNumber.text, ) + sharedSettingsViewModel.dataStore.clearTemporaryCanNumber() } } authenticateWebEidAction { @@ -676,8 +691,14 @@ fun NFCView( val cachedCert = sharedSettingsViewModel.dataStore.getSigningCertificate() if (isCertificateFlow) { + val currentStoredCan = sharedSettingsViewModel.dataStore.getCanNumber() + val canSkipCertificateRead = + shouldRememberMe && + cachedCert.isNotEmpty() && + currentStoredCan.isNotEmpty() && + canNumber.text == currentStoredCan saveFormParams() - if (cachedCert.isNotEmpty()) { + if (canSkipCertificateRead) { val certBytes = Base64.getDecoder().decode(cachedCert) webEidViewModel.handleWebEidCertificateResult(certBytes) onSuccess() @@ -688,10 +709,6 @@ fun NFCView( ) } } else { - saveFormParams() - val expectedSigningCertBase64 = - sharedSettingsViewModel.dataStore - .getSigningCertificate() nfcViewModel.performNFCWebEidSignWorkRequest( activity = activity, context = context, @@ -699,7 +716,7 @@ fun NFCView( pin2Code = pinCode.value, responseUri = responseUriString, hash = hashString, - expectedSigningCertBase64 = expectedSigningCertBase64, + requestSigningCert = requestSigningCertificateBase64, ) } } @@ -771,8 +788,8 @@ fun NFCView( TextFieldValue(removeInvisibleElement(it.text)) } }, - readOnly = canNumberReadOnly, - enabled = !canNumberReadOnly, + readOnly = isCanNumberReadOnly, + enabled = !isCanNumberReadOnly, singleLine = true, label = canNumberLabel, readDigitByDigit = true, 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 1a47ac743..cacc38256 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt @@ -24,6 +24,8 @@ package ee.ria.DigiDoc.viewmodel import android.app.Activity import android.content.Context import android.content.pm.ActivityInfo +import android.os.Handler +import android.os.Looper.getMainLooper import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -61,7 +63,6 @@ import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.bouncycastle.util.encoders.Hex -import java.util.Arrays import java.util.Base64 import javax.inject.Inject @@ -106,7 +107,11 @@ class NFCViewModel val webEidSignResult: LiveData?> = _webEidSignResult private val _webEidCertificateResult = MutableLiveData() val webEidCertificateResult: LiveData = _webEidCertificateResult - private var expectedWebEidSigningCert: ByteArray? = null + private val timeoutHandler = Handler(getMainLooper()) + private var timeoutRunnable: Runnable? = null + private var pendingWebEidAuthResult: Triple? = null + private val _certMismatch = MutableLiveData(false) + val certMismatch: LiveData = _certMismatch private val dialogMessages: ImmutableMap = ImmutableMap @@ -119,6 +124,10 @@ class NFCViewModel R.string.invalid_time_slot_message, ).build() + companion object { + private const val NFC_CARD_DETECTION_TIMEOUT_MS = 30_000L + } + fun resetErrorState() { _errorState.postValue(null) } @@ -177,16 +186,13 @@ class NFCViewModel fun getNFCStatus(activity: Activity): NfcStatus = NfcStatus.NFC_ACTIVE - fun setExpectedWebEidSigningCertificate(cert: ByteArray) { - expectedWebEidSigningCert = cert - } - private fun resetValues() { _errorState.postValue(null) _message.postValue(null) _signStatus.postValue(null) _decryptStatus.postValue(null) _nfcStatus.postValue(null) + pendingWebEidAuthResult = null } private fun resetNonErrorValues() { @@ -484,6 +490,7 @@ class NFCViewModel val pinType = context.getString(R.string.signature_id_card_pin1) activity.requestedOrientation = activity.resources.configuration.orientation resetValues() + startNFCDetectionTimeout(activity, pin1Code) withContext(Main) { _message.postValue(R.string.signature_update_nfc_hold) @@ -492,12 +499,15 @@ class NFCViewModel checkNFCStatus( nfcSmartCardReaderManager.startDiscovery(activity) { nfcReader, exc -> if ((nfcReader != null) && (exc == null)) { + stopNFCDetectionTimeout() try { _message.postValue(R.string.signature_update_nfc_detected) val card = TokenWithPace.create(nfcReader) card.tunnel(canNumber) + val pin2Changed = card.pinChangedFlag() == 1 + val (authCert, signingCert, signatureArray) = idCardService.authenticate( token = card, @@ -506,6 +516,11 @@ class NFCViewModel challenge = challenge, ) + if (!pin2Changed) { + handlePin2NotChanged(pin1Code, authCert, signingCert, signatureArray) + return@startDiscovery + } + pin1Code.clearSensitive() _shouldResetPIN.postValue(true) @@ -528,9 +543,8 @@ class NFCViewModel showTechnicalError(ex) } } finally { - if (pin1Code.isNotEmpty()) { - Arrays.fill(pin1Code, 0.toByte()) - } + stopNFCDetectionTimeout() + pin1Code.clearSensitive() nfcSmartCardReaderManager.disableNfcReaderMode() activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED @@ -546,6 +560,7 @@ class NFCViewModel ) { activity.requestedOrientation = activity.resources.configuration.orientation resetValues() + startNFCDetectionTimeout(activity) withContext(Main) { _message.postValue(R.string.signature_update_nfc_hold) @@ -554,6 +569,7 @@ class NFCViewModel checkNFCStatus( nfcSmartCardReaderManager.startDiscovery(activity) { nfcReader, exc -> if ((nfcReader != null) && (exc == null)) { + stopNFCDetectionTimeout() try { _message.postValue(R.string.signature_update_nfc_detected) @@ -594,6 +610,7 @@ class NFCViewModel showTechnicalError(ex) } } finally { + stopNFCDetectionTimeout() nfcSmartCardReaderManager.disableNfcReaderMode() activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED @@ -610,11 +627,12 @@ class NFCViewModel pin2Code: ByteArray?, responseUri: String, hash: String, - expectedSigningCertBase64: String?, + requestSigningCert: String?, ) { val pinType = context.getString(R.string.signature_id_card_pin2) activity.requestedOrientation = activity.resources.configuration.orientation resetValues() + startNFCDetectionTimeout(activity, pin2Code) withContext(Main) { _message.postValue(R.string.signature_update_nfc_hold) @@ -623,22 +641,26 @@ class NFCViewModel checkNFCStatus( nfcSmartCardReaderManager.startDiscovery(activity) { nfcReader, exc -> if ((nfcReader != null) && (exc == null)) { + stopNFCDetectionTimeout() try { _message.postValue(R.string.signature_update_nfc_detected) val card = TokenWithPace.create(nfcReader) card.tunnel(canNumber) val signerCert = card.certificate(CertificateType.SIGNING) - expectedSigningCertBase64 - ?.takeIf { it.isNotEmpty() } - ?.let { - val expectedCert = Base64.getDecoder().decode(it) - if (!expectedCert.contentEquals(signerCert)) { - throw IllegalStateException("Web eID signing certificate mismatch") - } + val signerCertB64 = Base64.getEncoder().encodeToString(signerCert) + + if (requestSigningCert.isNullOrEmpty()) { + throw IllegalStateException("Missing signing certificate from AUTH or CERT flow") + } else { + val expectedCert = Base64.getDecoder().decode(requestSigningCert) + + if (!expectedCert.contentEquals(signerCert)) { + _certMismatch.postValue(true) + throw IllegalStateException("Web eID signing certificate mismatch") } + } - val signerCertB64 = Base64.getEncoder().encodeToString(signerCert) val hashBytes = Base64.getDecoder().decode(hash) val (_, signatureArray) = idCardService.sign(card, pin2Code, hashBytes) @@ -669,7 +691,7 @@ class NFCViewModel showTechnicalError(ex) } } finally { - expectedWebEidSigningCert = null + stopNFCDetectionTimeout() pin2Code.clearSensitive() nfcSmartCardReaderManager.disableNfcReaderMode() activity.requestedOrientation = @@ -681,7 +703,6 @@ class NFCViewModel } fun handleBackButton() { - expectedWebEidSigningCert = null _shouldResetPIN.postValue(true) resetValues() } @@ -850,7 +871,75 @@ class NFCViewModel override fun onCleared() { super.onCleared() - expectedWebEidSigningCert = null + stopNFCDetectionTimeout() nfcSmartCardReaderManager.disableNfcReaderMode() } + + private fun startNFCDetectionTimeout( + activity: Activity, + pinToClear: ByteArray? = null, + ) { + timeoutRunnable = + Runnable { + pinToClear?.clearSensitive() + _errorState.postValue( + Triple(R.string.signature_update_nfc_detection_timeout, null, null), + ) + + nfcSmartCardReaderManager.disableNfcReaderMode() + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + + timeoutHandler.postDelayed(timeoutRunnable!!, NFC_CARD_DETECTION_TIMEOUT_MS) + } + + private fun stopNFCDetectionTimeout() { + timeoutRunnable?.let { + timeoutHandler.removeCallbacks(it) + } + timeoutRunnable = null + } + + private fun handlePin2NotChanged( + pin1Code: ByteArray, + authCert: ByteArray, + signingCert: ByteArray, + signatureArray: ByteArray, + ) { + pin1Code.clearSensitive() + _shouldResetPIN.postValue(true) + + pendingWebEidAuthResult = Triple(authCert, signingCert, signatureArray) + _dialogError.postValue(R.string.sign_blocked_pin2_unchanged_message) + } + + fun continuePendingWebEidAuth() { + pendingWebEidAuthResult?.let { + _webEidAuthResult.postValue(it) + pendingWebEidAuthResult = null + } + } + + fun checkWebEidSigningCertificateMismatch( + cachedCert: String?, + requestSigningCert: String?, + ): Boolean { + if (cachedCert.isNullOrEmpty() || requestSigningCert.isNullOrEmpty()) { + return false + } + + val isMismatch = cachedCert != requestSigningCert + if (isMismatch) { + _certMismatch.postValue(true) + showWebEidSigningCertificateMismatchError( + IllegalStateException("Web eID signing certificate mismatch"), + ) + } + + return isMismatch + } + + fun resetCertificateMismatch() { + _certMismatch.postValue(false) + } } 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 2d4218dac..9a5c47fc4 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt @@ -193,4 +193,31 @@ class WebEidViewModel _relyingPartyResponseEvents.emit(errorUri) } } + + suspend fun handleUserCancelled() { + try { + val responseUri = + authRequest.value?.loginUri + ?: certificateRequest.value?.responseUri + ?: signRequest.value?.responseUri + + if (responseUri.isNullOrBlank()) { + errorLog(logTag, "Cannot send cancel response — missing response URI") + return + } + + val errorPayload = + WebEidResponseUtil.createErrorPayload( + WebEidErrorCode.ERR_WEBEID_USER_CANCELLED, + "User cancelled", + ) + + val errorUri = + WebEidResponseUtil.createResponseUri(responseUri, errorPayload) + + _relyingPartyResponseEvents.emit(errorUri) + } catch (e: Exception) { + errorLog(logTag, "Failed to send cancel response", e) + } + } } diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index c9c0d3378..7939c5f3e 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -683,4 +683,5 @@ Vigane Web eID päring Päringu viga Vigane autentimispäring + Kaarti ei tuvastatud. Palun alusta uuesti. \ 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 8366d3a03..45f01cd88 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -683,4 +683,5 @@ Invalid Web eID request Request error Invalid authentication request + Card not detected. Please start again. \ No newline at end of file diff --git a/commons-lib/src/androidTest/kotlin/ee/ria/DigiDoc/common/certificate/CertificateParsingUtilTest.kt b/commons-lib/src/androidTest/kotlin/ee/ria/DigiDoc/common/certificate/CertificateParsingUtilTest.kt new file mode 100644 index 000000000..7602c660c --- /dev/null +++ b/commons-lib/src/androidTest/kotlin/ee/ria/DigiDoc/common/certificate/CertificateParsingUtilTest.kt @@ -0,0 +1,255 @@ +/* + * Copyright 2017 - 2025 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.common.certificate + +import org.bouncycastle.asn1.ASN1Encodable +import org.bouncycastle.asn1.x500.AttributeTypeAndValue +import org.bouncycastle.asn1.x500.RDN +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x500.style.BCStyle +import org.bouncycastle.cert.X509CertificateHolder +import org.junit.Assert +import org.junit.Test +import org.mockito.Mockito +import java.math.BigInteger +import java.security.Principal +import java.security.PublicKey +import java.security.cert.X509Certificate +import java.util.Date +import javax.security.auth.x500.X500Principal + +class CertificateParsingUtilTest { + @Test + fun certificateParsingUtil_extractFriendlyName_success() { + val certificateHolder = Mockito.mock(X509CertificateHolder::class.java) + val subject = Mockito.mock(X500Name::class.java) + + val rdNs = arrayOf(Mockito.mock(RDN::class.java)) + Mockito.`when`(certificateHolder.subject).thenReturn(subject) + Mockito.`when`(subject.getRDNs(BCStyle.CN)).thenReturn(rdNs) + + val attributeTypeAndValueCN = Mockito.mock(AttributeTypeAndValue::class.java) + Mockito.`when`(rdNs[0].first).thenReturn(attributeTypeAndValueCN) + + val commonName = "TestSurname,TestGivenName,12345678901" + val cnEncodable = Mockito.mock(ASN1Encodable::class.java) + Mockito.`when`(cnEncodable.toString()).thenReturn(commonName) + Mockito.`when`(attributeTypeAndValueCN.value).thenReturn(cnEncodable) + + val rdSNNs = arrayOf(Mockito.mock(RDN::class.java)) + val rdGNNs = arrayOf(Mockito.mock(RDN::class.java)) + val rdSERIALNs = arrayOf(Mockito.mock(RDN::class.java)) + + Mockito.`when`(subject.getRDNs(BCStyle.SURNAME)).thenReturn(rdSNNs) + Mockito.`when`(subject.getRDNs(BCStyle.GIVENNAME)).thenReturn(rdGNNs) + Mockito.`when`(subject.getRDNs(BCStyle.SERIALNUMBER)).thenReturn(rdSERIALNs) + + val attributeTypeAndValueSurname = Mockito.mock(AttributeTypeAndValue::class.java) + val attributeTypeAndValueGivenName = Mockito.mock(AttributeTypeAndValue::class.java) + val attributeTypeAndValueSerialNumber = Mockito.mock(AttributeTypeAndValue::class.java) + + Mockito.`when`(rdSNNs[0].first).thenReturn(attributeTypeAndValueSurname) + Mockito.`when`(rdGNNs[0].first).thenReturn(attributeTypeAndValueGivenName) + Mockito.`when`(rdSERIALNs[0].first).thenReturn(attributeTypeAndValueSerialNumber) + + val surname = "TestSurname" + val givenName = "TestGivenName" + val serialNumber = "12345678901" + + val surnameEncodable = Mockito.mock(ASN1Encodable::class.java) + val givenNameEncodable = Mockito.mock(ASN1Encodable::class.java) + val serialNumberEncodable = Mockito.mock(ASN1Encodable::class.java) + + Mockito.`when`(surnameEncodable.toString()).thenReturn(surname) + Mockito.`when`(givenNameEncodable.toString()).thenReturn(givenName) + Mockito.`when`(serialNumberEncodable.toString()).thenReturn(serialNumber) + + Mockito.`when`(attributeTypeAndValueSurname.value).thenReturn(surnameEncodable) + Mockito.`when`(attributeTypeAndValueGivenName.value).thenReturn(givenNameEncodable) + Mockito.`when`(attributeTypeAndValueSerialNumber.value).thenReturn(serialNumberEncodable) + + val result = CertificateParsingUtil.extractFriendlyName(certificateHolder) + + val expectedFriendlyName = "$surname,$givenName,$serialNumber" + Assert.assertEquals(expectedFriendlyName, result) + } + + @Test + fun certificateParsingUtil_extractFriendlyName_returnCommonNameIfNoSurnameAndGivenName() { + val certificateHolder = Mockito.mock(X509CertificateHolder::class.java) + val subject = Mockito.mock(X500Name::class.java) + + val rdNs = arrayOf(Mockito.mock(RDN::class.java)) + Mockito.`when`(certificateHolder.subject).thenReturn(subject) + Mockito.`when`(subject.getRDNs(BCStyle.CN)).thenReturn(rdNs) + + val attributeTypeAndValueCN = Mockito.mock(AttributeTypeAndValue::class.java) + Mockito.`when`(rdNs[0].first).thenReturn(attributeTypeAndValueCN) + + val commonName = "TestSurname,TestGivenName,12345678901" + val cnEncodable = Mockito.mock(ASN1Encodable::class.java) + Mockito.`when`(cnEncodable.toString()).thenReturn(commonName) + Mockito.`when`(attributeTypeAndValueCN.value).thenReturn(cnEncodable) + + Mockito.`when`(subject.getRDNs(BCStyle.SURNAME)).thenReturn(emptyArray()) + Mockito.`when`(subject.getRDNs(BCStyle.GIVENNAME)).thenReturn(emptyArray()) + Mockito.`when`(subject.getRDNs(BCStyle.SERIALNUMBER)).thenReturn(emptyArray()) + + val result = CertificateParsingUtil.extractFriendlyName(certificateHolder) + + Assert.assertEquals(commonName, result) + } + + @Test + fun certificateParsingUtil_extractFriendlyName_removeSerialNumberPrefix_success() { + val certificateHolder = Mockito.mock(X509CertificateHolder::class.java) + val subject = Mockito.mock(X500Name::class.java) + + val rdNs = arrayOf(Mockito.mock(RDN::class.java)) + Mockito.`when`(certificateHolder.subject).thenReturn(subject) + Mockito.`when`(subject.getRDNs(BCStyle.CN)).thenReturn(rdNs) + + val attributeTypeAndValueCN = Mockito.mock(AttributeTypeAndValue::class.java) + Mockito.`when`(rdNs[0].first).thenReturn(attributeTypeAndValueCN) + + val commonName = "TestSurname,TestGivenName,PNOEE-12345678901" + val cnEncodable = Mockito.mock(ASN1Encodable::class.java) + Mockito.`when`(cnEncodable.toString()).thenReturn(commonName) + Mockito.`when`(attributeTypeAndValueCN.value).thenReturn(cnEncodable) + + val rdSNNs = arrayOf(Mockito.mock(RDN::class.java)) + val rdGNNs = arrayOf(Mockito.mock(RDN::class.java)) + val rdSERIALNs = arrayOf(Mockito.mock(RDN::class.java)) + + Mockito.`when`(subject.getRDNs(BCStyle.SURNAME)).thenReturn(rdSNNs) + Mockito.`when`(subject.getRDNs(BCStyle.GIVENNAME)).thenReturn(rdGNNs) + Mockito.`when`(subject.getRDNs(BCStyle.SERIALNUMBER)).thenReturn(rdSERIALNs) + + val attributeTypeAndValueSurname = Mockito.mock(AttributeTypeAndValue::class.java) + val attributeTypeAndValueGivenName = Mockito.mock(AttributeTypeAndValue::class.java) + val attributeTypeAndValueSerialNumber = Mockito.mock(AttributeTypeAndValue::class.java) + + Mockito.`when`(rdSNNs[0].first).thenReturn(attributeTypeAndValueSurname) + Mockito.`when`(rdGNNs[0].first).thenReturn(attributeTypeAndValueGivenName) + Mockito.`when`(rdSERIALNs[0].first).thenReturn(attributeTypeAndValueSerialNumber) + + val surnameEncodable = Mockito.mock(ASN1Encodable::class.java) + val givenNameEncodable = Mockito.mock(ASN1Encodable::class.java) + val serialNumberEncodable = Mockito.mock(ASN1Encodable::class.java) + + Mockito.`when`(surnameEncodable.toString()).thenReturn("TestSurname") + Mockito.`when`(givenNameEncodable.toString()).thenReturn("TestGivenName") + Mockito.`when`(serialNumberEncodable.toString()).thenReturn("PNOEE-12345678901") + + Mockito.`when`(attributeTypeAndValueSurname.value).thenReturn(surnameEncodable) + Mockito.`when`(attributeTypeAndValueGivenName.value).thenReturn(givenNameEncodable) + Mockito.`when`(attributeTypeAndValueSerialNumber.value).thenReturn(serialNumberEncodable) + + val result = CertificateParsingUtil.extractFriendlyName(certificateHolder) + + Assert.assertEquals("TestSurname,TestGivenName,12345678901", result) + } + + @Test + fun certificateParsingUtil_personalData_success() { + val certificate = FakeX509Certificate("CN=TestSurname\\,TestGivenName\\,12345678901") + + val result = CertificateParsingUtil.personalData(certificate) + + Assert.assertEquals("TestSurname", result.surname) + Assert.assertEquals("TestGivenName", result.givenNames) + Assert.assertEquals("12345678901", result.personalCode) + } + + @Test + fun certificateParsingUtil_personalData_throwIllegalArgumentExceptionForUnexpectedCertificateSubjectFormat() { + val certificate = FakeX509Certificate("CN=OnlySurname") + + try { + CertificateParsingUtil.personalData(certificate) + Assert.fail("Expected IllegalArgumentException to be thrown") + } catch (exception: IllegalArgumentException) { + Assert.assertEquals("Unexpected signing certificate subject format", exception.message) + } + } + + private class FakeX509Certificate( + private val distinguishedName: String, + ) : X509Certificate() { + override fun getSubjectX500Principal(): X500Principal = X500Principal(distinguishedName) + + override fun checkValidity() = Unit + + override fun checkValidity(date: Date?) = Unit + + override fun getVersion(): Int = 3 + + override fun getSerialNumber(): BigInteger = BigInteger.ONE + + override fun getIssuerDN(): Principal? = null + + override fun getSubjectDN(): Principal? = null + + override fun getNotBefore(): Date = Date() + + override fun getNotAfter(): Date = Date() + + override fun getTBSCertificate(): ByteArray = byteArrayOf() + + override fun getSignature(): ByteArray = byteArrayOf() + + override fun getSigAlgName(): String = "" + + override fun getSigAlgOID(): String = "" + + override fun getSigAlgParams(): ByteArray? = null + + override fun getIssuerUniqueID(): BooleanArray? = null + + override fun getSubjectUniqueID(): BooleanArray? = null + + override fun getKeyUsage(): BooleanArray? = null + + override fun getBasicConstraints(): Int = -1 + + override fun getEncoded(): ByteArray = byteArrayOf() + + override fun verify(key: PublicKey?) = Unit + + override fun verify( + key: PublicKey?, + sigProvider: String?, + ) = Unit + + override fun toString(): String = distinguishedName + + override fun getPublicKey(): PublicKey? = null + + override fun hasUnsupportedCriticalExtension(): Boolean = false + + override fun getCriticalExtensionOIDs(): MutableSet? = null + + override fun getNonCriticalExtensionOIDs(): MutableSet? = null + + override fun getExtensionValue(oid: String?): ByteArray? = null + } +} diff --git a/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/certificate/CertificateParsingUtil.kt b/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/certificate/CertificateParsingUtil.kt new file mode 100644 index 000000000..0f5c3316a --- /dev/null +++ b/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/certificate/CertificateParsingUtil.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2017 - 2025 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.common.certificate + +import org.bouncycastle.asn1.ASN1ObjectIdentifier +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x500.style.BCStyle +import org.bouncycastle.cert.X509CertificateHolder +import java.security.cert.X509Certificate + +object CertificateParsingUtil { + fun extractFriendlyName(certificate: X509CertificateHolder): String = extractFriendlyName(certificate.subject) + + fun personalData(cert: X509Certificate): PersonalData = + personalData(X500Name.getInstance(cert.subjectX500Principal.encoded)) + + private fun extractFriendlyName(subject: X500Name): String { + val rdNs = subject.getRDNs(ASN1ObjectIdentifier.getInstance(BCStyle.CN)) + val commonName = + rdNs[0] + .first.value + .toString() + .trim { it <= ' ' } + + val rdSNNs = subject.getRDNs(ASN1ObjectIdentifier.getInstance(BCStyle.SURNAME)) + val rdGNNs = subject.getRDNs(ASN1ObjectIdentifier.getInstance(BCStyle.GIVENNAME)) + val rdSERIALNs = subject.getRDNs(ASN1ObjectIdentifier.getInstance(BCStyle.SERIALNUMBER)) + + val types: List = mutableListOf("PAS", "IDC", "PNO", "TAX", "TIN") + var serialNR = + if (rdSERIALNs.isEmpty()) { + "" + } else { + rdSERIALNs[0] + .first.value + .toString() + .trim { it <= ' ' } + } + + if (serialNR.length > 6 && + ( + types.contains(serialNR.substring(0, 3)) || + serialNR[2] == ':' + ) && + serialNR[5] == '-' + ) { + serialNR = serialNR.substring(6) + } + + return if (rdSNNs.isEmpty() || rdGNNs.isEmpty()) { + commonName + } else { + rdSNNs[0] + .first.value + .toString() + .trim { it <= ' ' } + "," + + rdGNNs[0] + .first.value + .toString() + .trim { it <= ' ' } + "," + serialNR + } + } + + private fun personalData(subject: X500Name): PersonalData { + val friendlyName = extractFriendlyName(subject) + val parts = friendlyName.split(",").map { it.trim() } + + require(parts.size >= 3) { + "Unexpected signing certificate subject format" + } + + return PersonalData( + surname = parts[0], + givenNames = parts[1], + personalCode = parts[2], + ) + } +} + +data class PersonalData( + val surname: String, + val givenNames: String, + val personalCode: String, +) diff --git a/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/certificate/CertificateServiceImpl.kt b/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/certificate/CertificateServiceImpl.kt index 47f47417a..ae4b495e0 100644 --- a/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/certificate/CertificateServiceImpl.kt +++ b/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/certificate/CertificateServiceImpl.kt @@ -22,8 +22,6 @@ package ee.ria.DigiDoc.common.certificate import ee.ria.DigiDoc.common.model.EIDType -import org.bouncycastle.asn1.ASN1ObjectIdentifier -import org.bouncycastle.asn1.x500.style.BCStyle import org.bouncycastle.asn1.x509.CertificatePolicies import org.bouncycastle.asn1.x509.ExtendedKeyUsage import org.bouncycastle.asn1.x509.KeyPurposeId @@ -60,57 +58,8 @@ class CertificateServiceImpl return extendedKeyUsage } - override fun extractFriendlyName(certificate: X509CertificateHolder): String { - val rdNs = certificate.subject.getRDNs(ASN1ObjectIdentifier.getInstance(BCStyle.CN)) - val commonName = - rdNs[0] - .first.value - .toString() - .trim { it <= ' ' } - - val rdSNNs = certificate.subject.getRDNs(ASN1ObjectIdentifier.getInstance(BCStyle.SURNAME)) - val rdGNNs = certificate.subject.getRDNs(ASN1ObjectIdentifier.getInstance(BCStyle.GIVENNAME)) - val rdSERIALNs = certificate.subject.getRDNs(ASN1ObjectIdentifier.getInstance(BCStyle.SERIALNUMBER)) - - // http://www.etsi.org/deliver/etsi_en/319400_319499/31941201/01.01.01_60/en_31941201v010101p.pdf - val types: List = mutableListOf("PAS", "IDC", "PNO", "TAX", "TIN") - var serialNR = - if (rdSERIALNs.isEmpty()) { - "" - } else { - rdSERIALNs[0] - .first.value - .toString() - .trim { it <= ' ' } - } - if (serialNR.length > 6 && - ( - types.contains( - serialNR.substring( - 0, - 3, - ), - ) || - serialNR[2] == ':' - ) && - serialNR[5] == '-' - ) { - serialNR = serialNR.substring(6) - } - - return if (rdSNNs.isEmpty() || rdGNNs.isEmpty()) { - commonName - } else { - rdSNNs[0] - .first.value - .toString() - .trim { it <= ' ' } + "," + - rdGNNs[0] - .first.value - .toString() - .trim { it <= ' ' } + "," + serialNR - } - } + override fun extractFriendlyName(certificate: X509CertificateHolder): String = + CertificateParsingUtil.extractFriendlyName(certificate) override fun isEllipticCurve(certificate: X509CertificateHolder): Boolean = certificate.subjectPublicKeyInfo.algorithm.algorithm diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt similarity index 100% rename from web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt rename to web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt similarity index 99% rename from web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt rename to web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt index 7331f348e..4996a8796 100644 --- a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt +++ b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt @@ -40,6 +40,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.security.MessageDigest import java.util.Base64 @RunWith(AndroidJUnit4::class) @@ -278,7 +279,7 @@ class WebEidRequestParserTest { } private fun validSha384Base64(): String { - val digest = java.security.MessageDigest.getInstance("SHA-384") + val digest = MessageDigest.getInstance("SHA-384") val hash = digest.digest("test-data".toByteArray()) return Base64.getEncoder().encodeToString(hash) } diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidSignServiceTest.kt b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidSignServiceTest.kt similarity index 100% rename from web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/WebEidSignServiceTest.kt rename to web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidSignServiceTest.kt diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/di/AppModulesTest.kt b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/di/AppModulesTest.kt similarity index 100% rename from web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/di/AppModulesTest.kt rename to web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/di/AppModulesTest.kt diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/exception/WebEidExceptionTest.kt b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/exception/WebEidExceptionTest.kt similarity index 100% rename from web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/exception/WebEidExceptionTest.kt rename to web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/exception/WebEidExceptionTest.kt diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtilTest.kt b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtilTest.kt similarity index 100% rename from web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtilTest.kt rename to web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtilTest.kt diff --git a/web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt similarity index 100% rename from web-eid-lib/src/androidTest/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt rename to web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthService.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthService.kt similarity index 100% rename from web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthService.kt rename to web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthService.kt diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt similarity index 100% rename from web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt rename to web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidSignService.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidSignService.kt similarity index 100% rename from web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidSignService.kt rename to web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidSignService.kt diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidSignServiceImpl.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidSignServiceImpl.kt similarity index 100% rename from web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/WebEidSignServiceImpl.kt rename to web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidSignServiceImpl.kt diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthRequest.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthRequest.kt similarity index 100% rename from web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthRequest.kt rename to web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthRequest.kt diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidCertificateRequest.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidCertificateRequest.kt similarity index 100% rename from web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidCertificateRequest.kt rename to web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidCertificateRequest.kt diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidPersonalData.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidPersonalData.kt similarity index 100% rename from web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidPersonalData.kt rename to web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidPersonalData.kt diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt similarity index 100% rename from web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt rename to web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/exception/WebEidErrorCode.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/exception/WebEidErrorCode.kt similarity index 96% rename from web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/exception/WebEidErrorCode.kt rename to web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/exception/WebEidErrorCode.kt index 5ab432b10..728b849d3 100644 --- a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/exception/WebEidErrorCode.kt +++ b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/exception/WebEidErrorCode.kt @@ -24,4 +24,5 @@ package ee.ria.DigiDoc.webEid.exception enum class WebEidErrorCode { ERR_WEBEID_MOBILE_INVALID_REQUEST, ERR_WEBEID_MOBILE_UNKNOWN_ERROR, + ERR_WEBEID_USER_CANCELLED, } diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/exception/WebEidException.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/exception/WebEidException.kt similarity index 100% rename from web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/exception/WebEidException.kt rename to web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/exception/WebEidException.kt diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtil.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtil.kt similarity index 100% rename from web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtil.kt rename to web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtil.kt diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt similarity index 88% rename from web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt rename to web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt index 7c748a9a5..a11ee2441 100644 --- a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt +++ b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt @@ -22,6 +22,7 @@ package ee.ria.DigiDoc.webEid.utils import android.net.Uri +import ee.ria.DigiDoc.common.certificate.CertificateParsingUtil import ee.ria.DigiDoc.utilsLib.signing.CertificateUtil import ee.ria.DigiDoc.webEid.domain.model.WebEidAuthRequest import ee.ria.DigiDoc.webEid.domain.model.WebEidCertificateRequest @@ -29,13 +30,9 @@ import ee.ria.DigiDoc.webEid.domain.model.WebEidPersonalData 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.bouncycastle.asn1.x500.X500Name -import org.bouncycastle.asn1.x500.style.BCStyle -import org.bouncycastle.asn1.x500.style.IETFUtils import org.json.JSONObject import java.net.URI import java.net.URISyntaxException -import java.security.cert.X509Certificate import java.util.Base64 object WebEidRequestParser { @@ -107,6 +104,7 @@ object WebEidRequestParser { val signingCertificateDerBytes = Base64.getDecoder().decode(signingCertificatePem) val signingCertificate = CertificateUtil.x509Certificate(signingCertificateDerBytes) + val parsedPersonalData = CertificateParsingUtil.personalData(signingCertificate) return WebEidSignRequest( responseUri = responseUri.toString(), @@ -114,7 +112,12 @@ object WebEidRequestParser { signingCertificate, hash = hash, hashFunction = hashFunction, - personalData = extractPersonalData(signingCertificate), + personalData = + WebEidPersonalData( + surname = parsedPersonalData.surname, + givenNames = parsedPersonalData.givenNames, + personalCode = parsedPersonalData.personalCode, + ), ) } @@ -214,30 +217,4 @@ object WebEidRequestParser { return hashBytes } - - private fun extractPersonalData(cert: X509Certificate): WebEidPersonalData { - val x500Name = X500Name.getInstance(cert.subjectX500Principal.encoded) - val cnRDNs = x500Name.getRDNs(BCStyle.CN) - - require(cnRDNs.isNotEmpty()) { - "Signing certificate CN missing" - } - - val cn = - IETFUtils - .valueToString(cnRDNs.first().first.value) - .replace("\\,", ",") - .replace("\\ ", " ") - val parts = cn.split(",").map { it.trim() } - - require(parts.size >= 3) { - "Unexpected signing certificate CN format: $cn" - } - - return WebEidPersonalData( - surname = parts[0], - givenNames = parts[1], - personalCode = parts[2], - ) - } } diff --git a/web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt similarity index 100% rename from web-eid-lib/src/main/java/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt rename to web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt From cb87916a61509d5b8a901cb476c311323ef32781 Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Wed, 4 Mar 2026 17:08:47 +0200 Subject: [PATCH 16/28] NFC-118 Switch request/response JSON fields to camelCase --- .../DigiDoc/viewmodel/WebEidViewModelTest.kt | 39 ++++++++++--------- .../ria/DigiDoc/viewmodel/WebEidViewModel.kt | 2 +- .../DigiDoc/webEid/WebEidRequestParserTest.kt | 18 ++++----- .../webEid/utils/WebEidRequestParser.kt | 16 ++++---- 4 files changed, 38 insertions(+), 37 deletions(-) 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 ae8618b60..badbc4fc4 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt @@ -96,7 +96,7 @@ class WebEidViewModelTest { runTest { val uri = Uri.parse( - "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6dHJ1ZX0", + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luVXJpIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZXNwb25zZSIsImdldFNpZ25pbmdDZXJ0aWZpY2F0ZSI6dHJ1ZX0", ) viewModel.handleAuth(uri) val authRequest = viewModel.authRequest.value @@ -114,7 +114,7 @@ class WebEidViewModelTest { fun webEidViewModel_handleAuth_emitErrorResponseEventWhenChallengeMinLength() { val uri = Uri.parse( - "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwibG9naW5fdXJpIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZXNwb25zZSIsImdldF9zaWduaW5nX2NlcnRpZmljYXRlIjp0cnVlfQ", + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwibG9naW5VcmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwiZ2V0U2lnbmluZ0NlcnRpZmljYXRlIjp0cnVlfQ", ) webEidViewModel_handleAuth_emitErrorResponseEventWhenInvalidChallenge(uri) } @@ -123,7 +123,7 @@ class WebEidViewModelTest { fun webEidViewModel_handleAuth_emitErrorResponseEventWhenChallengeMaxLength() { val uri = Uri.parse( - "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLCJsb2dpbl91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwiZ2V0X3NpZ25pbmdfY2VydGlmaWNhdGUiOnRydWV9", + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLCJsb2dpblVyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJnZXRTaWduaW5nQ2VydGlmaWNhdGUiOnRydWV9", ) webEidViewModel_handleAuth_emitErrorResponseEventWhenInvalidChallenge(uri) } @@ -154,7 +154,7 @@ class WebEidViewModelTest { runTest(UnconfinedTestDispatcher()) { val uri = Uri.parse( - "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS54eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eC5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6dHJ1ZX0", + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luVXJpIjoiaHR0cHM6Ly9leGFtcGxlLnh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4LmNvbS9yZXNwb25zZSIsImdldFNpZ25pbmdDZXJ0aWZpY2F0ZSI6dHJ1ZX0=", ) val deferred = async { @@ -196,7 +196,7 @@ class WebEidViewModelTest { val signature = byteArrayOf(4, 5, 6) val uri = Uri.parse( - "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6dHJ1ZX0", + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luVXJpIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZXNwb25zZSIsImdldFNpZ25pbmdDZXJ0aWZpY2F0ZSI6dHJ1ZX0", ) whenever(authService.buildAuthToken(cert, signingCert, signature)) .thenReturn(JSONObject().put("format", "web-eid:1.0")) @@ -213,7 +213,7 @@ 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("authToken") assertEquals("web-eid:1.0", authToken.getString("format")) } } @@ -227,7 +227,7 @@ class WebEidViewModelTest { val signature = byteArrayOf(4, 5, 6) val uri = Uri.parse( - "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6ZmFsc2V9", + "web-eid-mobile://auth#ewogICJjaGFsbGVuZ2UiOiAidGVzdC1jaGFsbGVuZ2UtMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLAogICJsb2dpblVyaSI6ICJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwKICAiZ2V0U2lnbmluZ0NlcnRpZmljYXRlIjogZmFsc2UKfQ==", ) whenever(authService.buildAuthToken(cert, null, signature)) .thenReturn(JSONObject().put("format", "web-eid:1.0")) @@ -236,6 +236,7 @@ class WebEidViewModelTest { viewModel.relyingPartyResponseEvents.first() } viewModel.handleAuth(uri) + println(viewModel.authRequest.value) viewModel.handleWebEidAuthResult(cert, signingCert, signature) verify(authService).buildAuthToken(cert, null, signature) @@ -244,7 +245,7 @@ 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("authToken") assertEquals("web-eid:1.0", authToken.getString("format")) } } @@ -258,7 +259,7 @@ class WebEidViewModelTest { val signature = byteArrayOf(4, 5, 6) val uri = Uri.parse( - "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6dHJ1ZX0", + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luVXJpIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZXNwb25zZSIsImdldFNpZ25pbmdDZXJ0aWZpY2F0ZSI6dHJ1ZX0", ) whenever(authService.buildAuthToken(cert, signingCert, signature)) .thenThrow(RuntimeException("Test exception")) @@ -286,7 +287,7 @@ class WebEidViewModelTest { runTest { val uri = Uri.parse( - "web-eid-mobile://cert#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIn0", + "web-eid-mobile://cert#eyJyZXNwb25zZVVyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UifQ", ) viewModel.handleCertificate(uri) val authRequest = viewModel.authRequest.value @@ -334,7 +335,7 @@ class WebEidViewModelTest { val uri = Uri.parse( "web-eid-mobile://sign#" + - "eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwic2lnbl9jZXJ0aWZpY2F0ZSI6InNpZ25lcnNlcnQiLCJoYXNoIjoiIn0", + "eyJyZXNwb25zZVVyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJzaWduQ2VydGlmaWNhdGUiOiJzaWduZXJzZXJ0IiwiaGFzaCI6IiJ9", ) val deferred = @@ -351,7 +352,7 @@ class WebEidViewModelTest { val jsonPayload = JSONObject(decodedPayload) assertEquals("ERR_WEBEID_MOBILE_INVALID_REQUEST", jsonPayload.getString("code")) assertEquals( - "Invalid signing request: missing hash or hash_function", + "Invalid signing request: missing hash or hashFunction", jsonPayload.getString("message"), ) } @@ -387,7 +388,7 @@ class WebEidViewModelTest { val signingCert = byteArrayOf(1, 2, 3) val uri = Uri.parse( - "web-eid-mobile://sign#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwic2lnbl9jZXJ0aWZpY2F0ZSI6InNpZ25pbmdfY2VydGlmaWNhdGUiLCJoYXNoIjoiaGFzaCIsImhhc2hfZnVuY3Rpb24iOiJoYXNoX2Z1bmN0aW9uIn0", + "web-eid-mobile://sign#eyJyZXNwb25zZVVyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJzaWduQ2VydGlmaWNhdGUiOiJzaWduaW5nX2NlcnRpZmljYXRlIiwiaGFzaCI6Imhhc2giLCJoYXNoRnVuY3Rpb24iOiJoYXNoX2Z1bmN0aW9uIn0", ) viewModel.handleCertificate(uri) @@ -419,7 +420,7 @@ class WebEidViewModelTest { val signingCert = byteArrayOf(1, 2, 3) val uri = Uri.parse( - "web-eid-mobile://sign#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwic2lnbl9jZXJ0aWZpY2F0ZSI6InNpZ25pbmdfY2VydGlmaWNhdGUiLCJoYXNoIjoiaGFzaCIsImhhc2hfZnVuY3Rpb24iOiJoYXNoX2Z1bmN0aW9uIn0", + "web-eid-mobile://sign#eyJyZXNwb25zZVVyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJzaWduQ2VydGlmaWNhdGUiOiJzaWduaW5nX2NlcnRpZmljYXRlIiwiaGFzaCI6Imhhc2giLCJoYXNoRnVuY3Rpb24iOiJoYXNoX2Z1bmN0aW9uIn0", ) viewModel.handleCertificate(uri) @@ -511,7 +512,7 @@ class WebEidViewModelTest { runTest(UnconfinedTestDispatcher()) { val uri = Uri.parse( - "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6dHJ1ZX0", + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luVXJpIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZXNwb25zZSIsImdldFNpZ25pbmdDZXJ0aWZpY2F0ZSI6dHJ1ZX0", ) viewModel.handleAuth(uri) @@ -542,7 +543,7 @@ class WebEidViewModelTest { runTest(UnconfinedTestDispatcher()) { val uri = Uri.parse( - "web-eid-mobile://cert#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIn0", + "web-eid-mobile://cert#eyJyZXNwb25zZVVyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UifQ", ) viewModel.handleCertificate(uri) @@ -619,11 +620,11 @@ class WebEidViewModelTest { val hashFunction = "SHA-384" val responseUri = "https://rp.example.com/sign/response" val sb = StringBuilder() - sb.append("{\"response_uri\":\"$responseUri\"") + sb.append("{\"responseUri\":\"$responseUri\"") sb.append(",\"hash\":\"$hash\"") - sb.append(",\"hash_function\":\"$hashFunction\"") + sb.append(",\"hashFunction\":\"$hashFunction\"") if (signingCertificate != null) { - sb.append(",\"signing_certificate\":\"$signingCertificate\"") + sb.append(",\"signingCertificate\":\"$signingCertificate\"") } sb.append("}") val encoded = Base64.getEncoder().encodeToString(sb.toString().toByteArray()) 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 9a5c47fc4..b7f6be76d 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt @@ -125,7 +125,7 @@ class WebEidViewModel if (getSigningCertificate == true) signingCert else null, signature, ) - val payload = JSONObject().put("auth_token", token) + val payload = JSONObject().put("authToken", token) val responseUri = WebEidResponseUtil.createResponseUri(loginUri, payload) _relyingPartyResponseEvents.emit(responseUri) } catch (e: Exception) { diff --git a/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt index 4996a8796..c36ac9220 100644 --- a/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt +++ b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt @@ -156,8 +156,8 @@ class WebEidRequestParserTest { """ { "challenge": "abc123", - "login_uri": "$loginUri", - "get_signing_certificate": false + "loginUri": "$loginUri", + "getSigningCertificate": false } """.trimIndent() @@ -183,8 +183,8 @@ class WebEidRequestParserTest { """ { "challenge": "$challenge", - "login_uri": "$loginUri", - "get_signing_certificate": $getCert + "loginUri": "$loginUri", + "getSigningCertificate": $getCert } """.trimIndent() val encoded = Base64.getEncoder().encodeToString(json.toByteArray()) @@ -210,8 +210,8 @@ class WebEidRequestParserTest { """ { "challenge": "${"b".repeat(60)}", - "login_uri": "$loginUri", - "get_signing_certificate": false + "loginUri": "$loginUri", + "getSigningCertificate": false } """.trimIndent() @@ -267,11 +267,11 @@ class WebEidRequestParserTest { ): String { val responseUri = "https://rp.example.com/sign/response" val sb = StringBuilder() - sb.append("{\"response_uri\":\"$responseUri\"") + sb.append("{\"responseUri\":\"$responseUri\"") if (hash != null) sb.append(",\"hash\":\"$hash\"") - if (hashFunction != null) sb.append(",\"hash_function\":\"$hashFunction\"") + if (hashFunction != null) sb.append(",\"hashFunction\":\"$hashFunction\"") if (signingCertificate != null) { - sb.append(",\"signing_certificate\":\"$signingCertificate\"") + sb.append(",\"signingCertificate\":\"$signingCertificate\"") } sb.append("}") val encoded = Base64.getEncoder().encodeToString(sb.toString().toByteArray()) diff --git a/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt index a11ee2441..13437ad0b 100644 --- a/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt +++ b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt @@ -43,7 +43,7 @@ object WebEidRequestParser { fun parseAuthUri(authUri: Uri): WebEidAuthRequest { val request = decodeUriFragment(authUri) val challenge = request.getString("challenge") - val responseUri = validateResponseUri(request.getString("login_uri")) + val responseUri = validateResponseUri(request.getString("loginUri")) if (challenge.isNullOrBlank() || challenge.length < MIN_CHALLENGE_LENGTH || challenge.length > MAX_CHALLENGE_LENGTH @@ -58,14 +58,14 @@ object WebEidRequestParser { return WebEidAuthRequest( challenge = challenge, loginUri = responseUri.toString(), - getSigningCertificate = request.optBoolean("get_signing_certificate", false), + getSigningCertificate = request.optBoolean("getSigningCertificate", false), origin = parseOrigin(responseUri), ) } fun parseCertificateUri(uri: Uri): WebEidCertificateRequest { val request = decodeUriFragment(uri) - val responseUri = validateResponseUri(request.optString("response_uri", "")) + val responseUri = validateResponseUri(request.optString("responseUri", "")) return WebEidCertificateRequest( responseUri = responseUri.toString(), @@ -75,14 +75,14 @@ object WebEidRequestParser { fun parseSignUri(uri: Uri): WebEidSignRequest { val request = decodeUriFragment(uri) - val responseUri = validateResponseUri(request.optString("response_uri", "")) + val responseUri = validateResponseUri(request.optString("responseUri", "")) val hash = request.optString("hash", "") - val hashFunction = request.optString("hash_function", "") + val hashFunction = request.optString("hashFunction", "") if (hash.isBlank() || hashFunction.isBlank()) { throw WebEidException( ERR_WEBEID_MOBILE_INVALID_REQUEST, - "Invalid signing request: missing hash or hash_function", + "Invalid signing request: missing hash or hashFunction", responseUri.toString(), ) } @@ -93,11 +93,11 @@ object WebEidRequestParser { responseUri = responseUri.toString(), ) - val signingCertificatePem = request.optString("signing_certificate", "") + val signingCertificatePem = request.optString("signingCertificate", "") if (signingCertificatePem.isBlank()) { throw WebEidException( ERR_WEBEID_MOBILE_INVALID_REQUEST, - "Invalid signing request: missing signing_certificate", + "Invalid signing request: missing signingCertificate", responseUri.toString(), ) } From 616a9dade668e7cdfcb4ba6bc8a40e8e2af039c5 Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Tue, 24 Mar 2026 14:52:52 +0200 Subject: [PATCH 17/28] NFC-115 Implement minimal multiple signing certification solution --- .../ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt | 14 ++++++++++---- .../ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt | 13 +++++++++++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt index d807e53ca..28352fa3c 100644 --- a/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt +++ b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt @@ -87,18 +87,24 @@ class WebEidAuthServiceTest { assertEquals("web-eid:1.1", token.getString("format")) assert(token.getString("unverifiedCertificate").isNotBlank()) - assert(token.getString("unverifiedSigningCertificate").isNotBlank()) assert(token.getString("signature").isNotBlank()) assert(token.has("algorithm")) - assert(token.has("supportedSignatureAlgorithms")) + assert(token.has("unverifiedSigningCertificates")) + + val signingCertificates = token.getJSONArray("unverifiedSigningCertificates") + assertEquals(1, signingCertificates.length()) + + val signingCertificate = signingCertificates.getJSONObject(0) + assert(signingCertificate.getString("certificate").isNotBlank()) + assert(signingCertificate.has("supportedSignatureAlgorithms")) assertEquals(Base64.getEncoder().encodeToString(authCertBytes), token.getString("unverifiedCertificate")) assertEquals( Base64.getEncoder().encodeToString(signingCertBytes), - token.getString("unverifiedSigningCertificate"), + signingCertificate.getString("certificate"), ) assertNotEquals( token.getString("unverifiedCertificate"), - token.getString("unverifiedSigningCertificate"), + signingCertificate.getString("certificate"), "Auth certificate and signing certificate should not be identical", ) } diff --git a/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt index 6624258d3..84c4b6e66 100644 --- a/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt +++ b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt @@ -23,6 +23,7 @@ package ee.ria.DigiDoc.webEid import ee.ria.DigiDoc.webEid.utils.WebEidAlgorithmUtil.buildSupportedSignatureAlgorithms import ee.ria.DigiDoc.webEid.utils.WebEidAlgorithmUtil.getAlgorithm +import org.json.JSONArray import org.json.JSONObject import java.security.cert.CertificateFactory import java.security.cert.X509Certificate @@ -55,8 +56,16 @@ class WebEidAuthServiceImpl if (signingCert != null) { val supportedSignatureAlgorithms = buildSupportedSignatureAlgorithms(publicKey) - put("unverifiedSigningCertificate", Base64.getEncoder().encodeToString(signingCert)) - put("supportedSignatureAlgorithms", supportedSignatureAlgorithms) + + val signingCertificates = + JSONArray().put( + JSONObject().apply { + put("certificate", Base64.getEncoder().encodeToString(signingCert)) + put("supportedSignatureAlgorithms", supportedSignatureAlgorithms) + }, + ) + + put("unverifiedSigningCertificates", signingCertificates) put("format", "web-eid:1.1") } else { put("format", "web-eid:1.0") From 06377f87ee243e77bf5a4c2ac082535de1a8e333 Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Wed, 25 Mar 2026 10:37:14 +0200 Subject: [PATCH 18/28] NFC-135 Update EU fund text --- app/src/main/res/values-et/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 7939c5f3e..ed10206f7 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -498,7 +498,7 @@ Eesti tuleviku heaks - Euroopa Liit Euroopa Regionaalarengu Fond + Kaasrahastanud Euroopa Liit Projekti on toetatud Euroopa Liidu Regionaalarengu Fondist RIA DigiDoc versioon %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 45f01cd88..b2233ec43 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -498,7 +498,7 @@ Eesti tuleviku heaks - Euroopa Liit Euroopa Regionaalarengu Fond + Kaasrahastanud Euroopa Liit The project is supported by the European Regional Development Fund RIA DigiDoc version %1$s From 93b85195b26a3eff2e24a9568217a0e632c1e0e8 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Wed, 18 Mar 2026 16:37:15 +0200 Subject: [PATCH 19/28] NFC-137 Add AppLinks intent --- .../ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt | 131 ++++++++++++++++++ app/src/debug/AndroidManifest.xml | 29 ++++ app/src/main/AndroidManifest.xml | 8 -- .../kotlin/ee/ria/DigiDoc/MainActivity.kt | 3 +- .../ee/ria/DigiDoc/fragment/WebEidFragment.kt | 14 +- .../ee/ria/DigiDoc/utils/WebEidUriUtil.kt | 52 +++++++ app/src/release/AndroidManifest.xml | 21 +++ 7 files changed, 242 insertions(+), 16 deletions(-) create mode 100644 app/src/androidTest/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt create mode 100644 app/src/debug/AndroidManifest.xml create mode 100644 app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt create mode 100644 app/src/release/AndroidManifest.xml diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt new file mode 100644 index 000000000..5211b2afe --- /dev/null +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.utils + +import android.net.Uri +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WebEidUriUtilTest { + @Test + fun isWebEidUri_customScheme_auth() { + assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("web-eid-mobile://auth"))) + } + + @Test + fun isWebEidUri_customScheme_cert() { + assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("web-eid-mobile://cert"))) + } + + @Test + fun isWebEidUri_customScheme_sign() { + assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("web-eid-mobile://sign"))) + } + + @Test + fun isWebEidUri_appLinks_auth() { + assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("https://riadigidoc.ee/auth"))) + } + + @Test + fun isWebEidUri_appLinks_cert() { + assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("https://riadigidoc.ee/cert"))) + } + + @Test + fun isWebEidUri_appLinks_sign() { + assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("https://riadigidoc.ee/sign"))) + } + + @Test + fun isWebEidUri_appLinks_unknownOperation() { + assertFalse(WebEidUriUtil.isWebEidUri(Uri.parse("https://riadigidoc.ee/unknown"))) + } + + @Test + fun isWebEidUri_wrongHost() { + assertFalse(WebEidUriUtil.isWebEidUri(Uri.parse("https://evil.com/auth"))) + } + + @Test + fun isWebEidUri_contentScheme() { + assertFalse(WebEidUriUtil.isWebEidUri(Uri.parse("content://some/path"))) + } + + @Test + fun isWebEidUri_fileScheme() { + assertFalse(WebEidUriUtil.isWebEidUri(Uri.parse("file:///some/path"))) + } + + @Test + fun isWebEidUri_customScheme_unknownOperation() { + assertFalse(WebEidUriUtil.isWebEidUri(Uri.parse("web-eid-mobile://unknown"))) + } + + @Test + fun getOperation_customScheme_auth() { + assertEquals(WebEidOperation.AUTH, WebEidUriUtil.getOperation(Uri.parse("web-eid-mobile://auth#dGVzdA"))) + } + + @Test + fun getOperation_customScheme_cert() { + assertEquals(WebEidOperation.CERT, WebEidUriUtil.getOperation(Uri.parse("web-eid-mobile://cert#dGVzdA"))) + } + + @Test + fun getOperation_customScheme_sign() { + assertEquals(WebEidOperation.SIGN, WebEidUriUtil.getOperation(Uri.parse("web-eid-mobile://sign#dGVzdA"))) + } + + @Test + fun getOperation_appLinks_auth() { + assertEquals(WebEidOperation.AUTH, WebEidUriUtil.getOperation(Uri.parse("https://riadigidoc.ee/auth#dGVzdA"))) + } + + @Test + fun getOperation_appLinks_cert() { + assertEquals(WebEidOperation.CERT, WebEidUriUtil.getOperation(Uri.parse("https://riadigidoc.ee/cert#dGVzdA"))) + } + + @Test + fun getOperation_appLinks_sign() { + assertEquals(WebEidOperation.SIGN, WebEidUriUtil.getOperation(Uri.parse("https://riadigidoc.ee/sign#dGVzdA"))) + } + + @Test + fun getOperation_unknownOperation_returnsNull() { + assertNull(WebEidUriUtil.getOperation(Uri.parse("web-eid-mobile://unknown"))) + } + + @Test + fun getOperation_appLinks_unknownOperation_returnsNull() { + assertNull(WebEidUriUtil.getOperation(Uri.parse("https://riadigidoc.ee/unknown"))) + } + + @Test + fun getOperation_unrelatedUri_returnsNull() { + assertNull(WebEidUriUtil.getOperation(Uri.parse("https://example.com/auth"))) + } +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..6973349d4 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 03ca01545..4e0cbb26f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,14 +58,6 @@ - - - - - - - - viewModel.handleAuth(it) - "cert" -> viewModel.handleCertificate(it) - "sign" -> viewModel.handleSign(it) - else -> { - viewModel.handleUnknown(it) - } + when (WebEidUriUtil.getOperation(it)) { + WebEidOperation.AUTH -> viewModel.handleAuth(it) + WebEidOperation.CERT -> viewModel.handleCertificate(it) + WebEidOperation.SIGN -> viewModel.handleSign(it) + null -> viewModel.handleUnknown(it) } } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt b/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt new file mode 100644 index 000000000..1df7a055a --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.utils + +import android.net.Uri + +enum class WebEidOperation(val operation: String) { + AUTH("auth"), + CERT("cert"), + SIGN("sign"); + + companion object { + fun fromOperation(operation: String): WebEidOperation? = + entries.find { it.operation == operation } + } +} + +object WebEidUriUtil { + private const val CUSTOM_SCHEME = "web-eid-mobile" + private const val APP_LINKS_HOST = "riadigidoc.ee" + + fun isWebEidUri(uri: Uri): Boolean = getOperation(uri) != null + + fun getOperation(uri: Uri): WebEidOperation? { + val operation = + when { + uri.scheme == CUSTOM_SCHEME -> uri.host + uri.scheme == "https" && uri.host == APP_LINKS_HOST -> uri.pathSegments.firstOrNull() + else -> null + } + return operation?.let { WebEidOperation.fromOperation(it) } + } +} diff --git a/app/src/release/AndroidManifest.xml b/app/src/release/AndroidManifest.xml new file mode 100644 index 000000000..087af8012 --- /dev/null +++ b/app/src/release/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + From 250533724fbd082a864a4e5c91f9f7a2d28218f5 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Thu, 19 Mar 2026 10:11:25 +0200 Subject: [PATCH 20/28] NFC-137 Remove redundant packageName storage --- .../domain/preferences/DataStoreTest.kt | 26 -------------- .../kotlin/ee/ria/DigiDoc/MainActivity.kt | 35 +++++++++++-------- .../ee/ria/DigiDoc/RIADigiDocAppNavigation.kt | 2 ++ .../DigiDoc/domain/preferences/DataStore.kt | 12 ------- .../ee/ria/DigiDoc/fragment/WebEidFragment.kt | 2 +- .../ria/DigiDoc/viewmodel/WebEidViewModel.kt | 3 -- 6 files changed, 23 insertions(+), 57 deletions(-) diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt index 7c01ae5ec..fe2b139df 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt @@ -616,32 +616,6 @@ class DataStoreTest { assertFalse(result) } - @Test - fun dataStore_getWebEidBrowserPackage_defaultNull() { - val result = dataStore.getWebEidBrowserPackage() - - assertNull(result) - } - - @Test - fun dataStore_setWebEidBrowserPackage_success() { - dataStore.setWebEidBrowserPackage("com.android.chrome") - - val result = dataStore.getWebEidBrowserPackage() - - assertEquals("com.android.chrome", result) - } - - @Test - fun dataStore_setWebEidBrowserPackage_nullClearsValue() { - dataStore.setWebEidBrowserPackage("com.android.chrome") - dataStore.setWebEidBrowserPackage(null) - - val result = dataStore.getWebEidBrowserPackage() - - assertNull(result) - } - @Test fun dataStore_getTemporaryCanNumber_defaultEmpty() { val result = dataStore.getTemporaryCanNumber() diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt b/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt index 38aa2dc5c..40d75be6c 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt @@ -22,10 +22,13 @@ package ee.ria.DigiDoc import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.core.app.ActivityCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -55,6 +58,7 @@ import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil import kotlinx.coroutines.launch import java.util.logging.Logger import javax.inject.Inject +import androidx.core.net.toUri @AndroidEntryPoint class MainActivity : @@ -123,21 +127,8 @@ class MainActivity : val locale = dataStore.getLocale() ?: getLocale("en") val webEidUri = intent.data?.takeIf { WebEidUriUtil.isWebEidUri(it) } - - if (webEidUri != null) { - val browserPackage = - intent - .getStringExtra("com.android.browser.application_id") - ?.takeIf { it.isNotEmpty() } - dataStore.setWebEidBrowserPackage(browserPackage) - } - - val externalFileUris = - if (webEidUri != null) { - listOf() - } else { - getExternalFileUris(intent) - } + val browserPackage = if (webEidUri != null) resolveBrowserPackage(intent) else null + val externalFileUris = getExternalFileUris(intent) localeUtil.updateLocale(applicationContext, locale) @@ -183,6 +174,7 @@ class MainActivity : RIADigiDocAppScreen( externalFileUris = externalFileUris, webEidUri = webEidUri, + browserPackage = browserPackage, ) } } @@ -210,4 +202,17 @@ class MainActivity : private fun isSystemModeEnabled(dataStore: DataStore): Boolean = dataStore.getThemeSetting() == ThemeSetting.SYSTEM private fun isDarkModeEnabled(dataStore: DataStore): Boolean = dataStore.getThemeSetting() == ThemeSetting.DARK + + private fun resolveBrowserPackage(intent: Intent): String? = + (intent + .getStringExtra("com.android.browser.application_id") + ?.takeIf { it.isNotEmpty() } + ?: ActivityCompat.getReferrer(this)?.host) // TODO: This needs testing with App Link + ?.takeIf { pkg -> + val browseIntent = Intent(Intent.ACTION_VIEW, "https://".toUri()).apply { + setPackage(pkg) + } + @Suppress("QueryPermissionsNeeded") + packageManager.resolveActivity(browseIntent, PackageManager.MATCH_DEFAULT_ONLY) != null + } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt b/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt index 12c966c38..bdeb3cfcc 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocAppNavigation.kt @@ -77,6 +77,7 @@ import ee.ria.DigiDoc.viewmodel.shared.SharedSignatureViewModel fun RIADigiDocAppScreen( externalFileUris: List, webEidUri: Uri? = null, + browserPackage: String? = null, ) { val navController = rememberNavController() val sharedMenuViewModel: SharedMenuViewModel = hiltViewModel() @@ -368,6 +369,7 @@ fun RIADigiDocAppScreen( modifier = Modifier.safeDrawingPadding(), navController = navController, webEidUri = webEidUri, + browserPackage = browserPackage, ) } } 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 3fe3aeb0e..d700bc54a 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 @@ -172,18 +172,6 @@ class DataStore fun getWebEidRememberMe(): Boolean = preferences.getBoolean("web_eid_remember_me", true) - fun setWebEidBrowserPackage(packageName: String?) { - preferences.edit { - if (packageName.isNullOrEmpty()) { - remove("web_eid_browser_package") - } else { - putString("web_eid_browser_package", packageName) - } - } - } - - fun getWebEidBrowserPackage(): String? = preferences.getString("web_eid_browser_package", null) - fun isWebEidSessionActive(): Boolean { val prefs = getEncryptedPreferences(context) return prefs?.getBoolean("web_eid_session_active", false) ?: false 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 a0d2edada..7f4993035 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt @@ -56,6 +56,7 @@ fun WebEidFragment( modifier: Modifier = Modifier, navController: NavHostController, webEidUri: Uri?, + browserPackage: String? = null, viewModel: WebEidViewModel = hiltViewModel(), sharedSettingsViewModel: SharedSettingsViewModel = hiltViewModel(), sharedContainerViewModel: SharedContainerViewModel = hiltViewModel(), @@ -65,7 +66,6 @@ fun WebEidFragment( LaunchedEffect(viewModel) { viewModel.relyingPartyResponseEvents.collect { responseUri -> - val browserPackage = viewModel.getWebEidBrowserPackage() val browserIntent = Intent(Intent.ACTION_VIEW, responseUri).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) 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 b7f6be76d..6def7a265 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt @@ -51,12 +51,9 @@ class WebEidViewModel constructor( private val authService: WebEidAuthService, private val signService: WebEidSignService, - private val dataStore: DataStore, ) : ViewModel() { private val logTag = javaClass.simpleName - fun getWebEidBrowserPackage(): String? = dataStore.getWebEidBrowserPackage() - private val _authRequest = MutableStateFlow(null) val authRequest: StateFlow = _authRequest.asStateFlow() private val _certificateRequest = MutableStateFlow(null) From 845816bd4e430dfcdd1fd5693b17fd653291a38e Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Thu, 19 Mar 2026 16:22:56 +0200 Subject: [PATCH 21/28] NFC-137 Filter external file URIs by content/file scheme --- .../ee/ria/DigiDoc/utilsLib/file/FileUtil.kt | 5 +- .../ria/DigiDoc/utilsLib/file/FileUtilTest.kt | 77 ++++++++++++++++++- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/file/FileUtil.kt b/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/file/FileUtil.kt index 62fd4f4e5..9122d8976 100644 --- a/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/file/FileUtil.kt +++ b/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/file/FileUtil.kt @@ -61,6 +61,7 @@ import javax.xml.parsers.SAXParserFactory object FileUtil { private val LOG_TAG = javaClass.simpleName + private val ALLOWED_FILE_SCHEMES = setOf("content", "file") /** * Check if file path is in cache directory @@ -506,10 +507,10 @@ object FileUtil { fun getExternalFileUris(intent: Intent): List { val externalFileUris = mutableListOf() - intent.data?.let { externalFileUris.add(it) } + intent.data?.takeIf { it.scheme in ALLOWED_FILE_SCHEMES }?.let { externalFileUris.add(it) } intent.clipData?.let { clipData -> for (i in 0 until clipData.itemCount) { - clipData.getItemAt(i)?.uri?.let { externalFileUris.add(it) } + clipData.getItemAt(i)?.uri?.takeIf { it.scheme in ALLOWED_FILE_SCHEMES }?.let { externalFileUris.add(it) } } } return externalFileUris diff --git a/utils-lib/src/test/kotlin/ee/ria/DigiDoc/utilsLib/file/FileUtilTest.kt b/utils-lib/src/test/kotlin/ee/ria/DigiDoc/utilsLib/file/FileUtilTest.kt index 647626291..a7f3ca89d 100644 --- a/utils-lib/src/test/kotlin/ee/ria/DigiDoc/utilsLib/file/FileUtilTest.kt +++ b/utils-lib/src/test/kotlin/ee/ria/DigiDoc/utilsLib/file/FileUtilTest.kt @@ -78,17 +78,61 @@ class FileUtilTest { } @Test - fun fileUtil_getExternalFileUris_successWithSingleUriIntentData() = + fun fileUtil_getExternalFileUris_successWithContentSchemeUri() = runBlocking { val mockIntent = mock(Intent::class.java) val mockUri = mock(Uri::class.java) + `when`(mockUri.scheme).thenReturn("content") `when`(mockIntent.data).thenReturn(mockUri) val externalFileUris = FileUtil.getExternalFileUris(mockIntent) assertEquals(1, externalFileUris.size) - assertEquals(externalFileUris.first(), mockIntent.data) + assertEquals(mockUri, externalFileUris.first()) + } + + @Test + fun fileUtil_getExternalFileUris_successWithFileSchemeUri() = + runBlocking { + val mockIntent = mock(Intent::class.java) + val mockUri = mock(Uri::class.java) + + `when`(mockUri.scheme).thenReturn("file") + `when`(mockIntent.data).thenReturn(mockUri) + + val externalFileUris = FileUtil.getExternalFileUris(mockIntent) + + assertEquals(1, externalFileUris.size) + assertEquals(mockUri, externalFileUris.first()) + } + + @Test + fun fileUtil_getExternalFileUris_excludesHttpsSchemeUri() = + runBlocking { + val mockIntent = mock(Intent::class.java) + val mockUri = mock(Uri::class.java) + + `when`(mockUri.scheme).thenReturn("https") + `when`(mockIntent.data).thenReturn(mockUri) + + val externalFileUris = FileUtil.getExternalFileUris(mockIntent) + + assertEquals(0, externalFileUris.size) + } + + @Test + fun fileUtil_getExternalFileUris_excludesCustomSchemeUri() = + runBlocking { + val mockIntent = mock(Intent::class.java) + val mockUri = mock(Uri::class.java) + + `when`(mockUri.scheme).thenReturn("web-eid-mobile") + `when`(mockIntent.data).thenReturn(mockUri) + + val externalFileUris = FileUtil.getExternalFileUris(mockIntent) + + assertEquals(0, externalFileUris.size) } @Test @@ -112,6 +156,8 @@ class FileUtilTest { val mockUri1 = mock(Uri::class.java) val mockUri2 = mock(Uri::class.java) + `when`(mockUri1.scheme).thenReturn("content") + `when`(mockUri2.scheme).thenReturn("file") `when`(mockClipDataItem1.uri).thenReturn(mockUri1) `when`(mockClipDataItem2.uri).thenReturn(mockUri2) @@ -128,6 +174,33 @@ class FileUtilTest { assertEquals(mockUri2, externalFileUris.last()) } + @Test + fun fileUtil_getExternalFileUris_clipDataFiltersOutNonAllowedSchemes() = + runBlocking { + val mockIntent = mock(Intent::class.java) + val mockClipData = mock(ClipData::class.java) + val mockClipDataItem1 = mock(ClipData.Item::class.java) + val mockClipDataItem2 = mock(ClipData.Item::class.java) + val mockUri1 = mock(Uri::class.java) + val mockUri2 = mock(Uri::class.java) + + `when`(mockUri1.scheme).thenReturn("content") + `when`(mockUri2.scheme).thenReturn("https") + `when`(mockClipDataItem1.uri).thenReturn(mockUri1) + `when`(mockClipDataItem2.uri).thenReturn(mockUri2) + + `when`(mockClipData.getItemAt(0)).thenReturn(mockClipDataItem1) + `when`(mockClipData.getItemAt(1)).thenReturn(mockClipDataItem2) + `when`(mockClipData.itemCount).thenReturn(2) + + `when`(mockIntent.clipData).thenReturn(mockClipData) + + val externalFileUris = FileUtil.getExternalFileUris(mockIntent) + + assertEquals(1, externalFileUris.size) + assertEquals(mockUri1, externalFileUris.first()) + } + @Test fun fileUtil_getExternalFileUris_returnEmptyListWithoutIntentClipData() = runBlocking { From 7ca54bcb21193c5c6834ce8a531180151fab9497 Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Wed, 1 Apr 2026 14:13:44 +0300 Subject: [PATCH 22/28] NFC-150 Update Web eID card mismatch error wording --- .../domain/preferences/DataStoreTest.kt | 1 - .../kotlin/ee/ria/DigiDoc/MainActivity.kt | 19 +++++++++++-------- .../ee/ria/DigiDoc/utils/WebEidUriUtil.kt | 10 ++++++---- .../ee/ria/DigiDoc/viewmodel/NFCViewModel.kt | 18 +++++++++++++++++- .../ria/DigiDoc/viewmodel/WebEidViewModel.kt | 1 - app/src/main/res/values-et/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../ee/ria/DigiDoc/utilsLib/file/FileUtil.kt | 7 ++++++- 8 files changed, 42 insertions(+), 16 deletions(-) diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt index fe2b139df..f9184a249 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt @@ -40,7 +40,6 @@ import ee.ria.DigiDoc.network.siva.SivaSetting import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.BeforeClass diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt b/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt index 40d75be6c..78ea19658 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt @@ -29,6 +29,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.core.app.ActivityCompat +import androidx.core.net.toUri import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -58,7 +59,6 @@ import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil import kotlinx.coroutines.launch import java.util.logging.Logger import javax.inject.Inject -import androidx.core.net.toUri @AndroidEntryPoint class MainActivity : @@ -204,14 +204,17 @@ class MainActivity : private fun isDarkModeEnabled(dataStore: DataStore): Boolean = dataStore.getThemeSetting() == ThemeSetting.DARK private fun resolveBrowserPackage(intent: Intent): String? = - (intent - .getStringExtra("com.android.browser.application_id") - ?.takeIf { it.isNotEmpty() } - ?: ActivityCompat.getReferrer(this)?.host) // TODO: This needs testing with App Link + ( + intent + .getStringExtra("com.android.browser.application_id") + ?.takeIf { it.isNotEmpty() } + ?: ActivityCompat.getReferrer(this)?.host + ) // TODO: This needs testing with App Link ?.takeIf { pkg -> - val browseIntent = Intent(Intent.ACTION_VIEW, "https://".toUri()).apply { - setPackage(pkg) - } + val browseIntent = + Intent(Intent.ACTION_VIEW, "https://".toUri()).apply { + setPackage(pkg) + } @Suppress("QueryPermissionsNeeded") packageManager.resolveActivity(browseIntent, PackageManager.MATCH_DEFAULT_ONLY) != null } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt b/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt index 1df7a055a..b1bd4f362 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt @@ -23,14 +23,16 @@ package ee.ria.DigiDoc.utils import android.net.Uri -enum class WebEidOperation(val operation: String) { +enum class WebEidOperation( + val operation: String, +) { AUTH("auth"), CERT("cert"), - SIGN("sign"); + SIGN("sign"), + ; companion object { - fun fromOperation(operation: String): WebEidOperation? = - entries.find { it.operation == operation } + fun fromOperation(operation: String): WebEidOperation? = entries.find { it.operation == operation } } } 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 cacc38256..3395e91c9 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt @@ -657,7 +657,9 @@ class NFCViewModel if (!expectedCert.contentEquals(signerCert)) { _certMismatch.postValue(true) - throw IllegalStateException("Web eID signing certificate mismatch") + throw IllegalStateException( + "Web eID card does not match the card used for authentication", + ) } } @@ -773,6 +775,15 @@ class NFCViewModel ) } + private fun showWebEidAuthenticationCardMismatchError(e: Exception) { + _errorState.postValue(Triple(R.string.web_eid_signing_card_mismatch, null, null)) + errorLog( + logTag, + "Web eID signing failed - selected ID card does not match the card used for authentication", + e, + ) + } + private fun showTechnicalError(e: Exception) { _errorState.postValue(Triple(R.string.signature_update_nfc_technical_error, null, null)) errorLog(logTag, "Unable to perform with NFC: ${e.message}", e) @@ -863,6 +874,11 @@ class NFCViewModel true } + message.contains("Web eID card does not match the card used for authentication") -> { + showWebEidAuthenticationCardMismatchError(ex) + true + } + else -> false }.also { errorLog(logTag, "Exception: ${ex.message}", ex) 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 6def7a265..edc6ed3b8 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt @@ -25,7 +25,6 @@ import android.net.Uri import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import ee.ria.DigiDoc.R -import ee.ria.DigiDoc.domain.preferences.DataStore import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog import ee.ria.DigiDoc.webEid.WebEidAuthService import ee.ria.DigiDoc.webEid.WebEidSignService diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index ed10206f7..87b246aba 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -684,4 +684,5 @@ Päringu viga Vigane autentimispäring Kaarti ei tuvastatud. Palun alusta uuesti. + Valitud ID-kaart ei ühti autentimisel kasutatud kaardiga. Palun kasuta sama ID-kaarti. \ 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 b2233ec43..d106a86eb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -684,4 +684,5 @@ Request error Invalid authentication request Card not detected. Please start again. + The selected ID card does not match the card used for authentication. Please use the same ID card. \ No newline at end of file diff --git a/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/file/FileUtil.kt b/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/file/FileUtil.kt index 9122d8976..8541b9337 100644 --- a/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/file/FileUtil.kt +++ b/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/file/FileUtil.kt @@ -510,7 +510,12 @@ object FileUtil { intent.data?.takeIf { it.scheme in ALLOWED_FILE_SCHEMES }?.let { externalFileUris.add(it) } intent.clipData?.let { clipData -> for (i in 0 until clipData.itemCount) { - clipData.getItemAt(i)?.uri?.takeIf { it.scheme in ALLOWED_FILE_SCHEMES }?.let { externalFileUris.add(it) } + clipData + .getItemAt( + i, + )?.uri + ?.takeIf { it.scheme in ALLOWED_FILE_SCHEMES } + ?.let { externalFileUris.add(it) } } } return externalFileUris From 466d50e69ee233722e05a3648f5f80b831f78b48 Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Wed, 1 Apr 2026 14:43:40 +0300 Subject: [PATCH 23/28] NFC-150 Fix test from last task --- .../ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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 badbc4fc4..d118f5804 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt @@ -25,9 +25,7 @@ import android.net.Uri import android.util.Base64.URL_SAFE import android.util.Base64.decode import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.test.platform.app.InstrumentationRegistry import ee.ria.DigiDoc.R -import ee.ria.DigiDoc.domain.preferences.DataStore import ee.ria.DigiDoc.webEid.WebEidAuthService import ee.ria.DigiDoc.webEid.WebEidSignService import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -60,8 +58,6 @@ class WebEidViewModelTest { @Mock private lateinit var signService: WebEidSignService - private lateinit var dataStore: DataStore - private lateinit var viewModel: WebEidViewModel private val signingCertBase64Raw = @@ -86,9 +82,7 @@ class WebEidViewModelTest { @Before fun setup() { MockitoAnnotations.openMocks(this) - val context = InstrumentationRegistry.getInstrumentation().targetContext - dataStore = DataStore(context) - viewModel = WebEidViewModel(authService, signService, dataStore) + viewModel = WebEidViewModel(authService, signService) } @Test From ce25caae1e6d39f9b3d0c144b094889030a86f8b Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Wed, 15 Apr 2026 13:22:11 +0300 Subject: [PATCH 24/28] NFC-150 Update text --- .../ee/ria/DigiDoc/viewmodel/NFCViewModel.kt | 20 ++----------------- app/src/main/res/values-et/strings.xml | 3 +-- app/src/main/res/values/strings.xml | 3 +-- 3 files changed, 4 insertions(+), 22 deletions(-) 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 3395e91c9..eb55093e9 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt @@ -657,9 +657,7 @@ class NFCViewModel if (!expectedCert.contentEquals(signerCert)) { _certMismatch.postValue(true) - throw IllegalStateException( - "Web eID card does not match the card used for authentication", - ) + throw IllegalStateException("Web eID signing certificate mismatch") } } @@ -767,19 +765,10 @@ class NFCViewModel } private fun showWebEidSigningCertificateMismatchError(e: Exception) { - _errorState.postValue(Triple(R.string.signature_update_nfc_wrong_certificate, null, null)) - errorLog( - logTag, - "Web eID signing failed - signing certificate does not match previously used certificate", - e, - ) - } - - private fun showWebEidAuthenticationCardMismatchError(e: Exception) { _errorState.postValue(Triple(R.string.web_eid_signing_card_mismatch, null, null)) errorLog( logTag, - "Web eID signing failed - selected ID card does not match the card used for authentication", + "Web eID signing failed - selected ID card does not match the required person", e, ) } @@ -874,11 +863,6 @@ class NFCViewModel true } - message.contains("Web eID card does not match the card used for authentication") -> { - showWebEidAuthenticationCardMismatchError(ex) - true - } - else -> false }.also { errorLog(logTag, "Exception: ${ex.message}", ex) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 87b246aba..6964c99cd 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -290,7 +290,6 @@ https://www.id.ee/artikkel/id-kaardi-pin-ja-puk-koodide-muutmine/ Sertifikaat on kehtetu Sertifikaadi staatus on teadmata - Valitud ID-kaart ei vasta varem kasutatud sertifikaadile. Palun kasuta sama ID-kaarti, millega autentisid. ID-kaardi Mobiil-ID Smart-ID @@ -684,5 +683,5 @@ Päringu viga Vigane autentimispäring Kaarti ei tuvastatud. Palun alusta uuesti. - Valitud ID-kaart ei ühti autentimisel kasutatud kaardiga. Palun kasuta sama ID-kaarti. + Valitud ID-kaart ei vasta nõutud isikule. Palun kasuta õiget ID-kaarti. \ 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 d106a86eb..63e552801 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -290,7 +290,6 @@ https://www.id.ee/en/article/changing-id-card-pin-codes-and-puk-code/ Certificate status revoked Certificate status unknown - The selected ID card does not match the previously used certificate. Please use the same ID card you authenticated with. ID-card\'s Mobile-ID Smart-ID @@ -684,5 +683,5 @@ Request error Invalid authentication request Card not detected. Please start again. - The selected ID card does not match the card used for authentication. Please use the same ID card. + The selected ID card does not match the required person. Please use the correct ID card. \ No newline at end of file From 0bb4e8e383518b0a4f2d354a9f49522fca94bbb7 Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Wed, 15 Apr 2026 13:28:38 +0300 Subject: [PATCH 25/28] NFC-150 Update text --- app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 eb55093e9..2169eb449 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt @@ -768,7 +768,7 @@ class NFCViewModel _errorState.postValue(Triple(R.string.web_eid_signing_card_mismatch, null, null)) errorLog( logTag, - "Web eID signing failed - selected ID card does not match the required person", + "Web eID signing failed - signing certificate does not match previously used certificate", e, ) } From 9a0997dce7f4ddb86181b7f4af106b90ae9e31c3 Mon Sep 17 00:00:00 2001 From: Mart Aarma Date: Fri, 10 Apr 2026 11:30:02 +0300 Subject: [PATCH 26/28] NFC-155 Change app link host riadigidoc.ee -> id.eesti.ee --- app/build.gradle.kts | 2 ++ .../ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt | 25 +++++++++++++------ app/src/debug/AndroidManifest.xml | 2 +- .../ee/ria/DigiDoc/utils/WebEidUriUtil.kt | 4 +-- app/src/release/AndroidManifest.xml | 2 +- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f57d6b401..1ccbe8e5d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -103,6 +103,7 @@ android { signingConfig = signingConfigs.getByName("debug") matchingFallbacks.add("debug") manifestPlaceholders["usesCleartextTraffic"] = "true" + buildConfigField("String", "APP_LINKS_HOST", "\"id-test.eesti.ee\"") isMinifyEnabled = false isShrinkResources = false proguardFiles( @@ -121,6 +122,7 @@ android { isMinifyEnabled = true isShrinkResources = true manifestPlaceholders["usesCleartextTraffic"] = "false" + buildConfigField("String", "APP_LINKS_HOST", "\"id.eesti.ee\"") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt index 5211b2afe..c43aff1c0 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt @@ -46,22 +46,22 @@ class WebEidUriUtilTest { @Test fun isWebEidUri_appLinks_auth() { - assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("https://riadigidoc.ee/auth"))) + assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("https://id-test.eesti.ee/auth"))) } @Test fun isWebEidUri_appLinks_cert() { - assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("https://riadigidoc.ee/cert"))) + assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("https://id-test.eesti.ee/cert"))) } @Test fun isWebEidUri_appLinks_sign() { - assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("https://riadigidoc.ee/sign"))) + assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("https://id-test.eesti.ee/sign"))) } @Test fun isWebEidUri_appLinks_unknownOperation() { - assertFalse(WebEidUriUtil.isWebEidUri(Uri.parse("https://riadigidoc.ee/unknown"))) + assertFalse(WebEidUriUtil.isWebEidUri(Uri.parse("https://id-test.eesti.ee/unknown"))) } @Test @@ -101,17 +101,26 @@ class WebEidUriUtilTest { @Test fun getOperation_appLinks_auth() { - assertEquals(WebEidOperation.AUTH, WebEidUriUtil.getOperation(Uri.parse("https://riadigidoc.ee/auth#dGVzdA"))) + assertEquals( + WebEidOperation.AUTH, + WebEidUriUtil.getOperation(Uri.parse("https://id-test.eesti.ee/auth#dGVzdA")), + ) } @Test fun getOperation_appLinks_cert() { - assertEquals(WebEidOperation.CERT, WebEidUriUtil.getOperation(Uri.parse("https://riadigidoc.ee/cert#dGVzdA"))) + assertEquals( + WebEidOperation.CERT, + WebEidUriUtil.getOperation(Uri.parse("https://id-test.eesti.ee/cert#dGVzdA")), + ) } @Test fun getOperation_appLinks_sign() { - assertEquals(WebEidOperation.SIGN, WebEidUriUtil.getOperation(Uri.parse("https://riadigidoc.ee/sign#dGVzdA"))) + assertEquals( + WebEidOperation.SIGN, + WebEidUriUtil.getOperation(Uri.parse("https://id-test.eesti.ee/sign#dGVzdA")), + ) } @Test @@ -121,7 +130,7 @@ class WebEidUriUtilTest { @Test fun getOperation_appLinks_unknownOperation_returnsNull() { - assertNull(WebEidUriUtil.getOperation(Uri.parse("https://riadigidoc.ee/unknown"))) + assertNull(WebEidUriUtil.getOperation(Uri.parse("https://id-test.eesti.ee/unknown"))) } @Test diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index 6973349d4..2eca79afb 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -19,7 +19,7 @@ - + diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt b/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt index b1bd4f362..847bed303 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt @@ -22,6 +22,7 @@ package ee.ria.DigiDoc.utils import android.net.Uri +import ee.ria.DigiDoc.BuildConfig enum class WebEidOperation( val operation: String, @@ -38,7 +39,6 @@ enum class WebEidOperation( object WebEidUriUtil { private const val CUSTOM_SCHEME = "web-eid-mobile" - private const val APP_LINKS_HOST = "riadigidoc.ee" fun isWebEidUri(uri: Uri): Boolean = getOperation(uri) != null @@ -46,7 +46,7 @@ object WebEidUriUtil { val operation = when { uri.scheme == CUSTOM_SCHEME -> uri.host - uri.scheme == "https" && uri.host == APP_LINKS_HOST -> uri.pathSegments.firstOrNull() + uri.scheme == "https" && uri.host == BuildConfig.APP_LINKS_HOST -> uri.pathSegments.firstOrNull() else -> null } return operation?.let { WebEidOperation.fromOperation(it) } diff --git a/app/src/release/AndroidManifest.xml b/app/src/release/AndroidManifest.xml index 087af8012..f77f2262f 100644 --- a/app/src/release/AndroidManifest.xml +++ b/app/src/release/AndroidManifest.xml @@ -11,7 +11,7 @@ - + From 4a7a42a546d9384c1e9ef16e06e5d275dd65dd56 Mon Sep 17 00:00:00 2001 From: Sander Kondratjev Date: Tue, 28 Apr 2026 13:56:59 +0300 Subject: [PATCH 27/28] NFC-157 Make signing certificates optional for authentication --- .../DigiDoc/viewmodel/WebEidViewModelTest.kt | 37 +++++++++++++++++++ .../DigiDoc/ui/component/signing/NFCView.kt | 6 ++- .../ee/ria/DigiDoc/viewmodel/NFCViewModel.kt | 8 ++-- .../ria/DigiDoc/viewmodel/WebEidViewModel.kt | 2 +- .../domain/service/IdCardServiceImplTest.kt | 28 ++++++++++++++ .../DigiDoc/domain/service/IdCardService.kt | 2 +- .../domain/service/IdCardServiceImpl.kt | 2 +- 7 files changed, 76 insertions(+), 9 deletions(-) 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 d118f5804..6af3d4547 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt @@ -276,6 +276,43 @@ class WebEidViewModelTest { } } + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun webEidViewModel_handleWebEidAuthResult_buildsAuthTokenWhenSigningCertIsMissing() { + runTest(UnconfinedTestDispatcher()) { + val cert = byteArrayOf(1, 2, 3) + val signingCert: ByteArray? = null + val signature = byteArrayOf(4, 5, 6) + val uri = + Uri.parse( + "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luVXJpIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZXNwb25zZSIsImdldFNpZ25pbmdDZXJ0aWZpY2F0ZSI6dHJ1ZX0", + ) + + 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("authToken") + + assertEquals("web-eid:1.0", authToken.getString("format")) + } + } + @Test fun webEidViewModel_handleCertificate_parsesCertificateUriAndSetsStateFlow() { runTest { 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 b7283e507..c416b8ba4 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 @@ -362,8 +362,10 @@ 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) + signingCert?.let { + val encodedCert = Base64.getEncoder().encodeToString(it) + sharedSettingsViewModel.dataStore.setSigningCertificate(encodedCert) + } 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 2169eb449..66ac28b3f 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt @@ -101,15 +101,15 @@ 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 _webEidSignResult = MutableLiveData?>() val webEidSignResult: LiveData?> = _webEidSignResult private val _webEidCertificateResult = MutableLiveData() val webEidCertificateResult: LiveData = _webEidCertificateResult private val timeoutHandler = Handler(getMainLooper()) private var timeoutRunnable: Runnable? = null - private var pendingWebEidAuthResult: Triple? = null + private var pendingWebEidAuthResult: Triple? = null private val _certMismatch = MutableLiveData(false) val certMismatch: LiveData = _certMismatch @@ -903,7 +903,7 @@ class NFCViewModel private fun handlePin2NotChanged( pin1Code: ByteArray, authCert: ByteArray, - signingCert: ByteArray, + signingCert: ByteArray?, signatureArray: ByteArray, ) { pin1Code.clearSensitive() 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 edc6ed3b8..8f55ac6d1 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt @@ -108,7 +108,7 @@ class WebEidViewModel suspend fun handleWebEidAuthResult( authCert: ByteArray, - signingCert: ByteArray, + signingCert: ByteArray?, signature: ByteArray, ) { val loginUri = authRequest.value?.loginUri!! diff --git a/id-card-lib/src/androidTest/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImplTest.kt b/id-card-lib/src/androidTest/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImplTest.kt index a31a51072..0579aab34 100644 --- a/id-card-lib/src/androidTest/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImplTest.kt +++ b/id-card-lib/src/androidTest/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImplTest.kt @@ -331,4 +331,32 @@ class IdCardServiceImplTest { idCardService.unblockAndEditPin(token, codeType, currentPuk, newPin) } } + + @Test + fun idCardService_authenticate_successWhenSigningCertificateIsNull() { + val certPemString = (Constant.PEM_BEGIN_CERT + "\n" + cert + "\n" + Constant.PEM_END_CERT).trimIndent() + val testData = + certPemString.let { + CertificateUtil.x509Certificate(it).encoded + } + + val pin1 = byteArrayOf(1, 2, 3, 4) + val signature = byteArrayOf(5, 6, 7, 8) + + Mockito.`when`(token.certificate(CertificateType.AUTHENTICATION)).thenReturn(testData) + Mockito.`when`(token.certificate(CertificateType.SIGNING)).thenReturn(null) + Mockito.`when`(token.authenticate(eq(pin1), any())).thenReturn(signature) + + val result = + idCardService.authenticate( + token = token, + pin1 = pin1, + origin = "https://example.com", + challenge = "challenge", + ) + + Assert.assertArrayEquals(testData, result.first) + Assert.assertNull(result.second) + Assert.assertArrayEquals(signature, result.third) + } } 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 23b1af900..1f2c20b82 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 @@ -53,7 +53,7 @@ interface IdCardService { pin1: ByteArray, origin: String, challenge: String, - ): Triple + ): Triple @Throws(Exception::class) fun sign( 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 8433f5328..9ef760a73 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 @@ -98,7 +98,7 @@ class IdCardServiceImpl pin1: ByteArray, origin: String, challenge: String, - ): Triple { + ): Triple { val authCert = token.certificate(CertificateType.AUTHENTICATION) val signingCert = token.certificate(CertificateType.SIGNING) From 547ac080fb5720fd4407568fd8418df1db603cca Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Tue, 16 Jun 2026 11:01:19 +0300 Subject: [PATCH 28/28] Code fixes --- .../ee/ria/DigiDoc/viewmodel/NFCViewModel.kt | 2 +- .../domain/service/IdCardServiceImplTest.kt | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) 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 66ac28b3f..4e91a8b92 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt @@ -506,7 +506,7 @@ class NFCViewModel val card = TokenWithPace.create(nfcReader) card.tunnel(canNumber) - val pin2Changed = card.pinChangedFlag() == 1 + val pin2Changed = card.pinChangedFlag(CodeType.PIN2) == 1 val (authCert, signingCert, signatureArray) = idCardService.authenticate( diff --git a/id-card-lib/src/androidTest/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImplTest.kt b/id-card-lib/src/androidTest/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImplTest.kt index 0579aab34..0fbd3f311 100644 --- a/id-card-lib/src/androidTest/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImplTest.kt +++ b/id-card-lib/src/androidTest/kotlin/ee/ria/DigiDoc/domain/service/IdCardServiceImplTest.kt @@ -24,6 +24,7 @@ package ee.ria.DigiDoc.domain.service import android.content.Context import androidx.test.platform.app.InstrumentationRegistry import com.google.gson.Gson +import ee.ria.DigiDoc.common.Constant import ee.ria.DigiDoc.common.certificate.CertificateService import ee.ria.DigiDoc.common.model.EIDType import ee.ria.DigiDoc.common.model.ExtendedCertificate @@ -42,6 +43,7 @@ import ee.ria.DigiDoc.idcard.Token import ee.ria.DigiDoc.libdigidoclib.init.Initialization import ee.ria.DigiDoc.libdigidoclib.init.LibdigidocLibraryLoader import ee.ria.DigiDoc.smartcardreader.SmartCardReaderException +import ee.ria.DigiDoc.utilsLib.signing.CertificateUtil import kotlinx.coroutines.runBlocking import org.bouncycastle.asn1.x509.ExtendedKeyUsage import org.bouncycastle.asn1.x509.KeyUsage @@ -57,6 +59,7 @@ import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doNothing +import org.mockito.kotlin.eq @RunWith(MockitoJUnitRunner::class) class IdCardServiceImplTest { @@ -65,6 +68,42 @@ class IdCardServiceImplTest { private val token = Mockito.mock(Token::class.java) + private val cert = + "MIIGGzCCBQOgAwIBAgIQDmRuJmtGcd4j6HiqQzw0hzANBgkqhkiG9w0BAQsFADBZ\n" + + "MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMTMwMQYDVQQDEypE\n" + + "aWdpQ2VydCBHbG9iYWwgRzIgVExTIFJTQSBTSEEyNTYgMjAyMCBDQTEwHhcNMjMw\n" + + "ODMxMDAwMDAwWhcNMjQwOTMwMjM1OTU5WjBXMQswCQYDVQQGEwJFRTEQMA4GA1UE\n" + + "BxMHVGFsbGlubjEhMB8GA1UECgwYUmlpZ2kgSW5mb3PDvHN0ZWVtaSBBbWV0MRMw\n" + + "EQYDVQQDDAoqLmVlc3RpLmVlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEIbJjZD5M\n" + + "fjpd2P6FDuNclnN0hp/1ANWr05wK6/Nl/BIR/rr702rV2Y17uoBukHA4TvChN3P8\n" + + "YMHloK+TcXmjy+CQpRQtYUvm+meobN0NWSdKGASqtX9C4E6RYQKcs2mXo4IDjTCC\n" + + "A4kwHwYDVR0jBBgwFoAUdIWAwGbH3zfez70pN6oDHb7tzRcwHQYDVR0OBBYEFB/b\n" + + "eFjCUl4v17Qy2g1AgqvJwOaHMB8GA1UdEQQYMBaCCiouZWVzdGkuZWWCCGVlc3Rp\n" + + "LmVlMA4GA1UdDwEB/wQEAwIHgDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUH\n" + + "AwIwgZ8GA1UdHwSBlzCBlDBIoEagRIZCaHR0cDovL2NybDMuZGlnaWNlcnQuY29t\n" + + "L0RpZ2lDZXJ0R2xvYmFsRzJUTFNSU0FTSEEyNTYyMDIwQ0ExLTEuY3JsMEigRqBE\n" + + "hkJodHRwOi8vY3JsNC5kaWdpY2VydC5jb20vRGlnaUNlcnRHbG9iYWxHMlRMU1JT\n" + + "QVNIQTI1NjIwMjBDQTEtMS5jcmwwPgYDVR0gBDcwNTAzBgZngQwBAgIwKTAnBggr\n" + + "BgEFBQcCARYbaHR0cDovL3d3dy5kaWdpY2VydC5jb20vQ1BTMIGHBggrBgEFBQcB\n" + + "AQR7MHkwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBRBggr\n" + + "BgEFBQcwAoZFaHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0R2xv\n" + + "YmFsRzJUTFNSU0FTSEEyNTYyMDIwQ0ExLTEuY3J0MAkGA1UdEwQCMAAwggF+Bgor\n" + + "BgEEAdZ5AgQCBIIBbgSCAWoBaAB2AO7N0GTV2xrOxVy3nbTNE6Iyh0Z8vOzew1FI\n" + + "WUZxH7WbAAABikpp0YgAAAQDAEcwRQIhAOuRDRbH2F/4xj+4psS1uN7agonxJpSX\n" + + "7l1m9CpJX/gkAiBFDEGuoEijUPdQ3M5ibV6YsXW4648t7mkR0W56XiNZYAB2AEiw\n" + + "42vapkc0D+VqAvqdMOscUgHLVt0sgdm7v6s52IRzAAABikppz/EAAAQDAEcwRQIh\n" + + "ALEE3j07957wr2WLsozkjmXPepYu5p/iTZx65kYtO47aAiAKS1VoZ0mMssYUcwmY\n" + + "s5FB79zNnVW5rXD4heRSFvpT9AB2ANq2v2s/tbYin5vCu1xr6HCRcWy7UYSFNL2k\n" + + "PTBI1/urAAABikpp0BkAAAQDAEcwRQIgOuq96euO9Aade5R6HfpNGEciZUfbgW+o\n" + + "MmstOl3YqAUCIQDsafdu8nlmkNrN7h8uuqVXBqyv9J/u0WU80dAxPCGBiTANBgkq\n" + + "hkiG9w0BAQsFAAOCAQEAaCYTTF6Sps1YXdD6kKiYkslaxzql6D/F9Imog4pJXRZH\n" + + "7ye5kHuGOPFfnUQEqOziOspZCusX2Bz4DK4I/oc4cQnMxQHIDdF4H/GS/2aBbU/R\n" + + "4Ustgxkd4PCdxOn6lVux8aFDCRKrNBrUF1/970StNuh8tatyYvDEenwC0F3l2hRB\n" + + "Q3FYZMYkR9H8FM314a/sGST6lQiKJq2hrziMWilOwKxc88MBz9H9CYrEsCMI65iH\n" + + "vWA8njofxSYdM5NHhxTxhHKn6qZxHSjiQvF9edUYTQ4wwTczmHuqYY2qxYh6WUzR\n" + + "yaKSeng9fe8ZVZdjOwmCa9ZdgjQYMZbDezMt+oRp2Q==" + + companion object { private var context: Context = InstrumentationRegistry.getInstrumentation().targetContext