Skip to content

Commit df6d21a

Browse files
SanderKondratjevNortalaarmam
authored andcommitted
NFC-83 Implement certificate and signing logic
1 parent cb25434 commit df6d21a

File tree

27 files changed

+1465
-102
lines changed

27 files changed

+1465
-102
lines changed

app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/WebEidViewModelTest.kt

Lines changed: 269 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import android.net.Uri
66
import android.util.Base64.URL_SAFE
77
import android.util.Base64.decode
88
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
9+
import ee.ria.DigiDoc.R
910
import ee.ria.DigiDoc.webEid.WebEidAuthService
11+
import ee.ria.DigiDoc.webEid.WebEidSignService
1012
import kotlinx.coroutines.ExperimentalCoroutinesApi
1113
import kotlinx.coroutines.async
1214
import kotlinx.coroutines.flow.first
@@ -32,12 +34,15 @@ class WebEidViewModelTest {
3234
@Mock
3335
private lateinit var authService: WebEidAuthService
3436

37+
@Mock
38+
private lateinit var signService: WebEidSignService
39+
3540
private lateinit var viewModel: WebEidViewModel
3641

3742
@Before
3843
fun setup() {
3944
MockitoAnnotations.openMocks(this)
40-
viewModel = WebEidViewModel(authService)
45+
viewModel = WebEidViewModel(authService, signService)
4146
}
4247

4348
@Test
@@ -127,20 +132,13 @@ class WebEidViewModelTest {
127132
}
128133

129134
@Test
130-
fun webEidViewModel_handleSign_parsesSignUriAndSetsStateFlow() {
131-
val uri =
132-
Uri.parse(
133-
"web-eid-mobile://sign#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwic2lnbl9jZXJ0aWZpY2F0ZSI6InNpZ25pbmdfY2VydGlmaWNhdGUiLCJoYXNoIjoiaGFzaCIsImhhc2hfZnVuY3Rpb24iOiJoYXNoX2Z1bmN0aW9uIn0",
134-
)
135-
viewModel.handleSign(uri)
136-
val authRequest = viewModel.authRequest.value
137-
val signRequest = viewModel.signRequest.value
138-
assert(authRequest == null)
139-
assert(signRequest != null)
140-
assertEquals("https://example.com/response", signRequest?.responseUri)
141-
assertEquals("signing_certificate", signRequest?.signCertificate)
142-
assertEquals("hash", signRequest?.hash)
143-
assertEquals("hash_function", signRequest?.hashFunction)
135+
@OptIn(ExperimentalCoroutinesApi::class)
136+
fun webEidViewModel_handleAuth_emitDialogErrorWhenGenericException() {
137+
runTest(UnconfinedTestDispatcher()) {
138+
val uri = Uri.parse("web-eid-mobile://auth#{}")
139+
viewModel.handleAuth(uri)
140+
assertEquals(R.string.web_eid_invalid_auth_request_error, viewModel.dialogError.value)
141+
}
144142
}
145143

146144
@Test
@@ -169,7 +167,38 @@ class WebEidViewModelTest {
169167
assert(emittedUri.fragment != null)
170168
val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE))
171169
val jsonPayload = JSONObject(decodedPayload)
172-
val authToken = jsonPayload.getJSONObject("auth-token")
170+
val authToken = jsonPayload.getJSONObject("auth_token")
171+
assertEquals("web-eid:1.0", authToken.getString("format"))
172+
}
173+
}
174+
175+
@Test
176+
@OptIn(ExperimentalCoroutinesApi::class)
177+
fun webEidViewModel_handleWebEidAuthResult_buildsAuthTokenWithoutSigningCert() {
178+
runTest(UnconfinedTestDispatcher()) {
179+
val cert = byteArrayOf(1, 2, 3)
180+
val signingCert = byteArrayOf(9, 9, 9)
181+
val signature = byteArrayOf(4, 5, 6)
182+
val uri =
183+
Uri.parse(
184+
"web-eid-mobile://auth#eyJjaGFsbGVuZ2UiOiJ0ZXN0LWNoYWxsZW5nZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsImxvZ2luX3VyaSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vcmVzcG9uc2UiLCJnZXRfc2lnbmluZ19jZXJ0aWZpY2F0ZSI6ZmFsc2V9",
185+
)
186+
whenever(authService.buildAuthToken(cert, null, signature))
187+
.thenReturn(JSONObject().put("format", "web-eid:1.0"))
188+
val deferred =
189+
async {
190+
viewModel.relyingPartyResponseEvents.first()
191+
}
192+
viewModel.handleAuth(uri)
193+
viewModel.handleWebEidAuthResult(cert, signingCert, signature)
194+
195+
verify(authService).buildAuthToken(cert, null, signature)
196+
val emittedUri = deferred.await()
197+
assert(emittedUri.toString().startsWith("https://example.com/response#"))
198+
assert(emittedUri.fragment != null)
199+
val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE))
200+
val jsonPayload = JSONObject(decodedPayload)
201+
val authToken = jsonPayload.getJSONObject("auth_token")
173202
assertEquals("web-eid:1.0", authToken.getString("format"))
174203
}
175204
}
@@ -205,4 +234,228 @@ class WebEidViewModelTest {
205234
assertEquals("Unexpected error", jsonPayload.getString("message"))
206235
}
207236
}
237+
238+
@Test
239+
fun webEidViewModel_handleCertificate_parsesCertificateUriAndSetsStateFlow() {
240+
runTest {
241+
val uri =
242+
Uri.parse(
243+
"web-eid-mobile://cert#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIn0",
244+
)
245+
viewModel.handleCertificate(uri)
246+
val authRequest = viewModel.authRequest.value
247+
val signRequest = viewModel.signRequest.value
248+
assert(authRequest == null)
249+
assert(signRequest != null)
250+
assertEquals("https://example.com/response", signRequest?.responseUri)
251+
assertEquals(null, signRequest?.hash)
252+
assertEquals(null, signRequest?.hashFunction)
253+
}
254+
}
255+
256+
@Test
257+
@OptIn(ExperimentalCoroutinesApi::class)
258+
fun webEidViewModel_handleCertificate_emitDialogErrorWhenGenericException() {
259+
runTest(UnconfinedTestDispatcher()) {
260+
val uri = Uri.parse("web-eid-mobile://cert#{}")
261+
viewModel.handleCertificate(uri)
262+
assertEquals(
263+
R.string.web_eid_invalid_sign_request_error,
264+
viewModel.dialogError.value,
265+
)
266+
}
267+
}
268+
269+
@Test
270+
fun webEidViewModel_handleSign_parsesSignUriAndSetsStateFlow() {
271+
runTest {
272+
val uri =
273+
Uri.parse(
274+
"web-eid-mobile://sign#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwic2lnbl9jZXJ0aWZpY2F0ZSI6InNpZ25pbmdfY2VydGlmaWNhdGUiLCJoYXNoIjoiaGFzaCIsImhhc2hfZnVuY3Rpb24iOiJoYXNoX2Z1bmN0aW9uIn0",
275+
)
276+
viewModel.handleSign(uri)
277+
val authRequest = viewModel.authRequest.value
278+
val signRequest = viewModel.signRequest.value
279+
assert(authRequest == null)
280+
assert(signRequest != null)
281+
assertEquals("https://example.com/response", signRequest?.responseUri)
282+
assertEquals("hash", signRequest?.hash)
283+
assertEquals("hash_function", signRequest?.hashFunction)
284+
}
285+
}
286+
287+
@Test
288+
@OptIn(ExperimentalCoroutinesApi::class)
289+
fun webEidViewModel_handleSign_emitErrorResponseEventWhenWebEidException() {
290+
runTest(UnconfinedTestDispatcher()) {
291+
val uri =
292+
Uri.parse(
293+
"web-eid-mobile://sign#" +
294+
"eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwic2lnbl9jZXJ0aWZpY2F0ZSI6InNpZ25lcnNlcnQiLCJoYXNoIjoiIn0",
295+
)
296+
297+
val deferred =
298+
async {
299+
viewModel.relyingPartyResponseEvents.first()
300+
}
301+
302+
viewModel.handleSign(uri)
303+
304+
val emittedUri = deferred.await()
305+
assert(emittedUri.toString().startsWith("https://example.com/response#"))
306+
assert(emittedUri.fragment != null)
307+
val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE))
308+
val jsonPayload = JSONObject(decodedPayload)
309+
assertEquals("ERR_WEBEID_MOBILE_INVALID_REQUEST", jsonPayload.getString("code"))
310+
assertEquals(
311+
"Invalid signing request: missing hash or hash_function",
312+
jsonPayload.getString("message"),
313+
)
314+
}
315+
}
316+
317+
@Test
318+
@OptIn(ExperimentalCoroutinesApi::class)
319+
fun webEidViewModel_handleSign_emitDialogErrorWhenGenericException() {
320+
runTest(UnconfinedTestDispatcher()) {
321+
val uri = Uri.parse("web-eid-mobile://sign#{}")
322+
viewModel.handleSign(uri)
323+
assertEquals(R.string.web_eid_invalid_sign_request_error, viewModel.dialogError.value)
324+
}
325+
}
326+
327+
@Test
328+
@OptIn(ExperimentalCoroutinesApi::class)
329+
fun webEidViewModel_handleUnknown_emitDialogError() {
330+
runTest(UnconfinedTestDispatcher()) {
331+
val uri = Uri.parse("web-eid-mobile://unknown#{}")
332+
viewModel.handleUnknown(uri)
333+
assertEquals(
334+
R.string.web_eid_invalid_sign_request_error,
335+
viewModel.dialogError.value,
336+
)
337+
}
338+
}
339+
340+
@Test
341+
@OptIn(ExperimentalCoroutinesApi::class)
342+
fun webEidViewModel_handleWebEidCertificateResult_buildsCertificatePayloadAndEmitsResponseEvent() {
343+
runTest(UnconfinedTestDispatcher()) {
344+
val signingCert = byteArrayOf(1, 2, 3)
345+
val uri =
346+
Uri.parse(
347+
"web-eid-mobile://sign#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwic2lnbl9jZXJ0aWZpY2F0ZSI6InNpZ25pbmdfY2VydGlmaWNhdGUiLCJoYXNoIjoiaGFzaCIsImhhc2hfZnVuY3Rpb24iOiJoYXNoX2Z1bmN0aW9uIn0",
348+
)
349+
viewModel.handleSign(uri)
350+
351+
whenever(signService.buildCertificatePayload(signingCert))
352+
.thenReturn(JSONObject().put("certificate", "mock-cert"))
353+
354+
val deferred =
355+
async {
356+
viewModel.relyingPartyResponseEvents.first()
357+
}
358+
359+
viewModel.handleWebEidCertificateResult(signingCert)
360+
361+
verify(signService).buildCertificatePayload(signingCert)
362+
val emittedUri = deferred.await()
363+
assert(emittedUri.toString().startsWith("https://example.com/response#"))
364+
assert(emittedUri.fragment != null)
365+
val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE))
366+
val jsonPayload = JSONObject(decodedPayload)
367+
val certificateValue = jsonPayload.getString("certificate")
368+
assertEquals("mock-cert", certificateValue)
369+
}
370+
}
371+
372+
@Test
373+
@OptIn(ExperimentalCoroutinesApi::class)
374+
fun webEidViewModel_handleWebEidCertificateResult_emitErrorResponseEventWhenException() {
375+
runTest(UnconfinedTestDispatcher()) {
376+
val signingCert = byteArrayOf(1, 2, 3)
377+
val uri =
378+
Uri.parse(
379+
"web-eid-mobile://sign#eyJyZXNwb25zZV91cmkiOiJodHRwczovL2V4YW1wbGUuY29tL3Jlc3BvbnNlIiwic2lnbl9jZXJ0aWZpY2F0ZSI6InNpZ25pbmdfY2VydGlmaWNhdGUiLCJoYXNoIjoiaGFzaCIsImhhc2hfZnVuY3Rpb24iOiJoYXNoX2Z1bmN0aW9uIn0",
380+
)
381+
viewModel.handleSign(uri)
382+
383+
whenever(signService.buildCertificatePayload(signingCert))
384+
.thenThrow(RuntimeException("Test exception"))
385+
386+
val deferred =
387+
async {
388+
viewModel.relyingPartyResponseEvents.first()
389+
}
390+
391+
viewModel.handleWebEidCertificateResult(signingCert)
392+
393+
verify(signService).buildCertificatePayload(signingCert)
394+
val emittedUri = deferred.await()
395+
assert(emittedUri.toString().startsWith("https://example.com/response#"))
396+
assert(emittedUri.fragment != null)
397+
val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE))
398+
val jsonPayload = JSONObject(decodedPayload)
399+
assertEquals("ERR_WEBEID_MOBILE_UNKNOWN_ERROR", jsonPayload.getString("code"))
400+
assertEquals("Unexpected error", jsonPayload.getString("message"))
401+
}
402+
}
403+
404+
@Test
405+
@OptIn(ExperimentalCoroutinesApi::class)
406+
fun webEidViewModel_handleWebEidSignResult_buildsSignPayloadAndEmitsResponseEvent() {
407+
runTest(UnconfinedTestDispatcher()) {
408+
val signingCert = "mock-sign-cert"
409+
val signature = byteArrayOf(1, 2, 3)
410+
val responseUri = "https://example.com/response"
411+
412+
whenever(signService.buildSignPayload(signingCert, signature))
413+
.thenReturn(JSONObject().put("signature", "mock-signature"))
414+
415+
val deferred =
416+
async {
417+
viewModel.relyingPartyResponseEvents.first()
418+
}
419+
420+
viewModel.handleWebEidSignResult(signingCert, signature, responseUri)
421+
422+
verify(signService).buildSignPayload(signingCert, signature)
423+
val emittedUri = deferred.await()
424+
assert(emittedUri.toString().startsWith("https://example.com/response#"))
425+
assert(emittedUri.fragment != null)
426+
val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE))
427+
val jsonPayload = JSONObject(decodedPayload)
428+
val signValue = jsonPayload.getString("signature")
429+
assertEquals("mock-signature", signValue)
430+
}
431+
}
432+
433+
@Test
434+
@OptIn(ExperimentalCoroutinesApi::class)
435+
fun webEidViewModel_handleWebEidSignResult_emitErrorResponseEventWhenException() {
436+
runTest(UnconfinedTestDispatcher()) {
437+
val signingCert = "mock-sign-cert"
438+
val signature = byteArrayOf(1, 2, 3)
439+
val responseUri = "https://example.com/response"
440+
441+
whenever(signService.buildSignPayload(signingCert, signature))
442+
.thenThrow(RuntimeException("Test exception"))
443+
444+
val deferred =
445+
async {
446+
viewModel.relyingPartyResponseEvents.first()
447+
}
448+
449+
viewModel.handleWebEidSignResult(signingCert, signature, responseUri)
450+
451+
verify(signService).buildSignPayload(signingCert, signature)
452+
val emittedUri = deferred.await()
453+
assert(emittedUri.toString().startsWith("https://example.com/response#"))
454+
assert(emittedUri.fragment != null)
455+
val decodedPayload = String(decode(emittedUri.fragment, URL_SAFE))
456+
val jsonPayload = JSONObject(decodedPayload)
457+
assertEquals("ERR_WEBEID_MOBILE_UNKNOWN_ERROR", jsonPayload.getString("code"))
458+
assertEquals("Unexpected error", jsonPayload.getString("message"))
459+
}
460+
}
208461
}

