diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a9038cdea..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",
@@ -214,6 +216,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/domain/preferences/DataStoreTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt
index 84f048b2f..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
@@ -614,4 +614,81 @@ class DataStoreTest {
assertFalse(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/utils/WebEidUriUtilTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt
new file mode 100644
index 000000000..c43aff1c0
--- /dev/null
+++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtilTest.kt
@@ -0,0 +1,140 @@
+/*
+ * 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://id-test.eesti.ee/auth")))
+ }
+
+ @Test
+ fun isWebEidUri_appLinks_cert() {
+ assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("https://id-test.eesti.ee/cert")))
+ }
+
+ @Test
+ fun isWebEidUri_appLinks_sign() {
+ assertTrue(WebEidUriUtil.isWebEidUri(Uri.parse("https://id-test.eesti.ee/sign")))
+ }
+
+ @Test
+ fun isWebEidUri_appLinks_unknownOperation() {
+ assertFalse(WebEidUriUtil.isWebEidUri(Uri.parse("https://id-test.eesti.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://id-test.eesti.ee/auth#dGVzdA")),
+ )
+ }
+
+ @Test
+ fun getOperation_appLinks_cert() {
+ 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://id-test.eesti.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://id-test.eesti.ee/unknown")))
+ }
+
+ @Test
+ fun getOperation_unrelatedUri_returnsNull() {
+ assertNull(WebEidUriUtil.getOperation(Uri.parse("https://example.com/auth")))
+ }
+}
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..6af3d4547
--- /dev/null
+++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt
@@ -0,0 +1,670 @@
+/*
+ * 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.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
+
+@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() {
+ MockitoAnnotations.openMocks(this)
+ viewModel = WebEidViewModel(authService, signService)
+ }
+
+ @Test
+ fun webEidViewModel_handleAuth_parsesAuthUriAndSetsStateFlow() {
+ runTest {
+ val uri =
+ Uri.parse(
+ "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luVXJpIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZXNwb25zZSIsImdldFNpZ25pbmdDZXJ0aWZpY2F0ZSI6dHJ1ZX0",
+ )
+ 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#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwibG9naW5VcmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwiZ2V0U2lnbmluZ0NlcnRpZmljYXRlIjp0cnVlfQ",
+ )
+ webEidViewModel_handleAuth_emitErrorResponseEventWhenInvalidChallenge(uri)
+ }
+
+ @Test
+ fun webEidViewModel_handleAuth_emitErrorResponseEventWhenChallengeMaxLength() {
+ val uri =
+ Uri.parse(
+ "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLCJsb2dpblVyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJnZXRTaWduaW5nQ2VydGlmaWNhdGUiOnRydWV9",
+ )
+ webEidViewModel_handleAuth_emitErrorResponseEventWhenInvalidChallenge(uri)
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private fun webEidViewModel_handleAuth_emitErrorResponseEventWhenInvalidChallenge(uri: Uri) {
+ runTest(UnconfinedTestDispatcher()) {
+ val deferred =
+ async {
+ viewModel.relyingPartyResponseEvents.first()
+ }
+
+ viewModel.handleAuth(uri)
+
+ val emittedUri = deferred.await()
+ assert(emittedUri.toString().startsWith("https://example.com/response#"))
+ assert(emittedUri.fragment != null)
+ val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE))
+ val jsonPayload = JSONObject(decodedPayload)
+ assertEquals("ERR_WEBEID_MOBILE_INVALID_REQUEST", jsonPayload.getString("code"))
+ assertEquals("Invalid challenge length", jsonPayload.getString("message"))
+ }
+ }
+
+ @Test
+ @OptIn(ExperimentalCoroutinesApi::class)
+ fun webEidViewModel_handleAuth_emitErrorResponseEventWhenOriginMaxLength() {
+ runTest(UnconfinedTestDispatcher()) {
+ val uri =
+ Uri.parse(
+ "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luVXJpIjoiaHR0cHM6Ly9leGFtcGxlLnh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4LmNvbS9yZXNwb25zZSIsImdldFNpZ25pbmdDZXJ0aWZpY2F0ZSI6dHJ1ZX0=",
+ )
+ val deferred =
+ async {
+ viewModel.relyingPartyResponseEvents.first()
+ }
+
+ viewModel.handleAuth(uri)
+
+ val emittedUri = deferred.await()
+ assert(
+ emittedUri.toString().startsWith(
+ "https://example.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.com/response#",
+ ),
+ )
+ assert(emittedUri.fragment != null)
+ val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE))
+ val jsonPayload = JSONObject(decodedPayload)
+ assertEquals("ERR_WEBEID_MOBILE_INVALID_REQUEST", jsonPayload.getString("code"))
+ assertEquals("Invalid origin length", jsonPayload.getString("message"))
+ }
+ }
+
+ @Test
+ @OptIn(ExperimentalCoroutinesApi::class)
+ fun webEidViewModel_handleAuth_emitDialogErrorWhenGenericException() {
+ runTest(UnconfinedTestDispatcher()) {
+ val uri = Uri.parse("web-eid-mobile://auth#{}")
+ viewModel.handleAuth(uri)
+ assertEquals(R.string.web_eid_invalid_auth_request_error, viewModel.dialogError.value)
+ }
+ }
+
+ @Test
+ @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#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luVXJpIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZXNwb25zZSIsImdldFNpZ25pbmdDZXJ0aWZpY2F0ZSI6dHJ1ZX0",
+ )
+ whenever(authService.buildAuthToken(cert, signingCert, signature))
+ .thenReturn(JSONObject().put("format", "web-eid:1.0"))
+ val deferred =
+ async {
+ viewModel.relyingPartyResponseEvents.first()
+ }
+ viewModel.handleAuth(uri)
+ viewModel.handleWebEidAuthResult(cert, signingCert, signature)
+
+ verify(authService).buildAuthToken(cert, signingCert, signature)
+ val emittedUri = deferred.await()
+ assert(emittedUri.toString().startsWith("https://example.com/response#"))
+ assert(emittedUri.fragment != null)
+ val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE))
+ val jsonPayload = JSONObject(decodedPayload)
+ val authToken = jsonPayload.getJSONObject("authToken")
+ assertEquals("web-eid:1.0", authToken.getString("format"))
+ }
+ }
+
+ @Test
+ @OptIn(ExperimentalCoroutinesApi::class)
+ fun webEidViewModel_handleWebEidAuthResult_buildsAuthTokenWithoutSigningCert() {
+ runTest(UnconfinedTestDispatcher()) {
+ val cert = byteArrayOf(1, 2, 3)
+ val signingCert = byteArrayOf(9, 9, 9)
+ val signature = byteArrayOf(4, 5, 6)
+ val uri =
+ Uri.parse(
+ "web-eid-mobile://auth#ewogICJjaGFsbGVuZ2UiOiAidGVzdC1jaGFsbGVuZ2UtMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLAogICJsb2dpblVyaSI6ICJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwKICAiZ2V0U2lnbmluZ0NlcnRpZmljYXRlIjogZmFsc2UKfQ==",
+ )
+ whenever(authService.buildAuthToken(cert, null, signature))
+ .thenReturn(JSONObject().put("format", "web-eid:1.0"))
+ val deferred =
+ async {
+ viewModel.relyingPartyResponseEvents.first()
+ }
+ viewModel.handleAuth(uri)
+ println(viewModel.authRequest.value)
+ 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
+ @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#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luVXJpIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZXNwb25zZSIsImdldFNpZ25pbmdDZXJ0aWZpY2F0ZSI6dHJ1ZX0",
+ )
+ 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
+ @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 {
+ val uri =
+ Uri.parse(
+ "web-eid-mobile://cert#eyJyZXNwb25zZVVyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UifQ",
+ )
+ 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#" +
+ "eyJyZXNwb25zZVVyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJzaWduQ2VydGlmaWNhdGUiOiJzaWduZXJzZXJ0IiwiaGFzaCI6IiJ9",
+ )
+
+ val deferred =
+ async {
+ viewModel.relyingPartyResponseEvents.first()
+ }
+
+ viewModel.handleSign(uri)
+
+ val emittedUri = deferred.await()
+ assert(emittedUri.toString().startsWith("https://example.com/response#"))
+ assert(emittedUri.fragment != null)
+ val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE))
+ val jsonPayload = JSONObject(decodedPayload)
+ assertEquals("ERR_WEBEID_MOBILE_INVALID_REQUEST", jsonPayload.getString("code"))
+ assertEquals(
+ "Invalid signing request: missing hash or hashFunction",
+ 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#eyJyZXNwb25zZVVyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJzaWduQ2VydGlmaWNhdGUiOiJzaWduaW5nX2NlcnRpZmljYXRlIiwiaGFzaCI6Imhhc2giLCJoYXNoRnVuY3Rpb24iOiJoYXNoX2Z1bmN0aW9uIn0",
+ )
+ 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#eyJyZXNwb25zZVVyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJzaWduQ2VydGlmaWNhdGUiOiJzaWduaW5nX2NlcnRpZmljYXRlIiwiaGFzaCI6Imhhc2giLCJoYXNoRnVuY3Rpb24iOiJoYXNoX2Z1bmN0aW9uIn0",
+ )
+ 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"))
+ }
+ }
+
+ @Test
+ @OptIn(ExperimentalCoroutinesApi::class)
+ fun webEidViewModel_handleUserCancelled_authFlow_emitsCancelError() {
+ runTest(UnconfinedTestDispatcher()) {
+ val uri =
+ Uri.parse(
+ "web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luVXJpIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZXNwb25zZSIsImdldFNpZ25pbmdDZXJ0aWZpY2F0ZSI6dHJ1ZX0",
+ )
+
+ 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#eyJyZXNwb25zZVVyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UifQ",
+ )
+
+ 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"
+ val responseUri = "https://rp.example.com/sign/response"
+ val sb = StringBuilder()
+ sb.append("{\"responseUri\":\"$responseUri\"")
+ sb.append(",\"hash\":\"$hash\"")
+ sb.append(",\"hashFunction\":\"$hashFunction\"")
+ if (signingCertificate != null) {
+ sb.append(",\"signingCertificate\":\"$signingCertificate\"")
+ }
+ sb.append("}")
+ val encoded = Base64.getEncoder().encodeToString(sb.toString().toByteArray())
+ return "web-eid-mobile://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/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
new file mode 100644
index 000000000..2eca79afb
--- /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 841ac54a7..4e0cbb26f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -148,6 +148,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 b3adfbe8a..78ea19658 100644
--- a/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt
+++ b/app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt
@@ -22,10 +22,14 @@
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.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
@@ -42,6 +46,7 @@ import ee.ria.DigiDoc.init.LibrarySetup
import ee.ria.DigiDoc.manager.ActivityManager
import ee.ria.DigiDoc.root.RootChecker
import ee.ria.DigiDoc.ui.theme.RIADigiDocTheme
+import ee.ria.DigiDoc.utils.WebEidUriUtil
import ee.ria.DigiDoc.utils.locale.LocaleUtil
import ee.ria.DigiDoc.utils.locale.LocaleUtilImpl
import ee.ria.DigiDoc.utils.secure.SecureUtil
@@ -120,8 +125,11 @@ class MainActivity :
val componentClassName = this.javaClass.name
- val externalFileUris = getExternalFileUris(intent)
val locale = dataStore.getLocale() ?: getLocale("en")
+ val webEidUri = intent.data?.takeIf { WebEidUriUtil.isWebEidUri(it) }
+ val browserPackage = if (webEidUri != null) resolveBrowserPackage(intent) else null
+ val externalFileUris = getExternalFileUris(intent)
+
localeUtil.updateLocale(applicationContext, locale)
// Observe if activity needs to be recreated for changes to take effect (eg. Settings)
@@ -163,7 +171,11 @@ class MainActivity :
setContent {
RIADigiDocTheme(darkTheme = useDarkMode) {
- RIADigiDocAppScreen(externalFileUris)
+ RIADigiDocAppScreen(
+ externalFileUris = externalFileUris,
+ webEidUri = webEidUri,
+ browserPackage = browserPackage,
+ )
}
}
}
@@ -190,4 +202,20 @@ 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 81f356ac6..bdeb3cfcc 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
@@ -73,7 +74,11 @@ 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,
+ browserPackage: String? = null,
+) {
val navController = rememberNavController()
val sharedMenuViewModel: SharedMenuViewModel = hiltViewModel()
val sharedContainerViewModel: SharedContainerViewModel = hiltViewModel()
@@ -85,10 +90,12 @@ fun RIADigiDocAppScreen(externalFileUris: List) {
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(
navController = navController,
@@ -357,6 +364,14 @@ fun RIADigiDocAppScreen(externalFileUris: List) {
sharedMyEidViewModel = sharedMyEidViewModel,
)
}
+ composable(route = Route.WebEidScreen.route) {
+ WebEidFragment(
+ modifier = Modifier.safeDrawingPadding(),
+ navController = navController,
+ webEidUri = webEidUri,
+ browserPackage = browserPackage,
+ )
+ }
}
}
@@ -365,6 +380,9 @@ fun RIADigiDocAppScreen(externalFileUris: List) {
@Composable
fun RIADigiDocAppScreenPreview() {
RIADigiDocTheme {
- RIADigiDocAppScreen(listOf())
+ 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..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
@@ -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,76 @@ 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 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 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
new file mode 100644
index 000000000..7f4993035
--- /dev/null
+++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/WebEidFragment.kt
@@ -0,0 +1,122 @@
+/*
+ * 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
+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
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTagsAsResourceId
+import androidx.compose.ui.tooling.preview.Preview
+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.utils.WebEidOperation
+import ee.ria.DigiDoc.utils.WebEidUriUtil
+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
+fun WebEidFragment(
+ modifier: Modifier = Modifier,
+ navController: NavHostController,
+ webEidUri: Uri?,
+ browserPackage: String? = null,
+ 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)
+ if (!browserPackage.isNullOrEmpty()) {
+ setPackage(browserPackage)
+ }
+ }
+ activity.startActivity(browserIntent)
+ activity.finishAndRemoveTask()
+ }
+ }
+
+ LaunchedEffect(webEidUri) {
+ webEidUri?.let {
+ when (WebEidUriUtil.getOperation(it)) {
+ WebEidOperation.AUTH -> viewModel.handleAuth(it)
+ WebEidOperation.CERT -> viewModel.handleCertificate(it)
+ WebEidOperation.SIGN -> viewModel.handleSign(it)
+ null -> viewModel.handleUnknown(it)
+ }
+ }
+ }
+
+ Surface(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .semantics { testTagsAsResourceId = true }
+ .testTag("webEidFragment"),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ WebEidScreen(
+ modifier = modifier,
+ navController = navController,
+ viewModel = viewModel,
+ sharedSettingsViewModel = sharedSettingsViewModel,
+ sharedContainerViewModel = sharedContainerViewModel,
+ sharedMenuViewModel = sharedMenuViewModel,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun WebEidFragmentPreview() {
+ RIADigiDocTheme {
+ WebEidFragment(
+ navController = rememberNavController(),
+ webEidUri = null,
+ )
+ }
+}
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..ce6eae4ef
--- /dev/null
+++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/WebEidScreen.kt
@@ -0,0 +1,643 @@
+/*
+ * 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.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,
+ viewModel: WebEidViewModel = hiltViewModel(),
+ sharedSettingsViewModel: SharedSettingsViewModel = hiltViewModel(),
+ sharedContainerViewModel: SharedContainerViewModel = hiltViewModel(),
+ sharedMenuViewModel: SharedMenuViewModel,
+) {
+ 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 scope = rememberCoroutineScope()
+ val messages by SnackBarManager.messages.collectAsState(emptyList())
+ val dialogError by viewModel.dialogError.collectAsState()
+ var rememberMe by rememberSaveable {
+ mutableStateOf(sharedSettingsViewModel.dataStore.getWebEidRememberMe())
+ }
+ val hasStoredCanNumber =
+ sharedSettingsViewModel.dataStore.getCanNumber().isNotEmpty() ||
+ sharedSettingsViewModel.dataStore.getTemporaryCanNumber().isNotEmpty()
+
+ LaunchedEffect(messages) {
+ messages.forEach { message ->
+ scope.launch {
+ snackBarHostState.showSnackbar(message)
+ }
+ SnackBarManager.removeMessage(message)
+ }
+ }
+
+ LaunchedEffect(authRequest, certificateRequest) {
+ if (authRequest != null || certificateRequest != null) {
+ if (!sharedSettingsViewModel.dataStore.isWebEidSessionActive()) {
+ sharedSettingsViewModel.dataStore.clearTemporaryCanNumber()
+ }
+ sharedSettingsViewModel.dataStore.setWebEidSessionActive(true)
+ }
+ }
+
+ 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)
+ }
+ }
+ }
+
+ 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 = { isRememberMeEnabled ->
+ rememberMe = isRememberMeEnabled
+ sharedSettingsViewModel.dataStore.setWebEidRememberMe(isRememberMeEnabled)
+ if (!isRememberMeEnabled) {
+ sharedSettingsViewModel.dataStore.setSigningCertificate("")
+ }
+ },
+ )
+ }
+ } else if (isCertificateFlow || signRequest != null) {
+ if (!isWebEidAuthenticating) {
+ val origin =
+ when {
+ isCertificateFlow -> certificateRequest.origin
+ signRequest != null -> signRequest.origin
+ else -> ""
+ }
+ val signingPersonInfo =
+ signRequest?.personalData?.let {
+ "${it.givenNames} ${it.surname}, ${it.personalCode}"
+ }
+ WebEidSignOrCertificateInfo(
+ origin = origin,
+ isCertificateFlow = isCertificateFlow,
+ signingPersonInfo = signingPersonInfo,
+ )
+ }
+
+ 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 = { isRememberMeEnabled ->
+ rememberMe = isRememberMeEnabled
+ sharedSettingsViewModel.dataStore.setWebEidRememberMe(isRememberMeEnabled)
+ if (!isRememberMeEnabled) {
+ sharedSettingsViewModel.dataStore.setSigningCertificate("")
+ }
+ },
+ )
+ }
+ } else {
+ NFCView(
+ activity = activity,
+ identityAction = IdentityAction.SIGN,
+ rememberMe = rememberMe,
+ isCertificate = false,
+ isSigning = false,
+ isDecrypting = false,
+ isWebEidAuthenticating = isWebEidAuthenticating,
+ isCanNumberReadOnly = hasStoredCanNumber,
+ onError = {
+ isWebEidAuthenticating = false
+ cancelWebEidSignAction()
+ },
+ onSuccess = {
+ sharedSettingsViewModel.dataStore.clearTemporaryCanNumber()
+ sharedSettingsViewModel.dataStore.setWebEidSessionActive(false)
+ if (!rememberMe) sharedSettingsViewModel.dataStore.setSigningCertificate("")
+ 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
+ scope.launch {
+ viewModel.handleUserCancelled()
+ }
+ },
+ 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,
+ signingPersonInfo: String? = null,
+) {
+ 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 =
+ if (isCertificateFlow) {
+ stringResource(R.string.web_eid_details_forwarded)
+ } else {
+ stringResource(R.string.web_eid_details)
+ },
+ style = MaterialTheme.typography.labelMedium,
+ textAlign = TextAlign.Left,
+ )
+
+ Spacer(modifier = Modifier.height(2.dp))
+
+ Text(
+ text =
+ if (!isCertificateFlow && !signingPersonInfo.isNullOrBlank()) {
+ signingPersonInfo
+ } else {
+ stringResource(R.string.web_eid_name_personal_identification_code)
+ },
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Left,
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ 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,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+}
+
+@Composable
+private fun WebEidRememberMe(
+ rememberMe: Boolean,
+ onRememberMeChange: (Boolean) -> Unit,
+) {
+ val rememberMeText = stringResource(R.string.signature_update_remember_me)
+
+ SettingsSwitchItem(
+ checked = rememberMe,
+ onCheckedChange = onRememberMeChange,
+ title = rememberMeText,
+ contentDescription = rememberMeText,
+ testTag = "webEidRememberMeSwitch",
+ )
+
+ if (rememberMe) {
+ Text(
+ text = stringResource(R.string.web_eid_remember_me_message),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun WebEidScreenPreview() {
+ RIADigiDocTheme {
+ WebEidScreen(
+ navController = rememberNavController(),
+ sharedMenuViewModel = hiltViewModel(),
+ sharedSettingsViewModel = hiltViewModel(),
+ sharedContainerViewModel = hiltViewModel(),
+ )
+ }
+}
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..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
@@ -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
@@ -106,6 +107,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 +116,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 +127,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 +139,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,
+ isCanNumberReadOnly: Boolean = false,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -161,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),
),
)
}
@@ -172,10 +198,14 @@ fun NFCView(
val showErrorDialog = rememberSaveable { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val saveFormParams = {
+ val currentCan = canNumber.text
+
if (shouldRememberMe) {
- sharedSettingsViewModel.dataStore.setCanNumber(canNumber.text)
+ sharedSettingsViewModel.dataStore.setCanNumber(currentCan)
+ sharedSettingsViewModel.dataStore.clearTemporaryCanNumber()
} else {
sharedSettingsViewModel.dataStore.setCanNumber("")
+ sharedSettingsViewModel.dataStore.setTemporaryCanNumber(currentCan)
}
}
@@ -211,8 +241,28 @@ 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 ?: ""
+ 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()
+ sharedSettingsViewModel.dataStore.clearTemporaryCanNumber()
+ sharedSettingsViewModel.dataStore.setWebEidSessionActive(false)
if (isSigning || isDecrypting || isAuthenticating) {
onError()
} else {
@@ -220,6 +270,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 {
@@ -299,6 +359,42 @@ fun NFCView(
}
}
+ LaunchedEffect(nfcViewModel.webEidAuthResult) {
+ nfcViewModel.webEidAuthResult.asFlow().collect { result ->
+ result?.let { (authCert, signingCert, signature) ->
+ signingCert?.let {
+ val encodedCert = Base64.getEncoder().encodeToString(it)
+ 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
@@ -337,9 +433,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) {
@@ -359,7 +474,9 @@ fun NFCView(
linkUrl = R.string.sign_blocked_pin2_unchanged_url
}
Box(modifier = modifier.fillMaxSize()) {
- onError()
+ if (!isAuthPin2UnchangedDialog) {
+ onError()
+ }
BasicAlertDialog(
modifier =
modifier
@@ -402,6 +519,7 @@ fun NFCView(
okButtonClick = {
showErrorDialog.value = false
nfcViewModel.resetDialogErrorState()
+ nfcViewModel.continuePendingWebEidAuth()
},
cancelButtonTitle = R.string.cancel_button,
okButtonTitle = R.string.ok_button,
@@ -431,7 +549,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 +595,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 +611,7 @@ fun NFCView(
LaunchedEffect(isValid) {
isValidToSign(isValid)
isValidToDecrypt(isValid)
+ isValidToWebEidAuthenticate(isValid)
}
LaunchedEffect(Unit, rememberMe) {
@@ -535,6 +658,7 @@ fun NFCView(
canNumber = canNumber.text,
roleData = roleDataRequest,
)
+ sharedSettingsViewModel.dataStore.clearTemporaryCanNumber()
}
}
decryptAction {
@@ -547,6 +671,56 @@ fun NFCView(
pin1Code = pinCode.value,
canNumber = canNumber.text,
)
+ sharedSettingsViewModel.dataStore.clearTemporaryCanNumber()
+ }
+ }
+ 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) {
+ val currentStoredCan = sharedSettingsViewModel.dataStore.getCanNumber()
+ val canSkipCertificateRead =
+ shouldRememberMe &&
+ cachedCert.isNotEmpty() &&
+ currentStoredCan.isNotEmpty() &&
+ canNumber.text == currentStoredCan
+ saveFormParams()
+ if (canSkipCertificateRead) {
+ val certBytes = Base64.getDecoder().decode(cachedCert)
+ webEidViewModel.handleWebEidCertificateResult(certBytes)
+ onSuccess()
+ } else {
+ nfcViewModel.performNFCWebEidCertificateWorkRequest(
+ activity = activity,
+ canNumber = canNumber.text,
+ )
+ }
+ } else {
+ nfcViewModel.performNFCWebEidSignWorkRequest(
+ activity = activity,
+ context = context,
+ canNumber = canNumber.text,
+ pin2Code = pinCode.value,
+ responseUri = responseUriString,
+ hash = hashString,
+ requestSigningCert = requestSigningCertificateBase64,
+ )
+ }
}
}
cancelAction {
@@ -557,7 +731,17 @@ fun NFCView(
}
cancelDecryptAction {
nfcViewModel.handleBackButton()
- nfcViewModel.cancelNFCDecryptWorkRequest()
+ nfcViewModel.cancelNfcOperation()
+ }
+
+ cancelWebEidAuthenticateAction {
+ nfcViewModel.handleBackButton()
+ nfcViewModel.cancelNfcOperation()
+ }
+
+ cancelWebEidSignAction {
+ nfcViewModel.handleBackButton()
+ nfcViewModel.cancelNfcOperation()
}
}
}
@@ -606,6 +790,8 @@ fun NFCView(
TextFieldValue(removeInvisibleElement(it.text))
}
},
+ readOnly = isCanNumberReadOnly,
+ enabled = !isCanNumberReadOnly,
singleLine = true,
label = canNumberLabel,
readDigitByDigit = true,
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)
}
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..847bed303
--- /dev/null
+++ b/app/src/main/kotlin/ee/ria/DigiDoc/utils/WebEidUriUtil.kt
@@ -0,0 +1,54 @@
+/*
+ * 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 ee.ria.DigiDoc.BuildConfig
+
+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"
+
+ 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 == BuildConfig.APP_LINKS_HOST -> uri.pathSegments.firstOrNull()
+ else -> null
+ }
+ return operation?.let { WebEidOperation.fromOperation(it) }
+ }
+}
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..4e91a8b92 100644
--- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt
+++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/NFCViewModel.kt
@@ -24,10 +24,11 @@ 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
-import androidx.lifecycle.viewModelScope
import com.google.common.collect.ImmutableMap
import dagger.hilt.android.lifecycle.HiltViewModel
import ee.ria.DigiDoc.R
@@ -54,16 +55,14 @@ 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
-import java.util.Arrays
import java.util.Base64
import javax.inject.Inject
@@ -102,6 +101,17 @@ 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 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
@@ -114,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)
}
@@ -138,6 +152,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() &&
@@ -166,6 +192,7 @@ class NFCViewModel
_signStatus.postValue(null)
_decryptStatus.postValue(null)
_nfcStatus.postValue(null)
+ pendingWebEidAuthResult = null
}
private fun resetNonErrorValues() {
@@ -193,7 +220,7 @@ class NFCViewModel
nfcSmartCardReaderManager.disableNfcReaderMode()
}
- fun cancelNFCDecryptWorkRequest() {
+ fun cancelNfcOperation() {
nfcSmartCardReaderManager.disableNfcReaderMode()
}
@@ -229,9 +256,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 +275,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 +284,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 +347,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 +369,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 +428,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 +479,229 @@ 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()
+ startNFCDetectionTimeout(activity, pin1Code)
+
+ withContext(Main) {
+ _message.postValue(R.string.signature_update_nfc_hold)
+ }
+
+ 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(CodeType.PIN2) == 1
+
+ val (authCert, signingCert, signatureArray) =
+ idCardService.authenticate(
+ token = card,
+ pin1 = pin1Code,
+ origin = origin,
+ challenge = challenge,
+ )
+
+ if (!pin2Changed) {
+ handlePin2NotChanged(pin1Code, authCert, signingCert, signatureArray)
+ return@startDiscovery
+ }
+
+ 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 {
+ stopNFCDetectionTimeout()
+ pin1Code.clearSensitive()
+ nfcSmartCardReaderManager.disableNfcReaderMode()
+ activity.requestedOrientation =
+ ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ }
+ }
+ },
+ )
+ }
+
+ suspend fun performNFCWebEidCertificateWorkRequest(
+ activity: Activity,
+ canNumber: String,
+ ) {
+ activity.requestedOrientation = activity.resources.configuration.orientation
+ resetValues()
+ startNFCDetectionTimeout(activity)
+
+ withContext(Main) {
+ _message.postValue(R.string.signature_update_nfc_hold)
+ }
+
+ 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 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 {
+ stopNFCDetectionTimeout()
+ nfcSmartCardReaderManager.disableNfcReaderMode()
+ activity.requestedOrientation =
+ ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ }
+ }
+ },
+ )
+ }
+
+ suspend fun performNFCWebEidSignWorkRequest(
+ activity: Activity,
+ context: Context,
+ canNumber: String,
+ pin2Code: ByteArray?,
+ responseUri: String,
+ hash: 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)
+ }
+
+ 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)
+ 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 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 {
+ stopNFCDetectionTimeout()
+ pin2Code.clearSensitive()
+ nfcSmartCardReaderManager.disableNfcReaderMode()
+ activity.requestedOrientation =
+ ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ }
+ }
+ },
+ )
+ }
+
fun handleBackButton() {
_shouldResetPIN.postValue(true)
resetValues()
@@ -650,8 +764,182 @@ class NFCViewModel
errorLog(logTag, "Unable to sign with NFC - Certificate status: unknown", e)
}
+ private fun showWebEidSigningCertificateMismatchError(e: Exception) {
+ _errorState.postValue(Triple(R.string.web_eid_signing_card_mismatch, 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)
}
+
+ 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
+ }
+
+ message.contains("Web eID signing certificate mismatch") -> {
+ showWebEidSigningCertificateMismatchError(ex)
+ true
+ }
+
+ else -> false
+ }.also {
+ errorLog(logTag, "Exception: ${ex.message}", ex)
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ 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
new file mode 100644
index 000000000..8f55ac6d1
--- /dev/null
+++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModel.kt
@@ -0,0 +1,219 @@
+/*
+ * 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 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 javax.inject.Inject
+
+@HiltViewModel
+class WebEidViewModel
+ @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
+ }
+ }
+
+ 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
+
+ try {
+ val token =
+ authService.buildAuthToken(
+ authCert,
+ if (getSigningCertificate == true) signingCert else null,
+ signature,
+ )
+ val payload = JSONObject().put("authToken", 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)
+ }
+ }
+
+ 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 fe8481bab..6964c99cd 100644
--- a/app/src/main/res/values-et/strings.xml
+++ b/app/src/main/res/values-et/strings.xml
@@ -497,7 +497,7 @@
Eesti tuleviku heaks
- Euroopa Liit Euroopa Regionaalarengu Fond
+ Kaasrahastanud Euroopa Liit
Projekti on toetatud Euroopa Liidu Regionaalarengu Fondist
RIA DigiDoc
versioon %1$s
@@ -660,4 +660,28 @@
Muuda
+
+ Autentimine
+ Autentimine ID-kaardiga
+ Autentimispäringut ei saadetud
+ 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:
+ Allkirjastamispäring:
+ Tühista
+ Vigane Web eID päring
+ Päringu viga
+ Vigane autentimispäring
+ Kaarti ei tuvastatud. Palun alusta uuesti.
+ 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/donottranslate.xml b/app/src/main/res/values/donottranslate.xml
index 6007af785..c8a34638b 100644
--- a/app/src/main/res/values/donottranslate.xml
+++ b/app/src/main/res/values/donottranslate.xml
@@ -66,6 +66,8 @@
mainSettingsSignatureAddMethod
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 802e6abc5..63e552801 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
@@ -497,7 +497,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
@@ -660,4 +660,28 @@
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:
+ 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:
+ Signing request from:
+ Cancel
+ Invalid Web eID request
+ Request error
+ Invalid authentication request
+ Card not detected. Please start again.
+ The selected ID card does not match the required person. Please use the correct ID card.
\ No newline at end of file
diff --git a/app/src/release/AndroidManifest.xml b/app/src/release/AndroidManifest.xml
new file mode 100644
index 000000000..f77f2262f
--- /dev/null
+++ b/app/src/release/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/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..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
@@ -331,4 +370,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 c220c5367..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
@@ -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..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
@@ -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/main/kotlin/ee/ria/DigiDoc/utilsLib/file/FileUtil.kt b/utils-lib/src/main/kotlin/ee/ria/DigiDoc/utilsLib/file/FileUtil.kt
index 62fd4f4e5..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
@@ -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,15 @@ 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/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/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 {
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/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt
new file mode 100644
index 000000000..28352fa3c
--- /dev/null
+++ b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceTest.kt
@@ -0,0 +1,125 @@
+/*
+ * 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("signature").isNotBlank())
+ assert(token.has("algorithm"))
+ 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),
+ signingCertificate.getString("certificate"),
+ )
+ assertNotEquals(
+ token.getString("unverifiedCertificate"),
+ signingCertificate.getString("certificate"),
+ "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/kotlin/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt
new file mode 100644
index 000000000..c36ac9220
--- /dev/null
+++ b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidRequestParserTest.kt
@@ -0,0 +1,286 @@
+/*
+ * 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.security.MessageDigest
+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",
+ "loginUri": "$loginUri",
+ "getSigningCertificate": 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",
+ "loginUri": "$loginUri",
+ "getSigningCertificate": $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)}",
+ "loginUri": "$loginUri",
+ "getSigningCertificate": 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("{\"responseUri\":\"$responseUri\"")
+ if (hash != null) sb.append(",\"hash\":\"$hash\"")
+ if (hashFunction != null) sb.append(",\"hashFunction\":\"$hashFunction\"")
+ if (signingCertificate != null) {
+ sb.append(",\"signingCertificate\":\"$signingCertificate\"")
+ }
+ sb.append("}")
+ val encoded = Base64.getEncoder().encodeToString(sb.toString().toByteArray())
+ return "web-eid://sign#$encoded"
+ }
+
+ private fun validSha384Base64(): String {
+ 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/kotlin/ee/ria/DigiDoc/webEid/WebEidSignServiceTest.kt b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/WebEidSignServiceTest.kt
new file mode 100644
index 000000000..a0b0d8232
--- /dev/null
+++ b/web-eid-lib/src/androidTest/kotlin/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/kotlin/ee/ria/DigiDoc/webEid/di/AppModulesTest.kt b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/di/AppModulesTest.kt
new file mode 100644
index 000000000..c99404d44
--- /dev/null
+++ b/web-eid-lib/src/androidTest/kotlin/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/kotlin/ee/ria/DigiDoc/webEid/exception/WebEidExceptionTest.kt b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/exception/WebEidExceptionTest.kt
new file mode 100644
index 000000000..715cbb33c
--- /dev/null
+++ b/web-eid-lib/src/androidTest/kotlin/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/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtilTest.kt b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtilTest.kt
new file mode 100644
index 000000000..68902af73
--- /dev/null
+++ b/web-eid-lib/src/androidTest/kotlin/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/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt b/web-eid-lib/src/androidTest/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtilTest.kt
new file mode 100644
index 000000000..29a1e8068
--- /dev/null
+++ b/web-eid-lib/src/androidTest/kotlin/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/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthService.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthService.kt
new file mode 100644
index 000000000..79419db77
--- /dev/null
+++ b/web-eid-lib/src/main/kotlin/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/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt
new file mode 100644
index 000000000..84c4b6e66
--- /dev/null
+++ b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidAuthServiceImpl.kt
@@ -0,0 +1,75 @@
+/*
+ * 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.JSONArray
+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)
+
+ 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")
+ }
+ }
+ }
+ }
diff --git a/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidSignService.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidSignService.kt
new file mode 100644
index 000000000..3d4cda43a
--- /dev/null
+++ b/web-eid-lib/src/main/kotlin/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/kotlin/ee/ria/DigiDoc/webEid/WebEidSignServiceImpl.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/WebEidSignServiceImpl.kt
new file mode 100644
index 000000000..e03a95b6f
--- /dev/null
+++ b/web-eid-lib/src/main/kotlin/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/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()
+}
diff --git a/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthRequest.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidAuthRequest.kt
new file mode 100644
index 000000000..3211f62f0
--- /dev/null
+++ b/web-eid-lib/src/main/kotlin/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/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidCertificateRequest.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidCertificateRequest.kt
new file mode 100644
index 000000000..d787cfcbf
--- /dev/null
+++ b/web-eid-lib/src/main/kotlin/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/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidPersonalData.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidPersonalData.kt
new file mode 100644
index 000000000..187f75bbd
--- /dev/null
+++ b/web-eid-lib/src/main/kotlin/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/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt
new file mode 100644
index 000000000..5a4bab827
--- /dev/null
+++ b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/domain/model/WebEidSignRequest.kt
@@ -0,0 +1,33 @@
+/*
+ * 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?,
+ val personalData: WebEidPersonalData?,
+)
diff --git a/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/exception/WebEidErrorCode.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/exception/WebEidErrorCode.kt
new file mode 100644
index 000000000..728b849d3
--- /dev/null
+++ b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/exception/WebEidErrorCode.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
+
+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/kotlin/ee/ria/DigiDoc/webEid/exception/WebEidException.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/exception/WebEidException.kt
new file mode 100644
index 000000000..4f15bb810
--- /dev/null
+++ b/web-eid-lib/src/main/kotlin/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/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtil.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidAlgorithmUtil.kt
new file mode 100644
index 000000000..e1b896db4
--- /dev/null
+++ b/web-eid-lib/src/main/kotlin/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/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt
new file mode 100644
index 000000000..13437ad0b
--- /dev/null
+++ b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidRequestParser.kt
@@ -0,0 +1,220 @@
+/*
+ * 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.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
+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.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("loginUri"))
+ 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("getSigningCertificate", false),
+ origin = parseOrigin(responseUri),
+ )
+ }
+
+ fun parseCertificateUri(uri: Uri): WebEidCertificateRequest {
+ val request = decodeUriFragment(uri)
+ val responseUri = validateResponseUri(request.optString("responseUri", ""))
+
+ return WebEidCertificateRequest(
+ responseUri = responseUri.toString(),
+ origin = parseOrigin(responseUri),
+ )
+ }
+
+ fun parseSignUri(uri: Uri): WebEidSignRequest {
+ val request = decodeUriFragment(uri)
+ val responseUri = validateResponseUri(request.optString("responseUri", ""))
+ val hash = request.optString("hash", "")
+ val hashFunction = request.optString("hashFunction", "")
+
+ if (hash.isBlank() || hashFunction.isBlank()) {
+ throw WebEidException(
+ ERR_WEBEID_MOBILE_INVALID_REQUEST,
+ "Invalid signing request: missing hash or hashFunction",
+ responseUri.toString(),
+ )
+ }
+
+ validateAndDecodeHash(
+ hashBase64 = hash,
+ hashFunction = hashFunction,
+ responseUri = responseUri.toString(),
+ )
+
+ val signingCertificatePem = request.optString("signingCertificate", "")
+ if (signingCertificatePem.isBlank()) {
+ throw WebEidException(
+ ERR_WEBEID_MOBILE_INVALID_REQUEST,
+ "Invalid signing request: missing signingCertificate",
+ responseUri.toString(),
+ )
+ }
+
+ val signingCertificateDerBytes = Base64.getDecoder().decode(signingCertificatePem)
+ val signingCertificate = CertificateUtil.x509Certificate(signingCertificateDerBytes)
+ val parsedPersonalData = CertificateParsingUtil.personalData(signingCertificate)
+
+ return WebEidSignRequest(
+ responseUri = responseUri.toString(),
+ origin = parseOrigin(responseUri),
+ signingCertificate,
+ hash = hash,
+ hashFunction = hashFunction,
+ personalData =
+ WebEidPersonalData(
+ surname = parsedPersonalData.surname,
+ givenNames = parsedPersonalData.givenNames,
+ personalCode = parsedPersonalData.personalCode,
+ ),
+ )
+ }
+
+ 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/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt b/web-eid-lib/src/main/kotlin/ee/ria/DigiDoc/webEid/utils/WebEidResponseUtil.kt
new file mode 100644
index 000000000..da294de0c
--- /dev/null
+++ b/web-eid-lib/src/main/kotlin/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()
+ }
+}