diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9a161ed83..1b64e9e8c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -192,6 +192,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.mockito.kotlin) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.arch.core.testing) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/EncryptRecipientViewModelTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/EncryptRecipientViewModelTest.kt new file mode 100644 index 000000000..51a1755c4 --- /dev/null +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/viewmodel/EncryptRecipientViewModelTest.kt @@ -0,0 +1,130 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.viewmodel + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Observer +import androidx.test.platform.app.InstrumentationRegistry +import ee.ria.DigiDoc.R +import ee.ria.DigiDoc.configuration.repository.ConfigurationRepository +import ee.ria.DigiDoc.cryptolib.Addressee +import ee.ria.DigiDoc.cryptolib.CDOC2Settings +import ee.ria.DigiDoc.cryptolib.CertType +import ee.ria.DigiDoc.cryptolib.CryptoContainer +import ee.ria.DigiDoc.cryptolib.exception.CryptoException +import ee.ria.DigiDoc.cryptolib.repository.RecipientRepository +import ee.ria.DigiDoc.utilsLib.mimetype.MimeTypeResolver +import ee.ria.DigiDoc.viewmodel.shared.SharedContainerViewModel +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.fail +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 java.io.File + +@RunWith(MockitoJUnitRunner::class) +class EncryptRecipientViewModelTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Mock + private lateinit var mimeTypeResolver: MimeTypeResolver + + @Mock + private lateinit var recipientRepository: RecipientRepository + + @Mock + private lateinit var configurationRepository: ConfigurationRepository + + @Mock + private lateinit var errorStateObserver: Observer + + private lateinit var context: Context + private lateinit var sharedContainerViewModel: SharedContainerViewModel + private lateinit var viewModel: EncryptRecipientViewModel + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + context = InstrumentationRegistry.getInstrumentation().targetContext + sharedContainerViewModel = SharedContainerViewModel(context, context.contentResolver) + viewModel = + EncryptRecipientViewModel( + context, + mimeTypeResolver, + recipientRepository, + CDOC2Settings(context, configurationRepository), + configurationRepository, + ) + } + + @Test + fun encryptRecipientViewModel_encryptContainerWithPassword_returnsEncryptErrorWithNoContainer() = + runTest { + viewModel.errorState.observeForever(errorStateObserver) + + viewModel.encryptContainerWithPassword("MyKey", "password123".toByteArray(), sharedContainerViewModel) + + assertEquals(R.string.crypto_encrypt_error, viewModel.errorState.value) + } + + @Test + fun encryptRecipientViewModel_decryptContainerWithPassword_throwsCryptoExceptionWithNoContainer() = + runTest { + try { + viewModel.decryptContainerWithPassword("password123".toByteArray(), sharedContainerViewModel) + fail("Expected CryptoException to be thrown") + } catch (e: CryptoException) { + assertEquals("No container to decrypt", e.message) + } + } + + @Test + fun encryptRecipientViewModel_resetErrorState_clearsErrorState() = + runTest { + viewModel.encryptContainerWithPassword("key", "pass".toByteArray(), sharedContainerViewModel) + viewModel.resetErrorState() + + assertNull(viewModel.errorState.value) + } + + @Test + fun encryptRecipientViewModel_encryptContainerWithPassword_returnsDataFilesEmptyErrorWithEmptyDataFiles() = + runTest { + sharedContainerViewModel.setCryptoContainer( + CryptoContainer( + context = context, + file = File(context.cacheDir, "test.cdoc"), + dataFiles = ArrayList(), + recipients = + ArrayList().apply { + add( + Addressee( + data = ByteArray(0), + identifier = "key", + serialNumber = null, + givenName = null, + surname = null, + certType = CertType.PasswordType, + validTo = null, + concatKDFAlgorithmURI = null, + ), + ) + }, + decrypted = false, + encrypted = false, + ), + ) + + viewModel.encryptContainerWithPassword("key", "password".toByteArray(), sharedContainerViewModel) + + assertEquals(R.string.crypto_encrypt_data_files_empty_error, viewModel.errorState.value) + } +} diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/EncryptRecipientScreen.kt b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/EncryptRecipientScreen.kt index 672b46385..90d797026 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/EncryptRecipientScreen.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/fragment/screen/EncryptRecipientScreen.kt @@ -105,6 +105,7 @@ import ee.ria.DigiDoc.utils.accessibility.AccessibilityUtil.Companion.sendAccess import ee.ria.DigiDoc.utils.extensions.reachedBottom import ee.ria.DigiDoc.utils.snackbar.SnackBarManager.showMessage import ee.ria.DigiDoc.utils.snackbar.SnackbarType +import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog import ee.ria.DigiDoc.utilsLib.validator.PersonalCodeValidator import ee.ria.DigiDoc.viewmodel.EncryptRecipientViewModel import ee.ria.DigiDoc.viewmodel.EncryptViewModel @@ -127,6 +128,7 @@ fun EncryptRecipientScreen( sharedRecipientViewModel: SharedRecipientViewModel, encryptRecipientViewModel: EncryptRecipientViewModel = hiltViewModel(), ) { + val logTag = "EncryptRecipientScreen" val context = LocalContext.current val scope = rememberCoroutineScope() @@ -326,6 +328,33 @@ fun EncryptRecipientScreen( isBottomSheetVisible = isSettingsMenuBottomSheetVisible, ) + @Composable + fun RecipientTab(tabModifier: Modifier) { + RecipientTabContent( + modifier = tabModifier, + expanded = expanded, + onExpandedChange = { expanded = it }, + searchText = searchText, + onSearchTextChange = encryptRecipientViewModel::onSearchTextChange, + invalidPersonalCodeMessage = invalidPersonalCodeMessage, + onSearch = { encryptRecipientViewModel.onQueryTextChange(it) }, + onDismissSearch = dismissSearch, + recipientList = recipientList, + hasSearched = hasSearched, + containerRecipientList = containerRecipientList.value, + onAddRecipientToContainer = { recipient -> + encryptRecipientViewModel.addRecipientToContainer( + recipient, + sharedContainerViewModel, + ) + }, + onRecipientClick = { recipient -> + clickedRecipient.value = recipient + showRecipientBottomSheet.value = true + }, + ) + } + if (isCdoc2) { Column( modifier = modifier.padding(paddingValues).fillMaxSize(), @@ -341,29 +370,7 @@ fun EncryptRecipientScreen( tabItems = listOf( Pair(tabRecipientTitle) { - RecipientTabContent( - modifier = Modifier.fillMaxSize(), - expanded = expanded, - onExpandedChange = { expanded = it }, - searchText = searchText, - onSearchTextChange = encryptRecipientViewModel::onSearchTextChange, - invalidPersonalCodeMessage = invalidPersonalCodeMessage, - onSearch = { encryptRecipientViewModel.onQueryTextChange(it) }, - onDismissSearch = dismissSearch, - recipientList = recipientList, - hasSearched = hasSearched, - containerRecipientList = containerRecipientList.value, - onAddRecipientToContainer = { recipient -> - encryptRecipientViewModel.addRecipientToContainer( - recipient, - sharedContainerViewModel, - ) - }, - onRecipientClick = { recipient -> - clickedRecipient.value = recipient - showRecipientBottomSheet.value = true - }, - ) + RecipientTab(Modifier.fillMaxSize()) }, Pair(tabPasswordTitle) { PasswordTabContent(modifier = Modifier.fillMaxSize()) @@ -372,36 +379,27 @@ fun EncryptRecipientScreen( ) } } else { - RecipientTabContent( - modifier = Modifier.padding(paddingValues).fillMaxWidth(), - expanded = expanded, - onExpandedChange = { expanded = it }, - searchText = searchText, - onSearchTextChange = encryptRecipientViewModel::onSearchTextChange, - invalidPersonalCodeMessage = invalidPersonalCodeMessage, - onSearch = { encryptRecipientViewModel.onQueryTextChange(it) }, - onDismissSearch = dismissSearch, - recipientList = recipientList, - hasSearched = hasSearched, - containerRecipientList = containerRecipientList.value, - onAddRecipientToContainer = { recipient -> - encryptRecipientViewModel.addRecipientToContainer( - recipient, - sharedContainerViewModel, - ) - }, - onRecipientClick = { recipient -> - clickedRecipient.value = recipient - showRecipientBottomSheet.value = true - }, - ) + RecipientTab(Modifier.padding(paddingValues).fillMaxWidth()) } if (showPasswordDialog.value) { EncryptPasswordDialog( modifier = modifier, onDismiss = { showPasswordDialog.value = false }, - onEncrypt = { _, _ -> showPasswordDialog.value = false }, + onEncrypt = { keyLabel, password -> + debugLog(logTag, "User submitted password encryption dialog") + showPasswordDialog.value = false + encryptionButtonEnabled.value = false + showLoading.value = true + scope.launch(Main) { + encryptRecipientViewModel.encryptContainerWithPassword( + keyLabel = keyLabel, + password = password.toByteArray(Charsets.UTF_8), + sharedContainerViewModel = sharedContainerViewModel, + ) + showLoading.value = false + } + }, ) } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/DecryptPasswordDialog.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/DecryptPasswordDialog.kt index 8245258d5..34ada9ff2 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/DecryptPasswordDialog.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/DecryptPasswordDialog.kt @@ -22,28 +22,21 @@ package ee.ria.DigiDoc.ui.component.crypto import android.content.res.Configuration -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -51,9 +44,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.window.Dialog import ee.ria.DigiDoc.R -import ee.ria.DigiDoc.ui.component.shared.CancelAndOkButtonRow import ee.ria.DigiDoc.ui.component.shared.PrimaryTextField import ee.ria.DigiDoc.ui.theme.Dimensions.MPadding import ee.ria.DigiDoc.ui.theme.RIADigiDocTheme @@ -74,91 +65,54 @@ fun DecryptPasswordDialog( val containerKeyLabelTitle = stringResource(R.string.crypto_password_key_label_placeholder) val containerPasswordLabel = stringResource(R.string.crypto_password_enter) - Dialog(onDismissRequest = onDismiss) { - Surface( + PasswordDialogScaffold( + modifier = modifier, + title = stringResource(R.string.decrypt_button), + okButtonTitle = R.string.decrypt_button, + okButtonEnabled = password.text.isNotEmpty(), + onDismiss = onDismiss, + onOkButtonClick = { onDecrypt(password.text) }, + cancelButtonTestTag = "decryptPasswordDialogCancelButton", + okButtonTestTag = "decryptPasswordDialogDecryptButton", + ) { + Text( + text = containerKeyLabelTitle, modifier = modifier .fillMaxWidth() - .verticalScroll(rememberScrollState()), - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surface, - ) { - Column( - modifier = - modifier - .padding(MPadding) - .fillMaxWidth(), - horizontalAlignment = Alignment.Start, - ) { - Text( - text = stringResource(R.string.decrypt_button), - modifier = - modifier - .fillMaxWidth() - .semantics { heading() }, - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Start, - ) + .notAccessible(), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) - Spacer(modifier = modifier.height(MPadding)) - - Text( - text = containerKeyLabelTitle, - modifier = - modifier - .fillMaxWidth() - .notAccessible(), - textAlign = TextAlign.Start, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Text( - text = keyLabel, - modifier = - modifier - .fillMaxWidth() - .semantics { - contentDescription = "$containerKeyLabelTitle, $keyLabel" - }, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Start, - ) - - Spacer(modifier = modifier.height(MPadding)) - - PrimaryTextField( - modifier = modifier.fillMaxWidth(), - value = password, - onValueChange = { password = it }, - label = containerPasswordLabel, - placeholder = containerPasswordLabel, - isPasswordText = true, - keyboardOptions = - KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), - ) + Text( + text = keyLabel, + modifier = + modifier + .fillMaxWidth() + .semantics { + contentDescription = "$containerKeyLabelTitle, $keyLabel" + }, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Start, + ) - Spacer(modifier = modifier.height(MPadding)) + Spacer(modifier = modifier.height(MPadding)) - CancelAndOkButtonRow( - modifier = modifier, - cancelButtonClick = onDismiss, - okButtonClick = { onDecrypt(password.text) }, - okButtonEnabled = password.text.isNotEmpty(), - cancelButtonTitle = R.string.cancel_button, - okButtonTitle = R.string.decrypt_button, - cancelButtonContentDescription = - stringResource(R.string.cancel_button).lowercase(), - okButtonContentDescription = - stringResource(R.string.decrypt_button).lowercase(), - cancelButtonTestTag = "decryptPasswordDialogCancelButton", - okButtonTestTag = "decryptPasswordDialogDecryptButton", - ) - } - } + PrimaryTextField( + modifier = modifier.fillMaxWidth(), + value = password, + onValueChange = { password = it }, + label = containerPasswordLabel, + placeholder = containerPasswordLabel, + isPasswordText = true, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + ) } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/EncryptNavigation.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/EncryptNavigation.kt index fc1fba6f9..91a56b41f 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/EncryptNavigation.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/EncryptNavigation.kt @@ -87,6 +87,7 @@ import ee.ria.DigiDoc.common.Constant.DDOC_MIMETYPE import ee.ria.DigiDoc.cryptolib.Addressee import ee.ria.DigiDoc.cryptolib.CertType import ee.ria.DigiDoc.cryptolib.CryptoContainer +import ee.ria.DigiDoc.cryptolib.exception.WrongPasswordException import ee.ria.DigiDoc.libdigidoclib.SignedContainer import ee.ria.DigiDoc.ui.component.crypto.bottombar.CryptoNextBottomBar import ee.ria.DigiDoc.ui.component.crypto.bottomsheet.CryptoDataFileBottomSheet @@ -124,6 +125,7 @@ import ee.ria.DigiDoc.utilsLib.extensions.isContainer import ee.ria.DigiDoc.utilsLib.extensions.isSignedPDF import ee.ria.DigiDoc.utilsLib.extensions.mimeType import ee.ria.DigiDoc.utilsLib.file.FileUtil.sanitizeString +import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog import ee.ria.DigiDoc.viewmodel.EncryptRecipientViewModel import ee.ria.DigiDoc.viewmodel.EncryptViewModel @@ -151,6 +153,7 @@ fun EncryptNavigation( encryptViewModel: EncryptViewModel = hiltViewModel(), encryptRecipientViewModel: EncryptRecipientViewModel = hiltViewModel(), ) { + val logTag = "EncryptNavigation" val cryptoContainer by sharedContainerViewModel.cryptoContainer.collectAsState() val shouldResetContainer by encryptViewModel.shouldResetCryptoContainer.asFlow().collectAsState(false) val context = LocalContext.current @@ -387,6 +390,21 @@ fun EncryptNavigation( } } + val onDecryptActionClick = { + val passwordRecipient = + cryptoContainer + ?.recipients + ?.firstOrNull { it.certType == CertType.PasswordType } + if (passwordRecipient != null) { + decryptPasswordKeyLabel = passwordRecipient.identifier + showDecryptPasswordDialog.value = true + } else { + showLoadingScreen.value = true + navController.navigate(Route.DecryptScreen.route) + showLoadingScreen.value = false + } + } + var isSaved by remember { mutableStateOf(false) } val fileToSave = remember { mutableStateOf(null) } @@ -545,6 +563,17 @@ fun EncryptNavigation( } } + LaunchedEffect(sharedContainerViewModel.containerEncrypted) { + sharedContainerViewModel.containerEncrypted.collect { containerEncrypted -> + if (containerEncrypted == true) { + withContext(Main) { + selectedCryptoContainerTabIndex.intValue = 1 + sharedContainerViewModel.resetContainerEncrypted() + } + } + } + } + LaunchedEffect(filesAdded) { when { filesAdded == 1 -> showMessage(context, R.string.file_added, SnackbarType.SUCCESS) @@ -741,25 +770,14 @@ fun EncryptNavigation( rightActionButtonContentDescription = rightActionButtonName, onLeftActionButtonClick = {}, onRightActionButtonClick = { - if (encryptViewModel.isDecryptButtonShown(cryptoContainer, isNestedContainer)) { - val passwordRecipient = - cryptoContainer - ?.recipients - ?.firstOrNull { it.certType == CertType.PasswordType } - if (passwordRecipient != null) { - decryptPasswordKeyLabel = passwordRecipient.identifier - showDecryptPasswordDialog.value = true - } else { - showLoadingScreen.value = true - navController.navigate(Route.DecryptScreen.route) - showLoadingScreen.value = false + when { + encryptViewModel.isDecryptButtonShown(cryptoContainer, isNestedContainer) -> { + onDecryptActionClick() + } + + encryptViewModel.isEncryptButtonShown(cryptoContainer, isNestedContainer) -> { + onEncryptClick() } - } else if (encryptViewModel.isEncryptButtonShown( - cryptoContainer, - isNestedContainer, - ) - ) { - onEncryptClick() } }, onMoreOptionsActionButtonClick = { @@ -1029,7 +1047,44 @@ fun EncryptNavigation( modifier = modifier, keyLabel = decryptPasswordKeyLabel, onDismiss = { showDecryptPasswordDialog.value = false }, - onDecrypt = { _ -> showDecryptPasswordDialog.value = false }, + onDecrypt = { password -> + showDecryptPasswordDialog.value = false + showLoadingScreen.value = true + scope.launch(IO) { + try { + debugLog(logTag, "User submitted password decryption dialog") + encryptRecipientViewModel.decryptContainerWithPassword( + password = password.toByteArray(Charsets.UTF_8), + sharedContainerViewModel = sharedContainerViewModel, + ) + withContext(Main) { + debugLog(logTag, "Decryption complete — switching to files tab") + selectedCryptoContainerTabIndex.intValue = 0 + containerDecryptedSuccess.value = true + sendAccessibilityEvent( + context, + getAccessibilityEventType(), + containerDecryptedSuccessText, + ) + containerDecryptedSuccess.value = false + showMessage(containerDecryptedSuccessText) + } + } catch (_: WrongPasswordException) { + errorLog(logTag, "Wrong password — showing error to user") + withContext(Main) { + showMessage(context, R.string.crypto_decrypt_wrong_password) + } + } catch (e: Exception) { + errorLog(logTag, "Unexpected error during decryption", e) + withContext(Main) { + showMessage(context, R.string.crypto_decrypt_error) + } + } + withContext(Main) { + showLoadingScreen.value = false + } + } + }, ) } @@ -1165,9 +1220,13 @@ private fun handleBackButtonClick( } } } else { + sharedContainerViewModel.resetSignedContainer() + sharedContainerViewModel.resetCryptoContainer() sharedContainerViewModel.clearContainers() encryptViewModel.handleBackButton() - navController.navigateUp() + if (!navController.popBackStack(Route.Home.route, inclusive = false)) { + navController.navigateUp() + } } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/EncryptPasswordDialog.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/EncryptPasswordDialog.kt index 449b64ecb..b3b4ca483 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/EncryptPasswordDialog.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/EncryptPasswordDialog.kt @@ -30,27 +30,27 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.traversalIndex @@ -59,15 +59,14 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.window.Dialog import ee.ria.DigiDoc.R -import ee.ria.DigiDoc.ui.component.shared.CancelAndOkButtonRow import ee.ria.DigiDoc.ui.component.shared.PrimaryTextField import ee.ria.DigiDoc.ui.theme.Dimensions.MPadding import ee.ria.DigiDoc.ui.theme.Dimensions.SPadding import ee.ria.DigiDoc.ui.theme.Dimensions.XSPadding import ee.ria.DigiDoc.ui.theme.Dimensions.iconSizeXXS import ee.ria.DigiDoc.ui.theme.RIADigiDocTheme +import ee.ria.DigiDoc.utils.crypto.PasswordUtil.isPasswordValid @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -95,162 +94,164 @@ fun EncryptPasswordDialog( val requirementNumber = stringResource(R.string.crypto_password_requirement_number) val requirementUppercase = stringResource(R.string.crypto_password_requirement_uppercase) val requirementLowercase = stringResource(R.string.crypto_password_requirement_lowercase) - val requirementStrings = listOf(requirementLength, requirementNumber, requirementUppercase, requirementLowercase) - val requirementsContentDescription = "$requirementsTitle: ${listOf( - stringResource(R.string.crypto_password_requirement_length_tts), - stringResource(R.string.crypto_password_requirement_number_tts), - requirementUppercase, - requirementLowercase, - ).joinToString(", ")}" + val requirementLengthTts = stringResource(R.string.crypto_password_requirement_length_tts) + val requirementNumberTts = stringResource(R.string.crypto_password_requirement_number_tts) + val requirementStrings = + remember(requirementLength, requirementNumber, requirementUppercase, requirementLowercase) { + listOf(requirementLength, requirementNumber, requirementUppercase, requirementLowercase) + } + val requirementsContentDescription = + remember( + requirementsTitle, + requirementLengthTts, + requirementNumberTts, + requirementUppercase, + requirementLowercase, + ) { + "$requirementsTitle: ${ + listOf( + requirementLengthTts, + requirementNumberTts, + requirementUppercase, + requirementLowercase, + ).joinToString(", ") + }" + } + + val passwordValid by remember { derivedStateOf { isPasswordValid(password.text) } } + val passwordIsError = password.text.isNotEmpty() && !passwordValid + val passwordsMatch = password.text == repeatPassword.text + val repeatPasswordIsError = repeatPassword.text.isNotEmpty() && !passwordsMatch + + PasswordDialogScaffold( + modifier = modifier, + title = stringResource(R.string.crypto_encrypt_tab_password), + okButtonTitle = R.string.encrypt_button, + okButtonEnabled = passwordValid && passwordsMatch, + onDismiss = onDismiss, + onOkButtonClick = { onEncrypt(keyLabel.text, password.text) }, + cancelButtonTestTag = "encryptPasswordDialogCancelButton", + okButtonTestTag = "encryptPasswordDialogEncryptButton", + ) { + PrimaryTextField( + modifier = modifier.fillMaxWidth(), + value = keyLabel, + onValueChange = { keyLabel = it }, + label = keyLabelLabel, + placeholder = keyLabelLabel, + description = stringResource(R.string.crypto_password_key_label_hint), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + + Spacer(modifier = modifier.height(MPadding)) - Dialog(onDismissRequest = onDismiss) { Surface( - modifier = - modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surface, + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, ) { - Column( - modifier = - modifier - .padding(MPadding) - .fillMaxWidth(), - horizontalAlignment = Alignment.Start, + Row( + modifier = modifier.padding(SPadding), + verticalAlignment = Alignment.CenterVertically, ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_m3_info_48dp_wght400), + contentDescription = null, + modifier = modifier.size(iconSizeXXS), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = modifier.width(XSPadding)) Text( - text = stringResource(R.string.crypto_encrypt_tab_password), - modifier = - modifier - .fillMaxWidth() - .semantics { heading() }, - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Start, + text = stringResource(R.string.crypto_password_secure_place_info), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, ) + } + } - Spacer(modifier = modifier.height(MPadding)) + Spacer(modifier = modifier.height(MPadding)) - PrimaryTextField( - modifier = modifier.fillMaxWidth(), - value = keyLabel, - onValueChange = { keyLabel = it }, - label = keyLabelLabel, - placeholder = keyLabelLabel, - description = stringResource(R.string.crypto_password_key_label_hint), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - ) + Column( + modifier = + modifier + .fillMaxWidth() + .semantics { isTraversalGroup = true }, + ) { + PrimaryTextField( + modifier = + modifier + .fillMaxWidth() + .semantics { + isTraversalGroup = true + traversalIndex = 1f + }, + value = password, + onValueChange = { password = it }, + label = passwordLabel, + placeholder = passwordLabel, + isPasswordText = true, + isError = passwordIsError, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ), + ) - Spacer(modifier = modifier.height(MPadding)) + Spacer(modifier = modifier.height(SPadding)) - Surface( - modifier = modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.small, - ) { - Row( - modifier = modifier.padding(SPadding), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_m3_info_48dp_wght400), - contentDescription = null, - modifier = modifier.size(iconSizeXXS), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = modifier.width(XSPadding)) - Text( - text = stringResource(R.string.crypto_password_secure_place_info), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } + val requirementColor: Color = + if (passwordIsError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSecondaryContainer } - Spacer(modifier = modifier.height(MPadding)) - - Column( - modifier = - modifier - .fillMaxWidth() - .semantics { isTraversalGroup = true }, - ) { - PrimaryTextField( - modifier = - modifier - .fillMaxWidth() - .semantics { - isTraversalGroup = true - traversalIndex = 1f - }, - value = password, - onValueChange = { password = it }, - label = passwordLabel, - placeholder = passwordLabel, - isPasswordText = true, - keyboardOptions = - KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Next, - ), + Column( + modifier = + modifier + .fillMaxWidth() + .clearAndSetSemantics { + traversalIndex = 0f + contentDescription = requirementsContentDescription + }, + ) { + requirementStrings.forEach { req -> + Text( + text = "• $req", + modifier = modifier.fillMaxWidth(), + style = MaterialTheme.typography.labelMedium, + color = requirementColor, + textAlign = TextAlign.Start, ) - - Spacer(modifier = modifier.height(SPadding)) - - Column( - modifier = - modifier - .fillMaxWidth() - .clearAndSetSemantics { - traversalIndex = 0f - contentDescription = requirementsContentDescription - }, - ) { - requirementStrings.forEach { req -> - Text( - text = "• $req", - modifier = modifier.fillMaxWidth(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - textAlign = TextAlign.Start, - ) - } - } } + } + } - Spacer(modifier = modifier.height(MPadding)) - - PrimaryTextField( - modifier = modifier.fillMaxWidth(), - value = repeatPassword, - onValueChange = { repeatPassword = it }, - label = repeatPasswordLabel, - placeholder = repeatPasswordLabel, - isPasswordText = true, - keyboardOptions = - KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), - ) + Spacer(modifier = modifier.height(MPadding)) - Spacer(modifier = modifier.height(MPadding)) + PrimaryTextField( + modifier = modifier.fillMaxWidth(), + value = repeatPassword, + onValueChange = { repeatPassword = it }, + label = repeatPasswordLabel, + placeholder = repeatPasswordLabel, + isPasswordText = true, + isError = repeatPasswordIsError, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + ) - CancelAndOkButtonRow( - modifier = modifier, - cancelButtonClick = onDismiss, - okButtonClick = { onEncrypt(keyLabel.text, password.text) }, - okButtonEnabled = password.text.isNotEmpty() && password.text == repeatPassword.text, - cancelButtonTitle = R.string.cancel_button, - okButtonTitle = R.string.encrypt_button, - cancelButtonContentDescription = - stringResource(R.string.cancel_button).lowercase(), - okButtonContentDescription = - stringResource(R.string.encrypt_button).lowercase(), - cancelButtonTestTag = "encryptPasswordDialogCancelButton", - okButtonTestTag = "encryptPasswordDialogEncryptButton", - ) - } + if (repeatPasswordIsError) { + Text( + modifier = modifier.fillMaxWidth().padding(top = XSPadding), + text = stringResource(R.string.crypto_password_mismatch), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) } } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/PasswordDialogScaffold.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/PasswordDialogScaffold.kt new file mode 100644 index 000000000..911b6d2bf --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/PasswordDialogScaffold.kt @@ -0,0 +1,109 @@ +/* + * 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.ui.component.crypto + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.window.Dialog +import ee.ria.DigiDoc.R +import ee.ria.DigiDoc.ui.component.shared.CancelAndOkButtonRow +import ee.ria.DigiDoc.ui.theme.Dimensions.MPadding + +@Composable +fun PasswordDialogScaffold( + modifier: Modifier = Modifier, + title: String, + @StringRes okButtonTitle: Int, + okButtonEnabled: Boolean, + onDismiss: () -> Unit, + onOkButtonClick: () -> Unit, + cancelButtonTestTag: String, + okButtonTestTag: String, + content: @Composable ColumnScope.() -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Surface( + modifier = + modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = + modifier + .padding(MPadding) + .fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + Text( + text = title, + modifier = + modifier + .fillMaxWidth() + .semantics { heading() }, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Start, + ) + + Spacer(modifier = modifier.height(MPadding)) + + content() + + Spacer(modifier = modifier.height(MPadding)) + + CancelAndOkButtonRow( + modifier = modifier, + cancelButtonClick = onDismiss, + okButtonClick = onOkButtonClick, + okButtonEnabled = okButtonEnabled, + cancelButtonTitle = R.string.cancel_button, + okButtonTitle = okButtonTitle, + cancelButtonContentDescription = + stringResource(R.string.cancel_button).lowercase(), + okButtonContentDescription = + stringResource(okButtonTitle).lowercase(), + cancelButtonTestTag = cancelButtonTestTag, + okButtonTestTag = okButtonTestTag, + ) + } + } + } +} diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/recipient/RecipientDetailItem.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/recipient/RecipientDetailItem.kt index de566458d..5799862cf 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/recipient/RecipientDetailItem.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/recipient/RecipientDetailItem.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import ee.ria.DigiDoc.R import ee.ria.DigiDoc.cryptolib.Addressee +import ee.ria.DigiDoc.cryptolib.CertType import ee.ria.DigiDoc.utilsLib.date.DateUtil.dateFormat import ee.ria.DigiDoc.utilsLib.extensions.x509Certificate import java.security.cert.X509Certificate @@ -47,10 +48,39 @@ data class RecipientDetailItem( recipientFormattedName: String?, recipientIssuerName: String?, recipientConcatKDFAlgorithmURI: String?, + ): List = + if (recipient.certType == CertType.PasswordType) { + passwordRecipientItems(recipient) + } else { + certRecipientItems(recipient, recipientFormattedName, recipientIssuerName, recipientConcatKDFAlgorithmURI) + } + + @Composable + private fun passwordRecipientItems(recipient: Addressee): List = + listOf( + item( + label = R.string.recipient_details_name_label, + value = recipient.lockLabel, + testTag = "recipientLockLabel", + ), + item( + label = R.string.recipient_details_lock_type_label, + value = recipient.lockType, + testTag = "recipientLockType", + ), + ) + + @Composable + private fun certRecipientItems( + recipient: Addressee, + recipientFormattedName: String?, + recipientIssuerName: String?, + recipientConcatKDFAlgorithmURI: String?, ): List { val recipientCertificate = recipient.data.x509Certificate() + val validToFormatted = recipient.validTo?.let { dateFormat.format(it) } return listOf( - RecipientDetailItem( + item( icon = if (recipientCertificate != null) { R.drawable.ic_m3_expand_content_48dp_wght400 @@ -60,61 +90,21 @@ data class RecipientDetailItem( label = R.string.recipient_details_name_label, value = recipientFormattedName, certificate = recipientCertificate, - contentDescription = - if (value != null) { - "${stringResource( - id = R.string.recipient_details_name_label, - )} $value" - } else { - "" - }, testTag = "recipientFormattedName", ), - RecipientDetailItem( - icon = 0, + item( label = R.string.recipient_details_certificate_issuer_label, value = recipientIssuerName, - contentDescription = - if (value != null) { - "${stringResource( - id = R.string.recipient_details_certificate_issuer_label, - )} $value" - } else { - "" - }, testTag = "recipientCertificateIssuer", ), - RecipientDetailItem( - icon = 0, + item( label = R.string.recipient_details_concat_kdf_algorithm_url, value = recipientConcatKDFAlgorithmURI, - contentDescription = - if (value != null) { - "${stringResource( - id = R.string.recipient_details_concat_kdf_algorithm_url, - )} $value" - } else { - "" - }, testTag = "recipientConcatKDFAlgorithmURI", ), - RecipientDetailItem( - icon = 0, + item( label = R.string.recipient_details_certificate_valid_to_label, - value = - recipient.validTo?.let { - dateFormat.format( - it, - ) - }, - contentDescription = - if (value != null) { - "${stringResource( - id = R.string.recipient_details_certificate_valid_to_label, - )} $value" - } else { - "" - }, + value = validToFormatted, testTag = "recipientCertificateValidTo", ), RecipientDetailItem( @@ -137,4 +127,26 @@ data class RecipientDetailItem( ), ) } + + @Composable + private fun item( + @DrawableRes icon: Int = 0, + @StringRes label: Int, + value: String?, + certificate: X509Certificate? = null, + testTag: String, + ): RecipientDetailItem = + RecipientDetailItem( + icon = icon, + label = label, + value = value, + certificate = certificate, + contentDescription = + if (!value.isNullOrEmpty()) { + "${stringResource(label)} $value" + } else { + "" + }, + testTag = testTag, + ) } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/utils/crypto/PasswordUtil.kt b/app/src/main/kotlin/ee/ria/DigiDoc/utils/crypto/PasswordUtil.kt new file mode 100644 index 000000000..1a575b5ff --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/utils/crypto/PasswordUtil.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.utils.crypto + +import ee.ria.DigiDoc.common.Constant.Crypto.PASSWORD_MAXIMUM_LENGTH +import ee.ria.DigiDoc.common.Constant.Crypto.PASSWORD_MINIMUM_LENGTH + +object PasswordUtil { + fun isPasswordValid(password: String): Boolean = + password.length in PASSWORD_MINIMUM_LENGTH..PASSWORD_MAXIMUM_LENGTH && + password.any { it.isDigit() } && + password.any { it.isUpperCase() } && + password.any { it.isLowerCase() } +} diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/EncryptRecipientViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/EncryptRecipientViewModel.kt index 750fa0309..875ca93eb 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/EncryptRecipientViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/EncryptRecipientViewModel.kt @@ -34,6 +34,7 @@ import ee.ria.DigiDoc.configuration.repository.ConfigurationRepository import ee.ria.DigiDoc.cryptolib.Addressee import ee.ria.DigiDoc.cryptolib.CDOC2Settings import ee.ria.DigiDoc.cryptolib.CryptoContainer +import ee.ria.DigiDoc.cryptolib.exception.CryptoException import ee.ria.DigiDoc.cryptolib.exception.DataFilesEmptyException import ee.ria.DigiDoc.cryptolib.exception.RecipientsEmptyException import ee.ria.DigiDoc.cryptolib.repository.RecipientRepository @@ -59,7 +60,9 @@ class EncryptRecipientViewModel private val cdoc2Settings: CDOC2Settings, private val configurationRepository: ConfigurationRepository, ) : ViewModel() { - private val logTag = "EncryptRecipientViewModel" + companion object { + private const val LOG_TAG = "EncryptRecipientViewModel" + } private val _errorState = MutableLiveData(null) val errorState: LiveData = _errorState @@ -98,15 +101,15 @@ class EncryptRecipientViewModel try { allRecipients = recipientRepository.find(context, text) } catch (nce: NoInternetConnectionException) { - errorLog(logTag, "Unable to get LDAP addressees. No Internet connection", nce) + errorLog(LOG_TAG, "Unable to get LDAP addressees. No Internet connection", nce) _errorState.postValue(R.string.no_internet_connection) } catch (e: Exception) { - errorLog(logTag, "Unable to get LDAP addressees", e) + errorLog(LOG_TAG, "Unable to get LDAP addressees", e) _errorState.postValue(R.string.error_general_client) } if (allRecipients.second >= 50) { - debugLog(logTag, "Found ${allRecipients.second} addressees") + debugLog(LOG_TAG, "Found ${allRecipients.second} addressees") _errorState.postValue(R.string.crypto_recipients_too_many_results) } @@ -163,7 +166,11 @@ class EncryptRecipientViewModel cdoc2Settings = cdoc2Settings, configurationRepository = configurationRepository, ) - sharedContainerViewModel.setCryptoContainer(cryptoContainer, true) + sharedContainerViewModel.setCryptoContainer( + cryptoContainer, + overwriteContainer = true, + containerEncrypted = true, + ) handleIsContainerEncrypted(true) } catch (_: DataFilesEmptyException) { _errorState.postValue(R.string.crypto_encrypt_data_files_empty_error) @@ -177,6 +184,76 @@ class EncryptRecipientViewModel } } + suspend fun encryptContainerWithPassword( + keyLabel: String, + password: ByteArray, + sharedContainerViewModel: SharedContainerViewModel, + ) { + try { + val cryptoContainer = sharedContainerViewModel.cryptoContainer.value + if (cryptoContainer == null) { + errorLog(LOG_TAG, "Cannot encrypt — no container is open") + _errorState.postValue(R.string.crypto_encrypt_error) + return + } + + debugLog(LOG_TAG, "Encrypting '${cryptoContainer.file.name}' with password, key label: '$keyLabel'") + val encrypted = + CryptoContainer.encryptWithPassword( + context = context, + file = cryptoContainer.file, + dataFiles = cryptoContainer.dataFiles, + keyLabel = keyLabel, + password = password, + cdoc2Settings = cdoc2Settings, + configurationRepository = configurationRepository, + ) + debugLog(LOG_TAG, "Container encrypted successfully") + sharedContainerViewModel.setCryptoContainer( + encrypted, + overwriteContainer = true, + containerEncrypted = true, + ) + handleIsContainerEncrypted(true) + } catch (_: DataFilesEmptyException) { + errorLog(LOG_TAG, "Cannot encrypt — container has no data files") + _errorState.postValue(R.string.crypto_encrypt_data_files_empty_error) + } catch (e: Exception) { + errorLog(LOG_TAG, "Failed to encrypt container with password", e) + _errorState.postValue(R.string.crypto_encrypt_error) + } finally { + password.fill(0) + } + } + + suspend fun decryptContainerWithPassword( + password: ByteArray, + sharedContainerViewModel: SharedContainerViewModel, + ) { + try { + val cryptoContainer = + sharedContainerViewModel.cryptoContainer.value + ?: throw CryptoException("No container to decrypt") + debugLog(LOG_TAG, "Decrypting '${cryptoContainer.file.name}' with password") + val decrypted = + CryptoContainer.decryptWithPassword( + context = context, + file = cryptoContainer.file, + recipients = cryptoContainer.recipients, + password = password, + cdoc2Settings = cdoc2Settings, + configurationRepository = configurationRepository, + ) + debugLog(LOG_TAG, "Container decrypted successfully") + sharedContainerViewModel.setCryptoContainer(decrypted, overwriteContainer = true) + } catch (e: Exception) { + errorLog(LOG_TAG, "Failed to decrypt container with password", e) + throw e + } finally { + password.fill(0) + } + } + fun getMimetype(file: File): String? = mimeTypeResolver.mimeType(file) fun onSearchTextChange(text: String) { diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModel.kt index 91e42d8ad..552c45760 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModel.kt @@ -95,6 +95,9 @@ class SharedContainerViewModel private val _decryptIDCardStatus = MutableStateFlow(null) val decryptIDCardStatus: StateFlow = _decryptIDCardStatus.asStateFlow() + private val _containerEncrypted = MutableStateFlow(null) + val containerEncrypted: StateFlow = _containerEncrypted.asStateFlow() + private val _externalFileUris = MutableStateFlow>(listOf()) val externalFileUris: StateFlow> = _externalFileUris.asStateFlow() @@ -139,14 +142,22 @@ class SharedContainerViewModel fun setCryptoContainer( cryptoContainer: CryptoContainer?, overwriteContainer: Boolean = false, + containerEncrypted: Boolean = false, ) { _cryptoContainer.value = cryptoContainer if (overwriteContainer) { removeLastContainer() } + if (containerEncrypted) { + _containerEncrypted.value = true + } addNestedContainer(cryptoContainer) } + fun resetContainerEncrypted() { + _containerEncrypted.value = null + } + fun setExternalFileUris(uris: List) { _externalFileUris.value = uris } diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 86fffb56a..e3b1fb675 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -191,11 +191,14 @@ Sisaldab vähemalt ühte suurtähte Sisaldab vähemalt ühte väiketähte Korrake parooli + Paroolid ei kattu Leia adressaat… Katkesta adressaadi otsing Adressaati ei leitud Adressaat lisatud Ümbriku krüpteerimine ebaõnnestus. + Ümbriku dekrüpteerimine ebaõnnestus. + Vale parool. Ei saa luua tühja krüptokonteinerit Ei saa luua krüptokonteinerit ilma adressaatideta Adressaat on konteineris juba olemas @@ -423,6 +426,7 @@ Adressaadi tehnilised andmed: Serveri identifikaator: Transaktsiooni identifikaator: + Luku tüüp: Hoiatused diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f4b4eb0c..3a2dcf47f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -191,11 +191,14 @@ Contains at least one uppercase letter Contains at least one lowercase letter Repeat password + Passwords do not match Search recipients… Cancel search recipients No recipients found Recipient added Container encryption was unsuccessful + Container decryption was unsuccessful + Wrong password. Cannot create an empty crypto container Cannot create crypto container without recipients Recipient already exists in the container @@ -423,6 +426,7 @@ Recipient\'s technical details: Server identifier: Transaction identifier: + Lock type: Warnings diff --git a/app/src/test/kotlin/ee/ria/DigiDoc/utils/crypto/PasswordUtilTest.kt b/app/src/test/kotlin/ee/ria/DigiDoc/utils/crypto/PasswordUtilTest.kt new file mode 100644 index 000000000..5caca91e9 --- /dev/null +++ b/app/src/test/kotlin/ee/ria/DigiDoc/utils/crypto/PasswordUtilTest.kt @@ -0,0 +1,49 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.utils.crypto + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PasswordUtilTest { + @Test + fun passwordUtil_isPasswordValid_returnsTrueWithValidPassword() { + assertTrue(PasswordUtil.isPasswordValid("Abcdefghij1234567890")) + } + + @Test + fun passwordUtil_isPasswordValid_returnsTrueWithMaximumLength() { + assertTrue(PasswordUtil.isPasswordValid("Aa1" + "x".repeat(61))) + } + + @Test + fun passwordUtil_isPasswordValid_returnsFalseWhenTooShort() { + assertFalse(PasswordUtil.isPasswordValid("Abcdefghij123456789")) + } + + @Test + fun passwordUtil_isPasswordValid_returnsFalseWhenTooLong() { + assertFalse(PasswordUtil.isPasswordValid("Aa1" + "x".repeat(62))) + } + + @Test + fun passwordUtil_isPasswordValid_returnsFalseWhenMissingDigit() { + assertFalse(PasswordUtil.isPasswordValid("Abcdefghijklmnopqrst")) + } + + @Test + fun passwordUtil_isPasswordValid_returnsFalseWhenMissingUppercase() { + assertFalse(PasswordUtil.isPasswordValid("abcdefghij1234567890")) + } + + @Test + fun passwordUtil_isPasswordValid_returnsFalseWhenMissingLowercase() { + assertFalse(PasswordUtil.isPasswordValid("ABCDEFGHIJ1234567890")) + } + + @Test + fun passwordUtil_isPasswordValid_returnsFalseWithEmptyPassword() { + assertFalse(PasswordUtil.isPasswordValid("")) + } +} diff --git a/app/src/test/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModelEncryptionStateTest.kt b/app/src/test/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModelEncryptionStateTest.kt new file mode 100644 index 000000000..075b50275 --- /dev/null +++ b/app/src/test/kotlin/ee/ria/DigiDoc/viewmodel/shared/SharedContainerViewModelEncryptionStateTest.kt @@ -0,0 +1,86 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.viewmodel.shared + +import android.content.ContentResolver +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +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 + +@RunWith(MockitoJUnitRunner::class) +class SharedContainerViewModelEncryptionStateTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Mock + private lateinit var context: Context + + @Mock + private lateinit var contentResolver: ContentResolver + + private lateinit var viewModel: SharedContainerViewModel + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + viewModel = SharedContainerViewModel(context, contentResolver) + } + + @Test + fun sharedContainerViewModel_containerEncrypted_initialValueIsNull() { + assertNull(viewModel.containerEncrypted.value) + } + + @Test + fun sharedContainerViewModel_setCryptoContainer_setsContainerEncryptedTrueWhenContainerEncryptedTrue() { + viewModel.setCryptoContainer(null, overwriteContainer = true, containerEncrypted = true) + assertTrue(viewModel.containerEncrypted.value == true) + } + + @Test + fun sharedContainerViewModel_setCryptoContainer_doesNotSetContainerEncryptedWhenOverwriteTrueAndNoContainer() { + viewModel.setCryptoContainer(null, overwriteContainer = true) + assertNull(viewModel.containerEncrypted.value) + } + + @Test + fun sharedContainerViewModel_setCryptoContainer_doesNotSetContainerEncryptedWhenOverwriteFalseAndNoContainer() { + viewModel.setCryptoContainer(null, overwriteContainer = false) + assertNull(viewModel.containerEncrypted.value) + } + + @Test + fun sharedContainerViewModel_setCryptoContainer_doesNotSetContainerEncryptedWithNoContainer() { + viewModel.setCryptoContainer(null) + assertNull(viewModel.containerEncrypted.value) + } + + @Test + fun sharedContainerViewModel_setsValueToNullWhenContainerEncryptedIsReset() { + viewModel.setCryptoContainer(null, overwriteContainer = true, containerEncrypted = true) + viewModel.resetContainerEncrypted() + assertNull(viewModel.containerEncrypted.value) + } + + @Test + fun sharedContainerViewModel_resetContainerEncrypted_successWhenUnchanged() { + viewModel.resetContainerEncrypted() + assertNull(viewModel.containerEncrypted.value) + } + + @Test + fun sharedContainerViewModel_containerEncrypted_valueIsTrueWhenResetAndSetAgain() { + viewModel.setCryptoContainer(null, overwriteContainer = true, containerEncrypted = true) + viewModel.resetContainerEncrypted() + viewModel.setCryptoContainer(null, overwriteContainer = true, containerEncrypted = true) + assertTrue(viewModel.containerEncrypted.value == true) + } +} diff --git a/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/Constant.kt b/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/Constant.kt index cd6dfc642..8367a6f1c 100644 --- a/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/Constant.kt +++ b/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/Constant.kt @@ -212,4 +212,9 @@ object Constant { const val PUK_MINIMUM_LENGTH = 8 const val PIN_MAXIMUM_LENGTH = 12 } + + object Crypto { + const val PASSWORD_MINIMUM_LENGTH = 20 + const val PASSWORD_MAXIMUM_LENGTH = 64 + } } diff --git a/crypto-lib/src/androidTest/kotlin/ee/ria/DigiDoc/cryptolib/CryptoContainerTest.kt b/crypto-lib/src/androidTest/kotlin/ee/ria/DigiDoc/cryptolib/CryptoContainerTest.kt index 08a422c71..bbdfc3942 100644 --- a/crypto-lib/src/androidTest/kotlin/ee/ria/DigiDoc/cryptolib/CryptoContainerTest.kt +++ b/crypto-lib/src/androidTest/kotlin/ee/ria/DigiDoc/cryptolib/CryptoContainerTest.kt @@ -856,7 +856,7 @@ class CryptoContainerTest { true, ).apply() - val recipient = Addressee(Base64.getDecoder().decode(authCert)) + val recipient = Addressee.fromCert(Base64.getDecoder().decode(authCert)) val testFiles = listOf(testFile) val container = openOrCreate(context, testFile, testFiles, cdoc2Settings) @@ -886,7 +886,7 @@ class CryptoContainerTest { true, ).apply() - val recipient = Addressee(Base64.getDecoder().decode(authCert)) + val recipient = Addressee.fromCert(Base64.getDecoder().decode(authCert)) val testFiles: List = listOf() val container = openOrCreate(context, testFile, testFiles, cdoc2Settings) @@ -928,7 +928,7 @@ class CryptoContainerTest { "https://cdoc2.id.ee:8443", ).apply() - val recipient = Addressee(Base64.getDecoder().decode(authCert)) + val recipient = Addressee.fromCert(Base64.getDecoder().decode(authCert)) val testFiles = listOf(testFile) val container = openOrCreate(context, testFile, testFiles, cdoc2Settings) @@ -954,7 +954,7 @@ class CryptoContainerTest { false, ).apply() - val recipient = Addressee(Base64.getDecoder().decode(authCert)) + val recipient = Addressee.fromCert(Base64.getDecoder().decode(authCert)) val testFiles = listOf(testFile) val container = openOrCreate(context, testFile, testFiles, cdoc2Settings) @@ -978,7 +978,7 @@ class CryptoContainerTest { true, ).apply() - val recipient = Addressee(Base64.getDecoder().decode(authCert)) + val recipient = Addressee.fromCert(Base64.getDecoder().decode(authCert)) val testFiles = listOf(testFile) val container = openOrCreate(context, testFile, testFiles, cdoc2Settings) @@ -995,7 +995,7 @@ class CryptoContainerTest { true, ).apply() - val recipient = Addressee(Base64.getDecoder().decode(authCert)) + val recipient = Addressee.fromCert(Base64.getDecoder().decode(authCert)) val testFiles = listOf(testFile) val container = openOrCreate(context, testFile, testFiles, cdoc2Settings) @@ -1193,7 +1193,7 @@ class CryptoContainerTest { val dataFiles = listOf(testFile) val cryptoContainer = openOrCreate(context, testFile, dataFiles, cdoc2Settings) - cryptoContainer.addRecipients(listOf(Addressee(Base64.getDecoder().decode(authCert)))) + cryptoContainer.addRecipients(listOf(Addressee.fromCert(Base64.getDecoder().decode(authCert)))) assertEquals(1, cryptoContainer.getRecipients().size) } @@ -1241,7 +1241,7 @@ class CryptoContainerTest { val dataFiles = listOf(testFile) val cryptoContainer = openOrCreate(context, testFile, dataFiles, cdoc2Settings) - val recipient = Addressee(Base64.getDecoder().decode(authCert)) + val recipient = Addressee.fromCert(Base64.getDecoder().decode(authCert)) cryptoContainer.addRecipients(listOf(recipient)) cryptoContainer.removeRecipient(recipient) assertEquals(0, cryptoContainer.getRecipients().size) @@ -1259,7 +1259,7 @@ class CryptoContainerTest { val dataFiles = listOf(testFile) val cryptoContainer = openOrCreate(context, testFile, dataFiles, cdoc2Settings) - val recipient = Addressee(Base64.getDecoder().decode(authCert)) + val recipient = Addressee.fromCert(Base64.getDecoder().decode(authCert)) cryptoContainer.removeRecipient(recipient) assertEquals(0, cryptoContainer.getRecipients().size) } diff --git a/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/Addressee.kt b/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/Addressee.kt index 6ab998e46..891e95659 100644 --- a/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/Addressee.kt +++ b/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/Addressee.kt @@ -34,99 +34,115 @@ import java.io.Serializable import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import java.util.Date +import java.util.Objects -class Addressee( - var data: ByteArray, - var identifier: String, - var serialNumber: String?, - var givenName: String?, - var surname: String?, - var certType: CertType, - var validTo: Date?, - var concatKDFAlgorithmURI: String?, +data class Addressee( + val data: ByteArray, + val identifier: String, + val serialNumber: String?, + val givenName: String?, + val surname: String?, + val certType: CertType, + val validTo: Date?, + val concatKDFAlgorithmURI: String?, + val keyLabel: String? = null, + val serverId: String? = null, + val transactionId: String? = null, + val lockLabel: String? = null, + val lockType: String? = null, ) : Serializable { - var keyLabel: String? = null - var serverId: String? = null - var transactionId: String? = null - - constructor(cn: String, sn: String, certType: CertType, validTo: Date?, data: ByteArray) : this( - data = data, - identifier = "", - serialNumber = "", - givenName = null, - surname = null, - certType = certType, - validTo = validTo, - concatKDFAlgorithmURI = "", - ) { - val split = cn.split(',').map { it.trim() } - if (split.size > 1) { - surname = split[0] - givenName = split[1] - identifier = split[2] - } else { - surname = null - givenName = null - identifier = cn + companion object { + fun fromCN( + cn: String, + sn: String, + certType: CertType, + validTo: Date?, + data: ByteArray, + ): Addressee { + val split = cn.split(',').map { it.trim() } + return if (split.size > 1) { + Addressee( + data = data, + identifier = split[2], + serialNumber = sn, + givenName = split[1], + surname = split[0], + certType = certType, + validTo = validTo, + concatKDFAlgorithmURI = "", + ) + } else { + Addressee( + data = data, + identifier = cn, + serialNumber = sn, + givenName = null, + surname = null, + certType = certType, + validTo = validTo, + concatKDFAlgorithmURI = "", + ) + } } - serialNumber = sn - } - constructor(cert: ByteArray) : this( - cn = extractCNFromCertificate(cert), - sn = extractSerialNumberFromCertificate(cert), - certType = extractCertTypeFromCertificate(cert), - validTo = extractValidToFromCertificate(cert), - data = cert, - ) - - constructor(label: String, pub: ByteArray, concatKDFAlgorithmURI: String) : this( - data = pub, - identifier = "", - serialNumber = "", - givenName = null, - surname = null, - certType = CertType.UnknownType, - validTo = null, - concatKDFAlgorithmURI = concatKDFAlgorithmURI, - ) { - val info = parseLabel(label) - val cn = info["cn"] - val type = info["type"] - val serverExp = info["server_exp"] - val sn = info["serial_number"] - - val certType = - when (type) { - "cert" -> CertType.IDCardType - "ID-card" -> CertType.IDCardType - "Digi-ID" -> CertType.DigiIDType - "Digi-ID E-RESIDENT" -> CertType.EResidentType - else -> CertType.UnknownType - } + fun fromCert(cert: ByteArray): Addressee = + fromCN( + cn = extractCNFromCertificate(cert), + sn = extractSerialNumberFromCertificate(cert), + certType = extractCertTypeFromCertificate(cert), + validTo = extractValidToFromCertificate(cert), + data = cert, + ) + + fun fromLabel( + label: String, + pub: ByteArray, + concatKDFAlgorithmURI: String, + ): Addressee { + val info = parseLabel(label) + val cn = info["cn"] + val type = info["type"] + val serverExp = info["server_exp"] + val sn = info["serial_number"] + + val certType = + when (type) { + "cert" -> CertType.IDCardType + "ID-card" -> CertType.IDCardType + "Digi-ID" -> CertType.DigiIDType + "Digi-ID E-RESIDENT" -> CertType.EResidentType + else -> CertType.UnknownType + } - val validTo = serverExp?.toLongOrNull()?.let { Date(it * 1000) } + val validTo = serverExp?.toLongOrNull()?.let { Date(it * 1000) } - val split = cn?.split(',')?.map { it.trim() } - if (split != null) { - if (split.size > 1) { - this.surname = split[0] - this.givenName = split[1] - this.identifier = split[2] + val split = cn?.split(',')?.map { it.trim() } + val surname: String? + val givenName: String? + val identifier: String? + + if (split != null && split.size > 1) { + surname = split[0] + givenName = split[1] + identifier = split[2] } else { - this.surname = null - this.givenName = null - this.identifier = cn + surname = null + givenName = null + identifier = cn } + + return Addressee( + data = pub, + identifier = identifier ?: "", + serialNumber = sn, + givenName = givenName, + surname = surname, + certType = certType, + validTo = validTo, + concatKDFAlgorithmURI = concatKDFAlgorithmURI, + ) } - this.serialNumber = sn - this.certType = certType - this.validTo = validTo - this.data = pub - this.concatKDFAlgorithmURI = concatKDFAlgorithmURI - } - private companion object { private fun extractCNFromCertificate(cert: ByteArray): String = try { val certificate = @@ -135,12 +151,10 @@ class Addressee( .generateCertificate(cert.inputStream()) as X509Certificate val principal = certificate.subjectX500Principal - // Use Bouncy Castle for proper DN parsing val x500Name = X500Name.getInstance(principal.encoded) val cnAttributes = x500Name.getRDNs(BCStyle.CN) if (cnAttributes.isNotEmpty()) { - // Get all CN values and join them with commas (like the Swift version) cnAttributes .flatMap { rdn -> rdn.typesAndValues.map { IETFUtils.valueToString(it.value) } @@ -160,12 +174,10 @@ class Addressee( .generateCertificate(cert.inputStream()) as X509Certificate val principal = certificate.subjectX500Principal - // Use Bouncy Castle for proper DN parsing val x500Name = X500Name.getInstance(principal.encoded) val serialNumberAttributes = x500Name.getRDNs(BCStyle.SERIALNUMBER) if (serialNumberAttributes.isNotEmpty()) { - // Get all Serial number values and join them with commas serialNumberAttributes .flatMap { rdn -> rdn.typesAndValues.map { IETFUtils.valueToString(it.value) } @@ -217,4 +229,39 @@ class Addressee( null } } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Addressee) return false + return data.contentEquals(other.data) && + identifier == other.identifier && + serialNumber == other.serialNumber && + givenName == other.givenName && + surname == other.surname && + certType == other.certType && + validTo == other.validTo && + concatKDFAlgorithmURI == other.concatKDFAlgorithmURI && + keyLabel == other.keyLabel && + serverId == other.serverId && + transactionId == other.transactionId && + lockLabel == other.lockLabel && + lockType == other.lockType + } + + override fun hashCode(): Int = + Objects.hash( + data.contentHashCode(), + identifier, + serialNumber, + givenName, + surname, + certType, + validTo, + concatKDFAlgorithmURI, + keyLabel, + serverId, + transactionId, + lockLabel, + lockType, + ) } diff --git a/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/CryptoContainer.kt b/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/CryptoContainer.kt index a53db026f..0565d8345 100644 --- a/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/CryptoContainer.kt +++ b/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/CryptoContainer.kt @@ -33,6 +33,7 @@ import ee.ria.DigiDoc.cryptolib.exception.ContainerDataFilesEmptyException import ee.ria.DigiDoc.cryptolib.exception.CryptoException import ee.ria.DigiDoc.cryptolib.exception.DataFilesEmptyException import ee.ria.DigiDoc.cryptolib.exception.RecipientsEmptyException +import ee.ria.DigiDoc.cryptolib.exception.WrongPasswordException import ee.ria.DigiDoc.idcard.Token import ee.ria.DigiDoc.network.utils.ProxyUtil import ee.ria.DigiDoc.smartcardreader.SmartCardReaderException @@ -52,6 +53,7 @@ import ee.ria.cdoc.CryptoBackend import ee.ria.cdoc.DataBuffer import ee.ria.cdoc.FileInfo import ee.ria.cdoc.Lock +import ee.ria.cdoc.Lock.parseLabel import ee.ria.cdoc.LogLevel import ee.ria.cdoc.Logger import ee.ria.cdoc.NetworkBackend @@ -64,13 +66,12 @@ import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException -import java.io.InputStream -import java.io.OutputStream import java.util.Base64 import javax.inject.Inject import javax.inject.Singleton private const val LOG_TAG = "CryptoContainer" +private const val PASSWORD_KDF_ITER = 600000 // OWASP recommended minimum for PBKDF2-HMAC-SHA256 @Singleton class CryptoContainer @@ -166,6 +167,13 @@ class CryptoContainer val logger = JavaLogger() var loggingIsSet = false + private fun parsedLockLabel(label: String?): String = + try { + parseLabel(label.orEmpty())["label"].orEmpty() + } catch (_: Exception) { + label.orEmpty() + } + @Throws(CryptoException::class) private suspend fun open( context: Context, @@ -173,10 +181,10 @@ class CryptoContainer ): CryptoContainer { val cdoc1 = if (file.extension == CDOC1_EXTENSION) openCDOC1(context, file) else null - val cdocReader = CDocReader.createReader(file.path, null, null, null) - debugLog(LOG_TAG, "Reader created: (version ${cdocReader.version})") val lockAddressees = withContext(IO) { + val cdocReader = CDocReader.createReader(file.path, null, null, null) + debugLog(LOG_TAG, "Reader created: (version ${cdocReader.version})") try { cdocReader.locks.map(::addresseeOf) } finally { @@ -187,10 +195,11 @@ class CryptoContainer val cdoc1Recipients = cdoc1?.getRecipients().orEmpty() val recipients = if (cdoc1Recipients.isNotEmpty()) { - cdoc1Recipients.onEach { recipient -> + cdoc1Recipients.map { recipient -> lockAddressees .firstOrNull { it.data.contentEquals(recipient.data) } - ?.let { recipient.concatKDFAlgorithmURI = it.concatKDFAlgorithmURI } + ?.let { recipient.copy(concatKDFAlgorithmURI = it.concatKDFAlgorithmURI) } + ?: recipient } } else { lockAddressees @@ -207,24 +216,53 @@ class CryptoContainer ) } - private fun addresseeOf(lock: Lock): Addressee = - when { - lock.isCDoc1 -> - Addressee(lock.getBytes(Lock.Params.CERT)).apply { - if (!lock.isRSA) { - concatKDFAlgorithmURI = lock.getString(Lock.Params.CONCAT_DIGEST) + private fun addresseeOf(lock: Lock): Addressee { + val addressee = + when { + lock.isCDoc1 -> { + val certAddressee = Addressee.fromCert(lock.getBytes(Lock.Params.CERT) ?: ByteArray(0)) + if (lock.isRSA) { + certAddressee + } else { + certAddressee.copy(concatKDFAlgorithmURI = lock.getString(Lock.Params.CONCAT_DIGEST)) } } - lock.isPKI -> Addressee(lock.label, lock.getBytes(Lock.Params.RCPT_KEY), "") - lock.isSymmetric -> Addressee(lock.label, "", CertType.UnknownType, null, ByteArray(0)) - else -> Addressee("Unknown capsule", ByteArray(0), "") - }.apply { - keyLabel = lock.label.takeIf { it.isNotBlank() } - if (lock.type == Lock.Type.SERVER) { - serverId = lock.getString(Lock.Params.KEYSERVER_ID).takeIf { it.isNotBlank() } - transactionId = lock.getString(Lock.Params.TRANSACTION_ID).takeIf { it.isNotBlank() } - } + lock.isPKI -> + Addressee.fromLabel( + lock.label, + if (lock.type == Lock.Type.PUBLIC_KEY) { + lock.getBytes(Lock.Params.RCPT_KEY) ?: ByteArray(0) + } else { + ByteArray(0) + }, + "", + ) + lock.type == Lock.Type.PASSWORD -> + Addressee( + data = ByteArray(0), + identifier = parsedLockLabel(lock.label), + serialNumber = null, + givenName = null, + surname = null, + certType = CertType.PasswordType, + validTo = null, + concatKDFAlgorithmURI = null, + lockLabel = lock.label, + lockType = lock.type.name, + ) + lock.isSymmetric -> + Addressee.fromCN(lock.label, "", CertType.UnknownType, null, ByteArray(0)) + else -> Addressee.fromLabel("Unknown capsule", ByteArray(0), "") + }.copy(keyLabel = lock.label.takeIf { it.isNotBlank() }) + + if (lock.type != Lock.Type.SERVER) { + return addressee } + return addressee.copy( + serverId = lock.getString(Lock.Params.KEYSERVER_ID).takeIf { it.isNotBlank() }, + transactionId = lock.getString(Lock.Params.TRANSACTION_ID).takeIf { it.isNotBlank() }, + ) + } @Throws(CryptoException::class) suspend fun openCDOC1( @@ -243,7 +281,7 @@ class CryptoContainer } FileInputStream(file).use { recipientsStream -> CDOCParser.getRecipients(recipientsStream).forEach { recipient -> - val addressee = Addressee(recipient.certificate.encoded) + val addressee = Addressee.fromCert(recipient.certificate.encoded) recipients.add(addressee) } } @@ -282,63 +320,173 @@ class CryptoContainer throw CryptoException("Failed to get auth certificate") } val network = CryptoLibNetworkBackend(cdoc2Settings, configurationProvider, context, authCert, token) - val dataFiles = ArrayList() val cdocReader = CDocReader.createReader(file.path, conf, token, network) debugLog(LOG_TAG, "Reader created: (version ${cdocReader.version})") - val idx = cdocReader.getLockForCert(authCert) + try { + val idx = cdocReader.getLockForCert(authCert) + if (idx < 0) throw CryptoException("Failed to get lock for certificate") - if (idx < 0) { - throw CryptoException("Failed to get lock for certificate") - } + val fmk = cdocReader.getFMK(idx.toInt()) + if (token.lastError != null) throw token.lastError as Throwable + if (fmk.isEmpty()) throw CryptoException("Failed to get FMK") - val fmk = cdocReader.getFMK(idx.toInt()) + if (cdocReader.beginDecryption(fmk) != 0L) throw CryptoException("Failed to begin decryption") + val dataFiles = decryptFiles(context, file, cdocReader) + if (cdocReader.finishDecryption() != 0L) throw CryptoException("Failed to finish decryption") - if (token.lastError != null) { - throw token.lastError as Throwable + return create(context, file, dataFiles, recipients, decrypted = true, encrypted = false) + } finally { + cdocReader.delete() } + } - if (fmk.isEmpty()) { - throw CryptoException("Failed to get FMK") - } + @Throws(CryptoException::class) + suspend fun decryptWithPassword( + context: Context, + file: File, + recipients: List, + password: ByteArray, + cdoc2Settings: CDOC2Settings, + configurationRepository: ConfigurationRepository, + ): CryptoContainer { + val backend = PasswordCryptoBackend(password) + val conf = CryptoLibConf(cdoc2Settings) + val configurationProvider = configurationRepository.getConfiguration() + val network = Network(cdoc2Settings, configurationProvider, context) + + debugLog(LOG_TAG, "Decrypting '${file.name}' with password") - if (cdocReader.beginDecryption(fmk) != 0L) { - throw CryptoException("Failed to begin decryption") + return withContext(IO) { + val cdocReader = + CDocReader.createReader(file.path, conf, backend, network) + if (cdocReader == null) throw WrongPasswordException() + try { + val lockIdx = cdocReader.locks.indexOfFirst { it.type == Lock.Type.PASSWORD } + if (lockIdx < 0) throw CryptoException("No password lock found in container") + + val fmk = cdocReader.getFMK(lockIdx) + if (fmk == null || fmk.isEmpty()) throw WrongPasswordException() + + if (cdocReader.beginDecryption(fmk) != 0L) throw CryptoException("Failed to begin decryption") + val dataFiles = + try { + decryptFiles(context, file, cdocReader) + } catch (exc: CDocException) { + errorLog(LOG_TAG, "Wrong password entered for '${file.name}'") + throw WrongPasswordException(exc) + } + if (cdocReader.finishDecryption() != 0L) throw CryptoException("Failed to finish decryption") + + debugLog(LOG_TAG, "Successfully decrypted '${file.name}' — ${dataFiles.size} file(s) extracted") + create(context, file, dataFiles, recipients, decrypted = true, encrypted = false) + } finally { + cdocReader.delete() + } } + } + @Throws(CryptoException::class) + private fun decryptFiles( + context: Context, + containerFile: File, + cdocReader: CDocReader, + ): ArrayList { + val dataFiles = ArrayList() val fi = FileInfo() var result: Long = cdocReader.nextFile(fi) try { while (result == CDoc.OK.toLong()) { - val ofile = File(fi.name) - val dir = - ContainerUtil.getContainerDataFilesDir( - context, - file, - ) - val tmp = sanitizeString(ofile.name, "") - val fileToSave = File(dir, tmp) - val ofs: OutputStream = FileOutputStream(fileToSave) - cdocReader.readFile(ofs) + val dir = ContainerUtil.getContainerDataFilesDir(context, containerFile) + val fileToSave = File(dir, sanitizeString(File(fi.name).name, "")) + FileOutputStream(fileToSave).use { ofs -> cdocReader.readFile(ofs) } dataFiles.add(fileToSave) - ofs.close() result = cdocReader.nextFile(fi) } } catch (exc: IOException) { throw CryptoException("IO Exception: ${exc.message}", exc) } + return dataFiles + } - if (cdocReader.finishDecryption() != 0L) { - throw CryptoException("Failed to finish decryption") + @Throws(CryptoException::class) + private suspend fun encryptContainerWithPassword( + context: Context, + file: File, + dataFiles: List, + createWriter: () -> CDocWriter, + addRecipients: (CDocWriter) -> Unit, + ): CryptoContainer { + try { + withContext(IO) { + val cdocWriter = createWriter() + try { + addRecipients(cdocWriter) + if (cdocWriter.beginEncryption() != 0L) { + throw CryptoException("Failed to begin encryption") + } + dataFiles.forEach { dataFile -> + FileInputStream(dataFile).use { ifs -> + val bytes = ifs.readBytes() + if (cdocWriter.addFile(dataFile.name, bytes.size.toLong()) != 0L) { + throw CryptoException("Failed to add file") + } + if (cdocWriter.writeData(bytes) != 0L) { + throw CryptoException("Failed to write data") + } + } + } + if (cdocWriter.finishEncryption() != 0L) { + throw CryptoException("Failed to finish encryption") + } + } finally { + cdocWriter.delete() + } + } + } catch (exc: IOException) { + errorLog(LOG_TAG, "Failed to encrypt '${file.name}' — IO error: ${exc.message}", exc) + throw CryptoException("IO Exception: ${exc.message}", exc) + } catch (exc: CDocException) { + errorLog(LOG_TAG, "Failed to encrypt '${file.name}' — library error: ${exc.message}", exc) + throw CryptoException("CDoc Exception ${exc.code}: ${exc.message}", exc) } - return create( - context, - file, - dataFiles, - recipients, - decrypted = true, - encrypted = false, + debugLog(LOG_TAG, "Successfully encrypted '${file.name}'") + return open(context, file) + } + + @Throws(CryptoException::class) + suspend fun encryptWithPassword( + context: Context, + file: File, + dataFiles: List, + keyLabel: String, + password: ByteArray, + cdoc2Settings: CDOC2Settings, + configurationRepository: ConfigurationRepository, + ): CryptoContainer { + if (dataFiles.isEmpty()) { + throw DataFilesEmptyException("Cannot create an empty crypto container") + } + val backend = PasswordCryptoBackend(password) + val conf = CryptoLibConf(cdoc2Settings) + val configurationProvider = configurationRepository.getConfiguration() + val network = Network(cdoc2Settings, configurationProvider, context) + + debugLog(LOG_TAG, "Encrypting '${file.name}' with password, key label: '$keyLabel'") + + return encryptContainerWithPassword( + context = context, + file = file, + dataFiles = dataFiles, + createWriter = { CDocWriter.createWriter(2, file.path, conf, backend, network) }, + addRecipients = { cdocWriter -> + val recipient = Recipient.makeSymmetric("", PASSWORD_KDF_ITER) + recipient.setLabelValue("label", keyLabel) + if (cdocWriter.addRecipient(recipient) != 0L) { + throw CryptoException("Failed to add password recipient") + } + }, ) } @@ -370,9 +518,12 @@ class CryptoContainer 1 } - val cdocWriter = CDocWriter.createWriter(version, file.path, conf, null, network) - try { - withContext(IO) { + return encryptContainerWithPassword( + context = context, + file = file, + dataFiles = dataFiles, + createWriter = { CDocWriter.createWriter(version, file.path, conf, null, network) }, + addRecipients = { cdocWriter -> if (version == 2 && cdoc2Settings.getUseOnlineEncryption()) { val serverId = cdoc2Settings.getCDOC2UUID() recipients.forEach { addressee -> @@ -389,39 +540,8 @@ class CryptoContainer } } } - } - if (cdocWriter.beginEncryption() != 0L) { - throw CryptoException("Failed to begin encryption") - } - withContext(IO) { - dataFiles.forEach { dataFile -> - val ifs: InputStream = FileInputStream(dataFile) - val bytes = ifs.readBytes() - if (cdocWriter.addFile(dataFile.name, bytes.size.toLong()) != 0L) { - throw CryptoException("Failed to add file") - } - - if (cdocWriter.writeData(bytes) != 0L) { - throw CryptoException("Failed to write data") - } - ifs.close() - } - } - - if (cdocWriter.finishEncryption() != 0L) { - throw CryptoException("Failed to finish encryption") - } - } catch (exc: IOException) { - errorLog(LOG_TAG, "IO Exception: ${exc.message}", exc) - throw CryptoException("IO Exception: ${exc.message}", exc) - } catch (exc: CDocException) { - errorLog(LOG_TAG, "CDoc Exception ${exc.code}: ${exc.message}", exc) - throw CryptoException("CDoc Exception ${exc.code}: ${exc.message}", exc) - } finally { - cdocWriter.delete() - } - - return open(context, file) + }, + ) } @Throws(CryptoException::class) @@ -504,7 +624,12 @@ class CryptoContainer CDoc.setLogger(logger) loggingIsSet = true } - CDoc.log(LogLevel.LEVEL_DEBUG, "CryptoContainer", 450, "Set libcdoc logging: true") + CDoc.log( + LogLevel.LEVEL_DEBUG, + "CryptoContainer", + 0, + "Set libcdoc logging: true", + ) } } @@ -597,5 +722,17 @@ class CryptoContainer digest: ByteArray, ): Long = token.sign(dst, algorithm, digest, 0) } + + private class PasswordCryptoBackend( + private val password: ByteArray, + ) : CryptoBackend() { + override fun getSecret( + dst: DataBuffer, + idx: Int, + ): Long { + dst.data = password + return CDoc.OK.toLong() + } + } } } diff --git a/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/exception/WrongPasswordException.kt b/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/exception/WrongPasswordException.kt new file mode 100644 index 000000000..2ed982df5 --- /dev/null +++ b/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/exception/WrongPasswordException.kt @@ -0,0 +1,26 @@ +/* + * 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.cryptolib.exception + +class WrongPasswordException( + cause: Throwable = Throwable("Wrong password"), +) : CryptoException("Wrong password", cause) diff --git a/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/repository/RecipientRepositoryImpl.kt b/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/repository/RecipientRepositoryImpl.kt index cbe205f23..ddc575576 100644 --- a/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/repository/RecipientRepositoryImpl.kt +++ b/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/repository/RecipientRepositoryImpl.kt @@ -182,7 +182,7 @@ class RecipientRepositoryImpl for (value in attribute.rawValues) { val certificate = ExtendedCertificate.create(value.value, certificateService) if (isSuitableKeyAndNotMobileId(certificate)) { - builder.add(Addressee(certificate.data)) + builder.add(Addressee.fromCert(certificate.data)) } } } diff --git a/crypto-lib/src/test/kotlin/ee/ria/DigiDoc/cryptolib/AddresseeTest.kt b/crypto-lib/src/test/kotlin/ee/ria/DigiDoc/cryptolib/AddresseeTest.kt new file mode 100644 index 000000000..2719e4eca --- /dev/null +++ b/crypto-lib/src/test/kotlin/ee/ria/DigiDoc/cryptolib/AddresseeTest.kt @@ -0,0 +1,115 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.cryptolib + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.util.Date + +class AddresseeTest { + @Test + fun addressee_lockLabel_defaultIsNull() { + val addressee = addressee() + assertNull(addressee.lockLabel) + } + + @Test + fun addressee_lockType_defaultIsNull() { + val addressee = addressee() + assertNull(addressee.lockType) + } + + @Test + fun addressee_lockLabel_storedCorrectly() { + val raw = "data:,v=1&label=MyKey&type=pw" + val addressee = addressee(lockLabel = raw) + assertEquals(raw, addressee.lockLabel) + } + + @Test + fun addressee_lockType_storedCorrectly() { + val addressee = addressee(lockType = "PASSWORD") + assertEquals("PASSWORD", addressee.lockType) + } + + @Test + fun addressee_passwordRecipient_lockLabelAndTypeSet() { + val addressee = + Addressee( + data = ByteArray(0), + identifier = "MyKey", + serialNumber = null, + givenName = null, + surname = null, + certType = CertType.PasswordType, + validTo = null, + concatKDFAlgorithmURI = null, + lockLabel = "data:,v=1&label=MyKey&type=pw", + lockType = "PASSWORD", + ) + assertEquals("data:,v=1&label=MyKey&type=pw", addressee.lockLabel) + assertEquals("PASSWORD", addressee.lockType) + assertEquals(CertType.PasswordType, addressee.certType) + assertEquals("MyKey", addressee.identifier) + } + + @Test + fun addressee_otherFields_unaffectedByNewFields() { + val addressee = addressee(lockLabel = "label", lockType = "TYPE") + assertEquals("47101010033", addressee.identifier) + assertEquals("Test", addressee.givenName) + assertEquals("User", addressee.surname) + assertEquals(CertType.IDCardType, addressee.certType) + assertNull(addressee.validTo) + } + + @Test + fun addressee_isSerializable() { + val mockAddressee = + Addressee( + data = byteArrayOf(1, 2, 3, 4), + identifier = "47101010033", + serialNumber = "10060701", + givenName = "Test", + surname = "User", + certType = CertType.ESealType, + validTo = Date(1_700_000_000_000L), + concatKDFAlgorithmURI = "", + lockLabel = "data:,v=1&label=MyKey&type=pw", + lockType = "PASSWORD", + ) + + val bytes = + ByteArrayOutputStream().use { buffer -> + ObjectOutputStream(buffer).use { it.writeObject(mockAddressee) } + buffer.toByteArray() + } + val getAddressee = + ObjectInputStream(ByteArrayInputStream(bytes)).use { it.readObject() as Addressee } + + assertEquals(mockAddressee, getAddressee) + assertArrayEquals(mockAddressee.data, getAddressee.data) + } + + private fun addressee( + lockLabel: String? = null, + lockType: String? = null, + ) = Addressee( + data = ByteArray(0), + identifier = "47101010033", + serialNumber = null, + givenName = "Test", + surname = "User", + certType = CertType.IDCardType, + validTo = null, + concatKDFAlgorithmURI = null, + lockLabel = lockLabel, + lockType = lockType, + ) +} diff --git a/crypto-lib/src/test/kotlin/ee/ria/DigiDoc/cryptolib/exception/WrongPasswordExceptionTest.kt b/crypto-lib/src/test/kotlin/ee/ria/DigiDoc/cryptolib/exception/WrongPasswordExceptionTest.kt new file mode 100644 index 000000000..6bc4ac096 --- /dev/null +++ b/crypto-lib/src/test/kotlin/ee/ria/DigiDoc/cryptolib/exception/WrongPasswordExceptionTest.kt @@ -0,0 +1,54 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.cryptolib.exception + +import ee.ria.cdoc.CDocException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class WrongPasswordExceptionTest { + @Test + fun wrongPasswordException_exceptionMessage_passwordIsWrong() { + val cause = makeCDocException() + val exception = WrongPasswordException(cause) + assertEquals("Wrong password", exception.message) + } + + @Test + fun wrongPasswordException_cause_isCDocException() { + val cause = makeCDocException() + val exception = WrongPasswordException(cause) + assertSame(cause, exception.cause) + } + + @Test + fun wrongPasswordException_isCryptoException() { + val cause = makeCDocException() + val exception = WrongPasswordException(cause) + assertTrue(CryptoException::class.java.isAssignableFrom(exception.javaClass)) + } + + @Test + fun wrongPasswordException_isException() { + val cause = makeCDocException() + val exception = WrongPasswordException(cause) + assertTrue(Exception::class.java.isAssignableFrom(exception.javaClass)) + } + + @Test + fun wrongPasswordException_defaultConstructor_messageIsWrongPassword() { + val exception = WrongPasswordException() + assertEquals("Wrong password", exception.message) + } + + private fun makeCDocException( + code: Int = -109, + message: String = "Wrong key", + ): CDocException { + val constructor = CDocException::class.java.getDeclaredConstructor(Int::class.java, String::class.java) + constructor.isAccessible = true + return constructor.newInstance(code, message) as CDocException + } +}