app/src/main/kotlin/ee/ria/DigiDoc/MainActivity.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,12 @@ class MainActivity :
123123
val locale = dataStore.getLocale() ?: getLocale("en")
124124
val webEidUri = intent?.data?.takeIf { it.scheme == "web-eid-mobile" }
125125

126-
val externalFileUris = if (webEidUri != null) {
127-
listOf()
128-
} else {
129-
getExternalFileUris(intent)
130-
}
126+
val externalFileUris =
127+
if (webEidUri != null) {
128+
listOf()
129+
} else {
130+
getExternalFileUris(intent)
131+
}
131132

132133
localeUtil.updateLocale(applicationContext, locale)
133134

@@ -172,7 +173,7 @@ class MainActivity :
172173
RIADigiDocTheme(darkTheme = useDarkMode) {
173174
RIADigiDocAppScreen(
174175
externalFileUris = externalFileUris,
175-
webEidUri = webEidUri
176+
webEidUri = webEidUri,
176177
)
177178
}
178179
}

app/src/main/kotlin/ee/ria/DigiDoc/domain/model/IdentityAction.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,5 @@ enum class IdentityAction(
2727
SIGN("SIGN"),
2828
AUTH("AUTH"),
2929
DECRYPT("DECRYPT"),
30+
CERTIFICATE("CERTIFICATE"),
3031
}

