diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4b9084a0a..825bd7145 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -188,6 +188,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 fd0cf6cb8..59ef4600e 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 @@ -26,6 +26,7 @@ import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -50,6 +51,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -78,7 +80,10 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import ee.ria.DigiDoc.R import ee.ria.DigiDoc.cryptolib.Addressee +import ee.ria.DigiDoc.domain.model.settings.CDOCSetting +import ee.ria.DigiDoc.ui.component.crypto.EncryptPasswordDialog import ee.ria.DigiDoc.ui.component.crypto.bottombar.EncryptBottomBar +import ee.ria.DigiDoc.ui.component.crypto.bottombar.EncryptButtonBottomBar import ee.ria.DigiDoc.ui.component.crypto.bottomsheet.RecipientBottomSheet import ee.ria.DigiDoc.ui.component.menu.SettingsMenuBottomSheet import ee.ria.DigiDoc.ui.component.shared.InvisibleElement @@ -86,6 +91,7 @@ import ee.ria.DigiDoc.ui.component.shared.LoadingScreen import ee.ria.DigiDoc.ui.component.shared.MessageDialog import ee.ria.DigiDoc.ui.component.shared.PreventResize import ee.ria.DigiDoc.ui.component.shared.Recipient +import ee.ria.DigiDoc.ui.component.shared.TabView import ee.ria.DigiDoc.ui.component.shared.TopBar import ee.ria.DigiDoc.ui.theme.Dimensions.SPadding import ee.ria.DigiDoc.ui.theme.Dimensions.XSPadding @@ -100,8 +106,10 @@ import ee.ria.DigiDoc.utils.accessibility.AccessibilityUtil.Companion.sendAccess import ee.ria.DigiDoc.utils.extensions.reachedBottom import ee.ria.DigiDoc.utils.snackbar.SnackBarManager import ee.ria.DigiDoc.utils.snackbar.SnackBarManager.showMessage +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 import ee.ria.DigiDoc.viewmodel.shared.SharedContainerViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedMenuViewModel import ee.ria.DigiDoc.viewmodel.shared.SharedRecipientViewModel @@ -121,12 +129,11 @@ fun EncryptRecipientScreen( sharedRecipientViewModel: SharedRecipientViewModel, encryptRecipientViewModel: EncryptRecipientViewModel = hiltViewModel(), ) { + val logTag = "EncryptRecipientScreen" val context = LocalContext.current val scope = rememberCoroutineScope() - val focusManager = LocalFocusManager.current - val snackBarHostState = remember { SnackbarHostState() } val snackBarScope = rememberCoroutineScope() @@ -135,6 +142,7 @@ fun EncryptRecipientScreen( val showLoading = remember { mutableStateOf(false) } val isSettingsMenuBottomSheetVisible = rememberSaveable { mutableStateOf(false) } + val showPasswordDialog = rememberSaveable { mutableStateOf(false) } val recipientAddedSuccess = remember { mutableStateOf(false) } val recipientAddedSuccessText = stringResource(id = R.string.crypto_recipients_recipient_add_success) @@ -175,7 +183,19 @@ fun EncryptRecipientScreen( sendAccessibilityEvent(context, getAccessibilityEventType(), recipientRemovalCancelled) } - val listState = rememberLazyListState() + val encryptViewModel: EncryptViewModel = + hiltViewModel( + viewModelStoreOwner = + remember { + navController.getBackStackEntry(Route.Encrypt.route) + }, + ) + val isCdoc2 = encryptViewModel.cdocSetting == CDOCSetting.CDOC2 + var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) } + + val tabRecipientTitle = stringResource(R.string.crypto_encrypt_tab_recipient) + val tabPasswordTitle = stringResource(R.string.crypto_encrypt_tab_password) + var expanded by rememberSaveable { mutableStateOf(false) } val searchText by encryptRecipientViewModel.searchText.collectAsState() val recipientList by encryptRecipientViewModel.recipientList.collectAsState() @@ -294,20 +314,31 @@ fun EncryptRecipientScreen( }, bottomBar = { if (cryptoContainer != null) { - EncryptBottomBar( - modifier = modifier, - isEncryptButtonEnabled = encryptionButtonEnabled.value, - onEncryptClick = { - if (encryptionButtonEnabled.value) { - encryptionButtonEnabled.value = false - showLoading.value = true - scope.launch(Main) { - encryptRecipientViewModel.encryptContainer(sharedContainerViewModel) - showLoading.value = false + if (isCdoc2 && selectedTabIndex == 1) { + EncryptButtonBottomBar( + modifier = modifier, + encryptButtonIcon = R.drawable.ic_m3_arrow_forward_48dp_wght400, + encryptButtonName = R.string.next_button, + encryptButtonContentDescription = R.string.next_button, + isEncryptButtonEnabled = true, + onEncryptButtonClick = { showPasswordDialog.value = true }, + ) + } else { + EncryptBottomBar( + modifier = modifier, + isEncryptButtonEnabled = encryptionButtonEnabled.value, + onEncryptClick = { + if (encryptionButtonEnabled.value) { + encryptionButtonEnabled.value = false + showLoading.value = true + scope.launch(Main) { + encryptRecipientViewModel.encryptContainer(sharedContainerViewModel) + showLoading.value = false + } } - } - }, - ) + }, + ) + } } }, ) { paddingValues -> @@ -316,268 +347,79 @@ fun EncryptRecipientScreen( isBottomSheetVisible = isSettingsMenuBottomSheetVisible, ) - Column( - modifier = - modifier - .padding(paddingValues) - .fillMaxWidth(), - horizontalAlignment = Alignment.Start, - ) { - if (!expanded) { - Text( - text = stringResource(id = R.string.crypto_container_recipients_title), - maxLines = 2, - modifier = - modifier - .fillMaxWidth() - .padding(SPadding) - .semantics { heading() } - .focusable(enabled = true) - .focusTarget() - .focusProperties { canFocus = true }, - textAlign = TextAlign.Start, - style = MaterialTheme.typography.headlineSmall, - ) - } - val searchBarPadding = - if (!expanded) { - SPadding - } else { - zeroPadding - } - SearchBar( - modifier = - modifier - .padding(horizontal = searchBarPadding), - inputField = { - SearchBarDefaults.InputField( - modifier = - modifier - .fillMaxWidth() - .wrapContentHeight(), - query = searchText, - onQueryChange = encryptRecipientViewModel::onSearchTextChange, - onSearch = { - if (searchText.isDigitsOnly() && - searchText.length == 11 && - !PersonalCodeValidator.isPersonalCodeValid(searchText) - ) { - showMessage(invalidPersonalCodeMessage) - return@InputField - } - encryptRecipientViewModel.onQueryTextChange(searchText) - focusManager.clearFocus() - }, - expanded = expanded, - enabled = true, - placeholder = { - PreventResize { - Text(stringResource(id = R.string.crypto_recipients_search)) - } - }, - leadingIcon = { - Icon( - modifier = - modifier - .size(iconSizeXXS), - imageVector = ImageVector.vectorResource(R.drawable.ic_m3_search_48dp_wght400), - contentDescription = null, - ) - }, - trailingIcon = { - if (expanded) { - IconButton( - modifier = - modifier - .padding(end = XSPadding) - .size(iconSizeXXS) - .testTag("searchCancelButton"), - onClick = dismissSearch, - content = { - Icon( - imageVector = - ImageVector.vectorResource( - R.drawable.ic_m3_close_48dp_wght400, - ), - contentDescription = - stringResource( - id = R.string.crypto_recipients_search_cancel, - ), - ) - }, - ) - } - }, - onExpandedChange = { expanded = it }, - colors = inputFieldColors(), - interactionSource = null, - ) - }, + @Composable + fun RecipientTab(tabModifier: Modifier) { + RecipientTabContent( + modifier = tabModifier, expanded = expanded, onExpandedChange = { expanded = it }, - ) { - LazyColumn( - state = listState, - modifier = modifier.testTag("lazyColumnScrollView"), - ) { - if (recipientList.isNotEmpty()) { - item { - HorizontalDivider( - modifier = - modifier - .fillMaxWidth() - .padding(SPadding) - .height(dividerHeight), - ) - } - items(recipientList) { recipient -> - Recipient( - recipient = recipient, - isMoreOptionsButtonShown = false, - onClick = { - encryptRecipientViewModel.addRecipientToContainer( - recipient, - sharedContainerViewModel, - ) - }, - ) - HorizontalDivider( - modifier = - modifier - .fillMaxWidth() - .padding(SPadding) - .height(dividerHeight), - ) - } - } else if (hasSearched) { - item { - Box( - modifier = - modifier - .fillParentMaxSize() - .padding(SPadding), - contentAlignment = Alignment.Center, - ) { - Text( - modifier = - modifier - .testTag("encryptRecipientsListEmpty"), - textAlign = TextAlign.Center, - text = stringResource(id = R.string.crypto_recipients_search_empty), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyLarge, - ) - } - } - } - if (containerRecipientList.value.isNotEmpty()) { - item { - Text( - modifier = - modifier - .padding(horizontal = SPadding) - .padding(top = SPadding) - .semantics { - heading() - testTagsAsResourceId = true - }.testTag("encryptRecentlyAddedRecipientsListTitle"), - text = stringResource(R.string.crypto_container_latest_recipients_title), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Start, - ) - } - items(containerRecipientList.value) { recipient -> - Recipient( - recipient = recipient, - isMoreOptionsButtonShown = true, - onClick = { - clickedRecipient.value = recipient - showRecipientBottomSheet.value = true - }, - ) - HorizontalDivider( - modifier = - modifier - .fillMaxWidth() - .padding(SPadding) - .height(dividerHeight), - ) - } - } + 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 + }, + ) + } - item { - Spacer( - modifier = modifier.height(invisibleElementHeight), - ) - if (listState.reachedBottom()) { - InvisibleElement(modifier = modifier) - } - } - } + if (isCdoc2) { + Column( + modifier = modifier.padding(paddingValues).fillMaxSize(), + ) { + TabView( + modifier = modifier, + testTag = "encryptRecipientTabView", + selectedTabIndex = selectedTabIndex, + onTabSelected = { index -> + selectedTabIndex = index + if (index != 0) expanded = false + }, + tabItems = + listOf( + Pair(tabRecipientTitle) { + RecipientTab(Modifier.fillMaxSize()) + }, + Pair(tabPasswordTitle) { + PasswordTabContent(modifier = Modifier.fillMaxSize()) + }, + ), + ) } - if (!expanded) { - LazyColumn( - state = listState, - modifier = modifier.testTag("lazyColumnScrollView"), - ) { - item { - Text( - modifier = - modifier - .padding(horizontal = SPadding) - .padding(top = SPadding) - .semantics { - heading() - testTagsAsResourceId = true - }.testTag("encryptRecipientsDescription"), - text = stringResource(R.string.crypto_recipients_description), - textAlign = TextAlign.Start, - ) - } - if (containerRecipientList.value.isNotEmpty()) { - item { - Text( - modifier = - modifier - .padding(horizontal = SPadding) - .padding(top = SPadding) - .semantics { - heading() - testTagsAsResourceId = true - }.testTag("encryptRecipientsListTitle"), - text = stringResource(R.string.crypto_container_added_recipients_title), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Start, - ) - } - items(containerRecipientList.value) { recipient -> - Recipient( - recipient = recipient, - onClick = { - clickedRecipient.value = recipient - showRecipientBottomSheet.value = true - }, - ) - HorizontalDivider( - modifier = - modifier - .fillMaxWidth() - .padding(SPadding) - .height(dividerHeight), - ) - } + } else { + RecipientTab(Modifier.padding(paddingValues).fillMaxWidth()) + } - item { - Spacer( - modifier = modifier.height(invisibleElementHeight), - ) - if (listState.reachedBottom()) { - InvisibleElement(modifier = modifier) - } - } + if (showPasswordDialog.value) { + EncryptPasswordDialog( + modifier = modifier, + onDismiss = { 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 } - } - } + }, + ) } if (openRemoveRecipientDialog.value) { @@ -607,7 +449,7 @@ fun EncryptRecipientScreen( ) } - if (showLoading.value == true) { + if (showLoading.value) { LoadingScreen(modifier = modifier) } @@ -624,6 +466,273 @@ fun EncryptRecipientScreen( } } +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun RecipientTabContent( + modifier: Modifier = Modifier, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + searchText: String, + onSearchTextChange: (String) -> Unit, + invalidPersonalCodeMessage: String, + onSearch: (String) -> Unit, + onDismissSearch: () -> Unit, + recipientList: List, + hasSearched: Boolean, + containerRecipientList: List, + onAddRecipientToContainer: (Addressee) -> Unit, + onRecipientClick: (Addressee) -> Unit, +) { + val focusManager = LocalFocusManager.current + val searchListState = rememberLazyListState() + val mainListState = rememberLazyListState() + + Column( + modifier = modifier, + horizontalAlignment = Alignment.Start, + ) { + if (!expanded) { + Text( + text = stringResource(id = R.string.crypto_container_recipients_title), + maxLines = 2, + modifier = + Modifier + .fillMaxWidth() + .padding(SPadding) + .semantics { heading() } + .focusable(enabled = true) + .focusTarget() + .focusProperties { canFocus = true }, + textAlign = TextAlign.Start, + style = MaterialTheme.typography.headlineSmall, + ) + } + val searchBarPadding = if (!expanded) SPadding else zeroPadding + SearchBar( + modifier = Modifier.padding(horizontal = searchBarPadding), + inputField = { + SearchBarDefaults.InputField( + modifier = Modifier.fillMaxWidth().wrapContentHeight(), + query = searchText, + onQueryChange = onSearchTextChange, + onSearch = { + if (searchText.isDigitsOnly() && + searchText.length == 11 && + !PersonalCodeValidator.isPersonalCodeValid(searchText) + ) { + showMessage(invalidPersonalCodeMessage) + return@InputField + } + onSearch(searchText) + focusManager.clearFocus() + }, + expanded = expanded, + enabled = true, + placeholder = { + PreventResize { + Text(stringResource(id = R.string.crypto_recipients_search)) + } + }, + leadingIcon = { + Icon( + modifier = Modifier.size(iconSizeXXS), + imageVector = ImageVector.vectorResource(R.drawable.ic_m3_search_48dp_wght400), + contentDescription = null, + ) + }, + trailingIcon = { + if (expanded) { + IconButton( + modifier = + Modifier + .padding(end = XSPadding) + .size(iconSizeXXS) + .testTag("searchCancelButton"), + onClick = onDismissSearch, + content = { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_m3_close_48dp_wght400), + contentDescription = + stringResource( + id = R.string.crypto_recipients_search_cancel, + ), + ) + }, + ) + } + }, + onExpandedChange = onExpandedChange, + colors = inputFieldColors(), + interactionSource = null, + ) + }, + expanded = expanded, + onExpandedChange = onExpandedChange, + ) { + LazyColumn( + state = searchListState, + modifier = Modifier.testTag("lazyColumnScrollView"), + ) { + if (recipientList.isNotEmpty()) { + item { + HorizontalDivider( + modifier = + Modifier + .fillMaxWidth() + .padding(SPadding) + .height(dividerHeight), + ) + } + items(recipientList) { recipient -> + RecipientItem( + recipient = recipient, + isMoreOptionsButtonShown = false, + onClick = { onAddRecipientToContainer(it) }, + ) + } + } else if (hasSearched) { + item { + Box( + modifier = + Modifier + .fillParentMaxSize() + .padding(SPadding), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.testTag("encryptRecipientsListEmpty"), + textAlign = TextAlign.Center, + text = stringResource(id = R.string.crypto_recipients_search_empty), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + if (containerRecipientList.isNotEmpty()) { + item { + Text( + modifier = + Modifier + .padding(horizontal = SPadding) + .padding(top = SPadding) + .semantics { + heading() + testTagsAsResourceId = true + }.testTag("encryptRecentlyAddedRecipientsListTitle"), + text = stringResource(R.string.crypto_container_latest_recipients_title), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + ) + } + items(containerRecipientList) { recipient -> + RecipientItem( + recipient = recipient, + onClick = { onRecipientClick(it) }, + ) + } + } + item { + Spacer(modifier = Modifier.height(invisibleElementHeight)) + if (searchListState.reachedBottom()) { + InvisibleElement(modifier = Modifier) + } + } + } + } + if (!expanded) { + LazyColumn( + state = mainListState, + modifier = Modifier.testTag("lazyColumnScrollView"), + ) { + item { + Text( + modifier = + Modifier + .padding(horizontal = SPadding) + .padding(top = SPadding) + .semantics { + heading() + testTagsAsResourceId = true + }.testTag("encryptRecipientsDescription"), + text = stringResource(R.string.crypto_recipients_description), + textAlign = TextAlign.Start, + ) + } + if (containerRecipientList.isNotEmpty()) { + item { + Text( + modifier = + Modifier + .padding(horizontal = SPadding) + .padding(top = SPadding) + .semantics { + heading() + testTagsAsResourceId = true + }.testTag("encryptRecipientsListTitle"), + text = stringResource(R.string.crypto_container_added_recipients_title), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + ) + } + items(containerRecipientList) { recipient -> + RecipientItem( + recipient = recipient, + onClick = { onRecipientClick(it) }, + ) + } + item { + Spacer(modifier = Modifier.height(invisibleElementHeight)) + if (mainListState.reachedBottom()) { + InvisibleElement(modifier = Modifier) + } + } + } + } + } + } +} + +@Composable +private fun PasswordTabContent(modifier: Modifier = Modifier) { + LazyColumn(modifier = modifier) { + item { + Text( + modifier = + Modifier + .padding(horizontal = SPadding) + .padding(top = SPadding) + .semantics { + heading() + testTagsAsResourceId = true + }.testTag("encryptPasswordDescription"), + text = stringResource(R.string.crypto_password_encryption_description), + textAlign = TextAlign.Start, + ) + } + } +} + +@Composable +private fun RecipientItem( + recipient: Addressee, + isMoreOptionsButtonShown: Boolean = true, + onClick: (Addressee) -> Unit = {}, +) { + Recipient( + recipient = recipient, + isMoreOptionsButtonShown = isMoreOptionsButtonShown, + onClick = onClick, + ) + HorizontalDivider( + modifier = + Modifier + .fillMaxWidth() + .padding(SPadding) + .height(dividerHeight), + ) +} + @Preview(showBackground = true) @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable 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 new file mode 100644 index 000000000..34ada9ff2 --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/DecryptPasswordDialog.kt @@ -0,0 +1,126 @@ +/* + * 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 android.content.res.Configuration +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +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 ee.ria.DigiDoc.R +import ee.ria.DigiDoc.ui.component.shared.PrimaryTextField +import ee.ria.DigiDoc.ui.theme.Dimensions.MPadding +import ee.ria.DigiDoc.ui.theme.RIADigiDocTheme +import ee.ria.DigiDoc.utils.extensions.notAccessible + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun DecryptPasswordDialog( + modifier: Modifier = Modifier, + keyLabel: String, + onDismiss: () -> Unit = {}, + onDecrypt: (password: String) -> Unit = {}, +) { + var password by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue("")) + } + + val containerKeyLabelTitle = stringResource(R.string.crypto_password_key_label_placeholder) + val containerPasswordLabel = stringResource(R.string.crypto_password_enter) + + 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() + .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, + ), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun DecryptPasswordDialogPreview() { + RIADigiDocTheme { + DecryptPasswordDialog(keyLabel = "Allkirjastamata lepingud 2026") + } +} 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 7b2b1b1b7..84034feca 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,7 +87,9 @@ import androidx.navigation.compose.rememberNavController import ee.ria.DigiDoc.R 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 +126,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 +154,7 @@ fun EncryptNavigation( encryptViewModel: EncryptViewModel = hiltViewModel(), encryptRecipientViewModel: EncryptRecipientViewModel = hiltViewModel(), ) { + val logTag = "EncryptNavigation" val cryptoContainer by sharedContainerViewModel.cryptoContainer.asFlow().collectAsState(null) val shouldResetContainer by encryptViewModel.shouldResetCryptoContainer.asFlow().collectAsState(false) val context = LocalContext.current @@ -266,6 +270,8 @@ fun EncryptNavigation( val listState = rememberLazyListState() val showContainerCloseConfirmationDialog = rememberSaveable { mutableStateOf(false) } + val showDecryptPasswordDialog = rememberSaveable { mutableStateOf(false) } + var decryptPasswordKeyLabel by rememberSaveable { mutableStateOf("") } val showContainerBottomSheet = remember { mutableStateOf(false) } val showDataFileBottomSheet = remember { mutableStateOf(false) } @@ -415,6 +421,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) } @@ -587,6 +608,17 @@ fun EncryptNavigation( } } + LaunchedEffect(sharedContainerViewModel.containerEncrypted) { + sharedContainerViewModel.containerEncrypted.asFlow().collect { containerEncrypted -> + if (containerEncrypted == true) { + withContext(Main) { + selectedCryptoContainerTabIndex.intValue = 1 + sharedContainerViewModel.resetContainerEncrypted() + } + } + } + } + LaunchedEffect(filesAdded) { when { filesAdded == 1 -> showMessage(context, R.string.file_added) @@ -792,16 +824,14 @@ fun EncryptNavigation( rightActionButtonContentDescription = rightActionButtonName, onLeftActionButtonClick = onSignActionClick, onRightActionButtonClick = { - if (encryptViewModel.isDecryptButtonShown(cryptoContainer, isNestedContainer)) { - showLoadingScreen.value = true - navController.navigate(Route.DecryptScreen.route) - showLoadingScreen.value = false - } else if (encryptViewModel.isEncryptButtonShown( - cryptoContainer, - isNestedContainer, - ) - ) { - onEncryptClick() + when { + encryptViewModel.isDecryptButtonShown(cryptoContainer, isNestedContainer) -> { + onDecryptActionClick() + } + + encryptViewModel.isEncryptButtonShown(cryptoContainer, isNestedContainer) -> { + onEncryptClick() + } } }, onMoreOptionsActionButtonClick = { @@ -1065,6 +1095,52 @@ fun EncryptNavigation( } } + if (showDecryptPasswordDialog.value) { + DecryptPasswordDialog( + modifier = modifier, + keyLabel = decryptPasswordKeyLabel, + onDismiss = { 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 + } + } + }, + ) + } + SivaConfirmationDialog( showDialog = showSivaDialog, modifier = modifier, @@ -1199,9 +1275,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 new file mode 100644 index 000000000..b3b4ca483 --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/crypto/EncryptPasswordDialog.kt @@ -0,0 +1,266 @@ +/* + * 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 android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +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.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex +import androidx.compose.ui.text.input.ImeAction +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 ee.ria.DigiDoc.R +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 +fun EncryptPasswordDialog( + modifier: Modifier = Modifier, + onDismiss: () -> Unit = {}, + onEncrypt: (keyLabel: String, password: String) -> Unit = { _, _ -> }, +) { + var keyLabel by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue("")) + } + var password by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue("")) + } + var repeatPassword by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue("")) + } + + val keyLabelLabel = stringResource(R.string.crypto_password_key_label_placeholder) + val passwordLabel = stringResource(R.string.crypto_password_enter) + val repeatPasswordLabel = stringResource(R.string.crypto_password_repeat) + + val requirementsTitle = stringResource(R.string.crypto_password_requirements_title) + val requirementLength = stringResource(R.string.crypto_password_requirement_length) + 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 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)) + + 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, + ) + } + } + + 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, + isError = passwordIsError, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ), + ) + + Spacer(modifier = modifier.height(SPadding)) + + val requirementColor: Color = + if (passwordIsError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSecondaryContainer + } + + 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(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, + ), + ) + + if (repeatPasswordIsError) { + Text( + modifier = modifier.fillMaxWidth().padding(top = XSPadding), + text = stringResource(R.string.crypto_password_mismatch), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun EncryptPasswordDialogPreview() { + RIADigiDocTheme { + EncryptPasswordDialog() + } +} 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 6f1e7033f..38f62cf8d 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 @@ -48,68 +49,80 @@ data class RecipientDetailItem( 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( - RecipientDetailItem( + 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 validToFormatted = recipient.validTo?.let { dateFormat.format(it) } + return listOf( + item( icon = R.drawable.ic_m3_expand_content_48dp_wght400, label = R.string.recipient_details_name_label, value = recipientFormattedName, certificate = recipient.data.x509Certificate(), - 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", ), ) + } + + @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/ui/component/shared/CryptoDataFilesLocked.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/CryptoDataFilesLocked.kt index 6640411e4..722a776bd 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/CryptoDataFilesLocked.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/CryptoDataFilesLocked.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview import ee.ria.DigiDoc.R +import ee.ria.DigiDoc.ui.theme.Dimensions.MSPadding import ee.ria.DigiDoc.ui.theme.Dimensions.SPadding import ee.ria.DigiDoc.ui.theme.Dimensions.XSPadding import ee.ria.DigiDoc.ui.theme.Dimensions.iconSizeXXS @@ -99,7 +100,7 @@ fun CryptoDataFilesLocked(modifier: Modifier = Modifier) { Spacer(modifier = modifier.width(SPadding)) - Column(modifier = modifier.weight(1f)) { + Column(modifier = modifier.weight(1f).padding(bottom = MSPadding)) { Text( modifier = modifier diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/PrimaryTextField.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/PrimaryTextField.kt index 885bdd3d7..8c6d766c8 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/PrimaryTextField.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/PrimaryTextField.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -67,11 +68,14 @@ import androidx.compose.ui.text.style.TextAlign import ee.ria.DigiDoc.R import ee.ria.DigiDoc.ui.theme.Dimensions.MSPadding import ee.ria.DigiDoc.ui.theme.Dimensions.XSPadding +import ee.ria.DigiDoc.utils.accessibility.AccessibilityUtil.Companion.getAccessibilityEventType import ee.ria.DigiDoc.utils.accessibility.AccessibilityUtil.Companion.isTalkBackEnabled +import ee.ria.DigiDoc.utils.accessibility.AccessibilityUtil.Companion.sendAccessibilityEvent import ee.ria.DigiDoc.utils.extensions.notAccessible import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds @Composable fun PrimaryTextField( @@ -111,6 +115,12 @@ fun PrimaryTextField( val clearButtonText = stringResource(R.string.clear_text) val buttonName = stringResource(R.string.button_name) + LaunchedEffect(errorText) { + if (errorText.isNotEmpty()) { + sendAccessibilityEvent(context, getAccessibilityEventType(), errorText) + } + } + Column(modifier = modifier) { Row( modifier = Modifier.fillMaxWidth(), @@ -128,11 +138,18 @@ fun PrimaryTextField( editingStarted = false } }.semantics { - if (readDigitByDigit && value.text.isNotEmpty() && value.text.all { it.isDigit() }) { - contentDescription = value.text.split("").joinToString(" ") - } else if (isPasswordText) { - contentDescription = "" - } + contentDescription = + if (readDigitByDigit && value.text.isNotEmpty() && value.text.all { it.isDigit() }) { + value.text.split("").joinToString(" ") + } else if (isPasswordText) { + "" + } else { + if (description.isNotEmpty()) { + "$label, $description: ${value.text}" + } else { + "$label: ${value.text}" + } + } testTagsAsResourceId = true }.then(if (testTag.isNotEmpty()) Modifier.testTag(testTag) else Modifier), enabled = enabled, @@ -199,7 +216,7 @@ fun PrimaryTextField( scope.launch(Main) { focusRequester.requestFocus() focusManager.clearFocus() - delay(200) + delay(200.milliseconds) focusRequester.requestFocus() } }) { diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/Recipient.kt b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/Recipient.kt index 16ac64c8e..e2f23af1a 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/Recipient.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/ui/component/shared/Recipient.kt @@ -85,28 +85,27 @@ fun Recipient( val recipientText = stringResource(id = R.string.crypto_recipient_title) val buttonName = stringResource(id = R.string.button_name) + val isPasswordRecipient = recipient.certType == CertType.PasswordType val nameText = - if (PersonalCodeValidator.isPersonalCodeValid(recipient.identifier)) { - formatName(recipient.surname, recipient.givenName, recipient.identifier) - } else { - formatCompanyName(recipient.identifier, recipient.serialNumber) + when { + isPasswordRecipient -> recipient.identifier + PersonalCodeValidator.isPersonalCodeValid(recipient.identifier) -> + formatName(recipient.surname, recipient.givenName, recipient.identifier) + else -> formatCompanyName(recipient.identifier, recipient.serialNumber) } - val certTypeText = getRecipientCertTypeText(LocalContext.current, recipient.certType) + val certTypeText = getRecipientCertTypeText(context, recipient.certType) val certValidTo = - recipient.validTo - ?.let { - dateFormat.format( - it, - ) - }?.let { - stringResource( - R.string.crypto_cert_valid_to, - it, - ) - } ?: "" + if (isPasswordRecipient) { + "" + } else { + recipient.validTo + ?.let { dateFormat.format(it) } + ?.let { stringResource(R.string.crypto_cert_valid_to, it) } + ?: "" + } val iconRes = - if (recipient.surname.isNullOrEmpty() && recipient.givenName.isNullOrEmpty()) { + if (isPasswordRecipient || (recipient.surname.isNullOrEmpty() && recipient.givenName.isNullOrEmpty())) { R.drawable.ic_m3_domain_48dp_wght400 } else { R.drawable.ic_m3_encrypted_48dp_wght400 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/utils/libdigidoc/RecipientCertTypeUtil.kt b/app/src/main/kotlin/ee/ria/DigiDoc/utils/libdigidoc/RecipientCertTypeUtil.kt index 5703d7cd5..e0fa0cae5 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/utils/libdigidoc/RecipientCertTypeUtil.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/utils/libdigidoc/RecipientCertTypeUtil.kt @@ -38,5 +38,6 @@ object RecipientCertTypeUtil { CertType.MobileIDType -> context.getString(R.string.crypto_container_cert_type_mobile_id_type) CertType.SmartIDType -> context.getString(R.string.crypto_container_cert_type_smart_id_type) CertType.ESealType -> context.getString(R.string.crypto_container_cert_type_e_seal_type) + CertType.PasswordType -> context.getString(R.string.crypto_recipient_type_password) } } 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/EncryptViewModel.kt b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/EncryptViewModel.kt index 32a3891f9..325a5e45f 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/EncryptViewModel.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/viewmodel/EncryptViewModel.kt @@ -34,6 +34,8 @@ import ee.ria.DigiDoc.R import ee.ria.DigiDoc.common.Constant.CDOC1_EXTENSION import ee.ria.DigiDoc.cryptolib.CDOC2Settings import ee.ria.DigiDoc.cryptolib.CryptoContainer +import ee.ria.DigiDoc.domain.model.settings.CDOCSetting +import ee.ria.DigiDoc.domain.preferences.DataStore import ee.ria.DigiDoc.domain.repository.fileopening.FileOpeningRepository import ee.ria.DigiDoc.domain.repository.siva.SivaRepository import ee.ria.DigiDoc.libdigidoclib.SignedContainer @@ -57,9 +59,12 @@ class EncryptViewModel private val contentResolver: ContentResolver, private val fileOpeningRepository: FileOpeningRepository, private val cdoc2Settings: CDOC2Settings, + private val dataStore: DataStore, ) : ViewModel() { private val logTag = javaClass.simpleName + val cdocSetting: CDOCSetting = dataStore.getCdocSetting(false) + private val _shouldResetCryptoContainer = MutableLiveData(false) val shouldResetCryptoContainer: LiveData = _shouldResetCryptoContainer 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 7cf5bad77..e5f2ff0c5 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 @@ -96,6 +96,9 @@ class SharedContainerViewModel private val _decryptIDCardStatus = MutableLiveData(null) val decryptIDCardStatus: LiveData = _decryptIDCardStatus + private val _containerEncrypted = MutableLiveData(null) + val containerEncrypted: LiveData = _containerEncrypted + private val _externalFileUris = MutableStateFlow>(listOf()) val externalFileUris: StateFlow> = _externalFileUris @@ -140,14 +143,22 @@ class SharedContainerViewModel fun setCryptoContainer( cryptoContainer: CryptoContainer?, overwriteContainer: Boolean = false, + containerEncrypted: Boolean = false, ) { _cryptoContainer.postValue(cryptoContainer) if (overwriteContainer) { removeLastContainer() } + if (containerEncrypted) { + _containerEncrypted.postValue(true) + } addNestedContainer(cryptoContainer) } + fun resetContainerEncrypted() { + _containerEncrypted.postValue(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 fe8481bab..db7046dc7 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -172,11 +172,29 @@ Krüpteeritud failid Ümbriku nimi Krüpteerimiseks tuleb lisada vähemalt üks adressaat – isik (isikukood) või asutus (registrikood, nimi või nimeosa), kellel on õigus andmeid dekrüpteerida. Lisa enda isikukood, kui soovid hiljem ümbrikku dekrüpteerida. + Krüpteeri adressaadi alusel + Krüpteeri parooliga + Parooliga krüpteerimine on mõeldud pikaajaliseks säilitamiseks. Parooli ei saa muuta ega taastada. + Võtme silt + Adressaadi nimi või ID + Salvestage parool kindlasti turvalisse kohta – ilma paroolita ei saa faili enam avada. + Dokumendi parool + Parooli nõuded + Pikkus: 20 – 64 tähemärki + Pikkus: 20 kuni 64 tähemärki + Sisaldab vähemalt ühte numbrit (0 – 9) + Sisaldab vähemalt ühte numbrit (0 kuni 9) + 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 @@ -397,10 +415,11 @@ Adressaadi andmed - Adressaat: - Adressaadi sertifikaadi väljaandja: - ConcatKDF referaatmeetod: - Adressaadi sertifikaadi aegumiskuupäev: + Adressaat + Adressaadi sertifikaadi väljaandja + ConcatKDF referaatmeetod + Adressaadi sertifikaadi aegumiskuupäev + Luku tüüp Hoiatused diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 802e6abc5..14f3ea00b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -172,11 +172,29 @@ Encrypted files Container name For encryption, at least one recipient must be included - the person (personal identification number) or authority (registration number, name or part of the name) authorised to decrypt the data. Add your own personal identification number if you want to decrypt the envelope later. + Encrypt based on recipient + Encrypt with password + Password encryption is intended for long-term storage. The password cannot be changed or recovered. + Key label + Recipient name or id + Be sure to save the password in a secure place - without the password, you won\'t be able to open the file again. + Document password + Password requirements + Length: 20 – 64 characters + Length: 20 to 64 characters + Contains at least one number (0 – 9) + Contains at least one number (0 to 9) + 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 @@ -397,10 +415,11 @@ Recipient details - Recipient: - Recipient\'s Certificate issuer: - ConcatKDF reference method: - Recipient\'s Certificate expiry date: + Recipient + Recipient\'s Certificate issuer + ConcatKDF reference method + Recipient\'s Certificate expiry date + 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/buildSrc/src/main/kotlin/ee/ria/DigiDoc/libcdoc/update/UpdateLibcdocTask.kt b/buildSrc/src/main/kotlin/ee/ria/DigiDoc/libcdoc/update/UpdateLibcdocTask.kt index 3560f423b..8b726a383 100644 --- a/buildSrc/src/main/kotlin/ee/ria/DigiDoc/libcdoc/update/UpdateLibcdocTask.kt +++ b/buildSrc/src/main/kotlin/ee/ria/DigiDoc/libcdoc/update/UpdateLibcdocTask.kt @@ -102,7 +102,7 @@ open class UpdateLibcdocTask : DefaultTask() { log("Updating from $zipFile") val cacheDir = File(outputDir, PREFIX + ABI_FILES[abi]) - unzip(zipFile, cacheDir) + unzip(zipFile, outputDir) generateAndCopyJar(cacheDir) copyNativeLibraries(cacheDir, abi) 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 a3e14ef1e..6fd3d85cd 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 @@ -210,4 +210,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/libs/libcdoc.jar b/crypto-lib/libs/libcdoc.jar index caf826337..8bab50b1a 100644 Binary files a/crypto-lib/libs/libcdoc.jar and b/crypto-lib/libs/libcdoc.jar differ 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 f8cbbfae7..f86a5e2f2 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 @@ -855,7 +855,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) @@ -885,7 +885,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) @@ -927,7 +927,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) @@ -953,7 +953,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) @@ -977,7 +977,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) @@ -994,7 +994,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) @@ -1192,7 +1192,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) } @@ -1240,7 +1240,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) @@ -1258,7 +1258,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/debug/jniLibs/arm64-v8a/libcdoc_java.so b/crypto-lib/src/debug/jniLibs/arm64-v8a/libcdoc_java.so index e7698e7bb..afede327b 100644 Binary files a/crypto-lib/src/debug/jniLibs/arm64-v8a/libcdoc_java.so and b/crypto-lib/src/debug/jniLibs/arm64-v8a/libcdoc_java.so differ diff --git a/crypto-lib/src/debug/jniLibs/armeabi-v7a/libcdoc_java.so b/crypto-lib/src/debug/jniLibs/armeabi-v7a/libcdoc_java.so index f3ab39942..01bdcb30a 100644 Binary files a/crypto-lib/src/debug/jniLibs/armeabi-v7a/libcdoc_java.so and b/crypto-lib/src/debug/jniLibs/armeabi-v7a/libcdoc_java.so differ diff --git a/crypto-lib/src/debug/jniLibs/x86_64/libcdoc_java.so b/crypto-lib/src/debug/jniLibs/x86_64/libcdoc_java.so index a6de9db61..cc889a6f0 100644 Binary files a/crypto-lib/src/debug/jniLibs/x86_64/libcdoc_java.so and b/crypto-lib/src/debug/jniLibs/x86_64/libcdoc_java.so differ diff --git a/crypto-lib/src/main/jniLibs/arm64-v8a/libcdoc_java.so b/crypto-lib/src/main/jniLibs/arm64-v8a/libcdoc_java.so index e7698e7bb..afede327b 100644 Binary files a/crypto-lib/src/main/jniLibs/arm64-v8a/libcdoc_java.so and b/crypto-lib/src/main/jniLibs/arm64-v8a/libcdoc_java.so differ diff --git a/crypto-lib/src/main/jniLibs/armeabi-v7a/libcdoc_java.so b/crypto-lib/src/main/jniLibs/armeabi-v7a/libcdoc_java.so index f3ab39942..01bdcb30a 100644 Binary files a/crypto-lib/src/main/jniLibs/armeabi-v7a/libcdoc_java.so and b/crypto-lib/src/main/jniLibs/armeabi-v7a/libcdoc_java.so differ diff --git a/crypto-lib/src/main/jniLibs/x86_64/libcdoc_java.so b/crypto-lib/src/main/jniLibs/x86_64/libcdoc_java.so index a6de9db61..cc889a6f0 100644 Binary files a/crypto-lib/src/main/jniLibs/x86_64/libcdoc_java.so and b/crypto-lib/src/main/jniLibs/x86_64/libcdoc_java.so differ 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 32ce1500c..aeffc0b3e 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 @@ -21,7 +21,7 @@ package ee.ria.DigiDoc.cryptolib -import ee.ria.cdoc.Recipient.parseLabel +import ee.ria.cdoc.Lock.parseLabel import org.bouncycastle.asn1.ASN1InputStream import org.bouncycastle.asn1.ASN1OctetString import org.bouncycastle.asn1.ASN1Sequence @@ -30,99 +30,115 @@ import org.bouncycastle.asn1.x500.style.BCStyle import org.bouncycastle.asn1.x500.style.IETFUtils import org.bouncycastle.asn1.x509.Extension import org.bouncycastle.asn1.x509.PolicyInformation -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?, -) : Serializable { - 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 +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 lockLabel: String? = null, + val lockType: String? = null, +) { + 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 = @@ -131,12 +147,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) } @@ -156,12 +170,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) } @@ -213,4 +225,33 @@ 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 && + lockLabel == other.lockLabel && + lockType == other.lockType + } + + override fun hashCode(): Int = + Objects.hash( + data.contentHashCode(), + identifier, + serialNumber, + givenName, + surname, + certType, + validTo, + concatKDFAlgorithmURI, + lockLabel, + lockType, + ) } diff --git a/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/CertType.kt b/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/CertType.kt index d4b957b3c..61aea0a20 100644 --- a/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/CertType.kt +++ b/crypto-lib/src/main/kotlin/ee/ria/DigiDoc/cryptolib/CertType.kt @@ -29,6 +29,7 @@ enum class CertType { MobileIDType, SmartIDType, ESealType, + PasswordType, } fun certType(policies: List): CertType { 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 e9e5fdbe1..383ec78d5 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 @@ -51,8 +52,10 @@ import ee.ria.cdoc.Configuration import ee.ria.cdoc.CryptoBackend import ee.ria.cdoc.DataBuffer import ee.ria.cdoc.FileInfo -import ee.ria.cdoc.ILogger 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 import ee.ria.cdoc.Recipient import kotlinx.coroutines.Dispatchers.IO @@ -63,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 @@ -164,13 +166,20 @@ 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, file: File, ): CryptoContainer { val dataFiles = ArrayList() - var recipients = ArrayList() + val recipients = ArrayList() if (file.extension == CDOC1_EXTENSION) { val cdoc1Container = openCDOC1(context, file) dataFiles.addAll(cdoc1Container.getDataFiles()) @@ -178,51 +187,84 @@ class CryptoContainer } val addressees = ArrayList() - val cdocReader = CDocReader.createReader(file.path, null, null, null) - debugLog(LOG_TAG, "Reader created: (version ${cdocReader.version})") - withContext(IO) { - cdocReader.locks.forEach { lock -> - if (lock.isCertificate) { - var concatKDFAlgorithmURI = "" - if (!lock.isRSA) { - concatKDFAlgorithmURI = lock.getString(Lock.Params.CONCAT_DIGEST) + val cdocReader = CDocReader.createReader(file.path, null, null, null) + debugLog(LOG_TAG, "Reader created: (version ${cdocReader.version})") + try { + cdocReader.locks.forEach { lock -> + if (lock.isCDoc1) { + val concatKDFAlgorithmURI = + if (!lock.isRSA) { + lock.getString( + Lock.Params.CONCAT_DIGEST, + ) + } else { + "" + } + addressees.add( + Addressee.fromLabel( + lock.label, + lock.getBytes(Lock.Params.CERT) ?: ByteArray(0), + concatKDFAlgorithmURI, + ), + ) + } else if (lock.isPKI) { + val data = + if (lock.type == Lock.Type.PUBLIC_KEY) { + lock.getBytes(Lock.Params.RCPT_KEY) ?: ByteArray(0) + } else { + ByteArray(0) + } + addressees.add( + Addressee.fromLabel(lock.label, data, ""), + ) + } else if (lock.isSymmetric) { + if (lock.type == Lock.Type.PASSWORD) { + addressees.add( + 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, + ), + ) + } else { + addressees.add( + Addressee.fromCN(lock.label, "", CertType.UnknownType, null, ByteArray(0)), + ) + } + } else { + addressees.add(Addressee.fromLabel("Unknown capsule", ByteArray(0), "")) } - addressees.add( - Addressee(lock.label, lock.getBytes(Lock.Params.CERT), concatKDFAlgorithmURI), - ) - } else if (lock.isPKI) { - addressees.add( - Addressee(lock.label, lock.getBytes(Lock.Params.RCPT_KEY), ""), - ) - } else if (lock.isSymmetric) { - addressees.add( - Addressee(lock.label, "", CertType.UnknownType, null, ByteArray(0)), - ) - } else { - addressees.add(Addressee("Unknown capsule", ByteArray(0), "")) } + } finally { + cdocReader.delete() } - cdocReader.delete() } - if (!recipients.isEmpty()) { - addressees.forEach { addressee -> - recipients.forEach { recipient -> - if (addressee.data.contentEquals(recipient.data)) { - recipient.concatKDFAlgorithmURI = addressee.concatKDFAlgorithmURI - } + val mergedRecipients = + if (recipients.isNotEmpty()) { + recipients.map { recipient -> + addressees + .firstOrNull { it.data.contentEquals(recipient.data) } + ?.let { recipient.copy(concatKDFAlgorithmURI = it.concatKDFAlgorithmURI) } + ?: recipient } + } else { + addressees } - } else { - recipients = addressees - } return create( context, file, dataFiles, - recipients, + ArrayList(mergedRecipients), decrypted = false, encrypted = true, ) @@ -245,7 +287,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) } } @@ -283,63 +325,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") + + 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") - if (cdocReader.beginDecryption(fmk) != 0L) { - throw CryptoException("Failed to begin 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 + } + + @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) + } + + debugLog(LOG_TAG, "Successfully encrypted '${file.name}'") + return open(context, file) + } - if (cdocReader.finishDecryption() != 0L) { - throw CryptoException("Failed to finish decryption") + @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) - return create( - context, - file, - dataFiles, - recipients, - decrypted = true, - encrypted = false, + 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") + } + }, ) } @@ -371,9 +523,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 -> @@ -390,39 +545,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) @@ -498,24 +622,22 @@ class CryptoContainer fun setLogging(isLoggingEnabled: Boolean) { if (isLoggingEnabled) { - logger.SetMinLogLevel(ILogger.LogLevel.LEVEL_TRACE) + logger.setMinLogLevel(LogLevel.LEVEL_TRACE) if (!loggingIsSet) { - ILogger.addLogger(logger) + CDoc.setLogger(logger) loggingIsSet = true } - val lgr = ILogger.getLogger() - - lgr.LogMessage( - ILogger.LogLevel.LEVEL_DEBUG, + CDoc.log( + LogLevel.LEVEL_DEBUG, "CryptoContainer", - 450, + 0, "Set libcdoc logging: true", ) } } - class JavaLogger : ILogger() { - override fun LogMessage( + class JavaLogger : Logger() { + override fun logMessage( level: LogLevel?, file: String?, line: Int, @@ -603,5 +725,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/main/res/values-et/strings.xml b/crypto-lib/src/main/res/values-et/strings.xml index b0e9a8cb6..014cefdfc 100644 --- a/crypto-lib/src/main/res/values-et/strings.xml +++ b/crypto-lib/src/main/res/values-et/strings.xml @@ -7,4 +7,5 @@ Mobile-ID Smart-ID Krüpteerimissertifikaat + Parool \ No newline at end of file diff --git a/crypto-lib/src/main/res/values/strings.xml b/crypto-lib/src/main/res/values/strings.xml index 83706e0e2..f5789599d 100644 --- a/crypto-lib/src/main/res/values/strings.xml +++ b/crypto-lib/src/main/res/values/strings.xml @@ -7,4 +7,5 @@ Mobile-ID Smart-ID Certificate for Encryption + Password \ No newline at end of file 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..f9cea41c7 --- /dev/null +++ b/crypto-lib/src/test/kotlin/ee/ria/DigiDoc/cryptolib/AddresseeTest.kt @@ -0,0 +1,81 @@ +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.cryptolib + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +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) + } + + 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 + } +}