app/src/main/kotlin/ee/ria/DigiDoc/domain/preferences/DataStore.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
package ee.ria.DigiDoc.domain.preferences
2323

24+
import android.annotation.SuppressLint
2425
import android.content.Context
2526
import android.content.SharedPreferences
2627
import android.content.res.Resources
@@ -119,6 +120,34 @@ class DataStore
119120
errorLog(logTag, "Unable to save CAN")
120121
}
121122

123+
fun getSigningCertificate(): String {
124+
val encryptedPrefs = getEncryptedPreferences(context)
125+
if (encryptedPrefs == null) {
126+
errorLog(logTag, "Unable to read signing certificate")
127+
return ""
128+
}
129+
130+
val currentCan = getCanNumber()
131+
val key = "${resources.getString(R.string.main_settings_signing_cert_key)}_$currentCan"
132+
return encryptedPrefs.getString(key, "") ?: ""
133+
}
134+
135+
@SuppressLint("ApplySharedPref")
136+
fun setSigningCertificate(cert: String) {
137+
val encryptedPrefs = getEncryptedPreferences(context)
138+
if (encryptedPrefs == null) {
139+
errorLog(logTag, "Unable to save signing certificate")
140+
return
141+
}
142+
143+
val currentCanNumber = getCanNumber()
144+
val key = "${resources.getString(R.string.main_settings_signing_cert_key)}_$currentCanNumber"
145+
val editor = encryptedPrefs.edit()
146+
147+
editor.remove(key).commit()
148+
if (cert.isNotEmpty()) editor.putString(key, cert).commit()
149+
}
150+
122151
fun getPhoneNo(): String =
123152
preferences.getString(
124153
resources.getString(R.string.main_settings_phone_no_key),

0 commit comments

Comments
 (0)