diff --git a/app/src/androidTest/java/com/nextcloud/utils/CertificateValidatorTests.kt b/app/src/androidTest/java/com/nextcloud/utils/CertificateValidatorTests.kt index 127858586a22..624b901400c3 100644 --- a/app/src/androidTest/java/com/nextcloud/utils/CertificateValidatorTests.kt +++ b/app/src/androidTest/java/com/nextcloud/utils/CertificateValidatorTests.kt @@ -11,6 +11,8 @@ import androidx.test.platform.app.InstrumentationRegistry import com.google.gson.Gson import com.owncloud.android.datamodel.Credentials import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator +import com.owncloud.android.ui.dialog.setupEncryption.model.CertificateValidationResult +import org.junit.Assert.assertEquals import org.junit.After import org.junit.Before import org.junit.Test @@ -39,7 +41,6 @@ class CertificateValidatorTests { Gson().fromJson(reader, Credentials::class.java) } - val isCertificateValid = sut?.validate(credentials.publicKey, credentials.certificate) ?: false - assert(isCertificateValid) + assertEquals(CertificateValidationResult.VALID, sut?.validate(credentials.publicKey, credentials.certificate)) } } diff --git a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt index 0a7d511741d0..4bc91bac81c1 100644 --- a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt @@ -13,7 +13,10 @@ import com.nextcloud.client.database.entity.FileEntity import com.nextcloud.client.network.NetworkModule import com.nextcloud.test.SinceServer import com.nextcloud.utils.e2ee.E2EEActionResolver +import com.nextcloud.utils.e2ee.E2EECertificateManager import com.nextcloud.utils.e2ee.E2EEKeyInspector +import com.nextcloud.repository.ClientRepository +import org.mockito.kotlin.mock import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.resources.e2ee.ToggleEncryptionRemoteOperation import com.owncloud.android.lib.resources.status.GetCapabilitiesRemoteOperation @@ -61,12 +64,17 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { init { val accountManager: UserAccountManager = UserAccountManagerImpl.fromContext(targetContext) + val clientRepository: ClientRepository = mock { + onBlocking { getOwncloudClient() }.thenReturn(client) + onBlocking { getNextcloudClient() }.thenReturn(nextcloudClient) + } val inspector = E2EEKeyInspector( targetContext, storageManager, - CertificateValidator(), + E2EECertificateManager(CertificateValidator(), arbitraryDataProvider), arbitraryDataProvider, - accountManager + accountManager, + clientRepository ) e2eeActionResolver = E2EEActionResolver( storageManager, diff --git a/app/src/main/java/com/nextcloud/client/di/AppModule.kt b/app/src/main/java/com/nextcloud/client/di/AppModule.kt index 7dad415d7bc1..dcd67eb872a2 100644 --- a/app/src/main/java/com/nextcloud/client/di/AppModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/AppModule.kt @@ -46,6 +46,7 @@ import com.nextcloud.client.utils.Throttler import com.nextcloud.repository.ClientRepository import com.nextcloud.repository.RemoteClientRepository import com.nextcloud.utils.e2ee.E2EEActionResolver +import com.nextcloud.utils.e2ee.E2EECertificateManager import com.nextcloud.utils.e2ee.E2EEKeyInspector import com.nextcloud.utils.thumbnail.FolderThumbnailGenerator import com.owncloud.android.authentication.PassCodeManager @@ -241,15 +242,17 @@ internal class AppModule { fun e2eeKeyInspector( context: Context, fileDataStorageManager: FileDataStorageManager, - certificateValidator: CertificateValidator, + certificateManager: E2EECertificateManager, arbitraryDataProvider: ArbitraryDataProvider, - accountManager: UserAccountManager + accountManager: UserAccountManager, + clientRepository: ClientRepository ): E2EEKeyInspector = E2EEKeyInspector( context, fileDataStorageManager, - certificateValidator, + certificateManager, arbitraryDataProvider, - accountManager + accountManager, + clientRepository ) @Provides diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/E2EEActionResolver.kt b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEActionResolver.kt index 7b09a81ad08a..0a1ec761029e 100644 --- a/app/src/main/java/com/nextcloud/utils/e2ee/E2EEActionResolver.kt +++ b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEActionResolver.kt @@ -11,6 +11,7 @@ import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.di.Injectable import com.nextcloud.client.network.ConnectivityService import com.nextcloud.utils.e2ee.model.E2EEKeyCheck +import com.nextcloud.utils.e2ee.model.E2EEKeyCheckResult import com.nextcloud.utils.extensions.isNetworkAndServerAvailableSuspended import com.owncloud.android.datamodel.ArbitraryDataProvider import com.owncloud.android.datamodel.FileDataStorageManager @@ -45,45 +46,47 @@ class E2EEActionResolver @Inject constructor( return@withContext canDecrypt } - suspend fun checkKeys(): E2EEKeyCheck = withContext(Dispatchers.IO) { resolveKeyCheck() } + suspend fun checkKeys(): E2EEKeyCheckResult = withContext(Dispatchers.IO) { + resolveKeyCheck().also { Log_OC.d(TAG, "e2ee key check result: ${it.state}") } + } - private suspend fun resolveKeyCheck(): E2EEKeyCheck { + private suspend fun resolveKeyCheck(): E2EEKeyCheckResult { val keysAbsentLocally = inspector.isLocalKeysAbsent() - if (!inspector.fetchCapabilities()) { - return if (connectivityService.isNetworkAndServerAvailableSuspended()) { + inspector.fetchCapabilities()?.let { capabilitiesError -> + val state = if (connectivityService.isNetworkAndServerAvailableSuspended()) { E2EEKeyCheck.CHECK_FAILED } else { E2EEKeyCheck.NO_NETWORK } + return E2EEKeyCheckResult(state, capabilitiesError.descriptionId) } val capability = storageManager.getCapability(accountManager.user) val keysExistOnServer = capability.endToEndEncryptionKeysExist - val result = when { + val state = when { keysExistOnServer.isUnknown || capability.endToEndEncryption.isUnknown -> E2EEKeyCheck.E2EE_UNAVAILABLE keysAbsentLocally && keysExistOnServer.isTrue -> E2EEKeyCheck.ONLY_ON_SERVER keysAbsentLocally -> E2EEKeyCheck.MISSING_EVERYWHERE !keysExistOnServer.isTrue -> E2EEKeyCheck.ONLY_ON_DEVICE - else -> compareKeys() + else -> return compareKeys() } - Log_OC.d(TAG, "e2ee key check result: $result") - - return result + return E2EEKeyCheckResult(state) } - private suspend fun compareKeys(): E2EEKeyCheck { + private suspend fun compareKeys(): E2EEKeyCheckResult { val storedPublicKey = arbitraryDataProvider.getValue(accountManager.user, EncryptionUtils.PUBLIC_KEY) return when (val result = inspector.compareWithServerKey(storedPublicKey)) { - is DownloadKeyResult.CompareKeys -> + is DownloadKeyResult.CompareKeys -> E2EEKeyCheckResult( if (result.same) E2EEKeyCheck.SAME_AS_SERVER else E2EEKeyCheck.DIFFERS_FROM_SERVER + ) - is DownloadKeyResult.NoServerKey -> E2EEKeyCheck.ONLY_ON_DEVICE + is DownloadKeyResult.NoServerKey -> E2EEKeyCheckResult(E2EEKeyCheck.ONLY_ON_DEVICE) - else -> E2EEKeyCheck.CHECK_FAILED + else -> E2EEKeyCheckResult(E2EEKeyCheck.CHECK_FAILED, result.descriptionId) } } } diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/E2EECertificateManager.kt b/app/src/main/java/com/nextcloud/utils/e2ee/E2EECertificateManager.kt new file mode 100644 index 000000000000..5765cc69b724 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/e2ee/E2EECertificateManager.kt @@ -0,0 +1,85 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee + +import com.nextcloud.client.account.User +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator +import com.owncloud.android.ui.dialog.setupEncryption.model.CertificateValidationResult +import com.owncloud.android.ui.dialog.setupEncryption.model.DownloadKeyResult +import com.owncloud.android.utils.EncryptionUtils +import com.owncloud.android.utils.crypto.CryptoStringUtils +import java.security.GeneralSecurityException +import java.security.KeyFactory +import java.security.Signature +import java.security.cert.X509Certificate +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Base64 +import javax.inject.Inject + +class E2EECertificateManager @Inject constructor( + private val validator: CertificateValidator, + private val arbitraryDataProvider: ArbitraryDataProvider +) { + fun compareAndRefresh( + user: User, + storedCertificate: String, + remoteCertificate: String, + serverPublicKey: String + ): DownloadKeyResult { + val validation = validator.validate(serverPublicKey, remoteCertificate) + if (validation != CertificateValidationResult.VALID) { + return DownloadKeyResult.CertificateVerificationFailed(validation) + } + + val remote = validator.parseCertificate(remoteCertificate) + try { + val stored = validator.parseCertificate(storedCertificate) + if (!stored.publicKey.encoded.contentEquals(remote.publicKey.encoded)) { + return DownloadKeyResult.CompareKeys(same = false) + } + + val privateKey = arbitraryDataProvider.getValue(user, EncryptionUtils.PRIVATE_KEY) + if (!matchesPrivateKey(remote, privateKey)) { + return DownloadKeyResult.LocalKeysInvalid() + } + } catch (e: GeneralSecurityException) { + Log_OC.w(TAG, "Cannot verify local encryption keys: $e") + return DownloadKeyResult.LocalKeysInvalid() + } catch (e: IllegalArgumentException) { + Log_OC.w(TAG, "Cannot decode local encryption keys: $e") + return DownloadKeyResult.LocalKeysInvalid() + } + + if (storedCertificate != remoteCertificate) { + arbitraryDataProvider.storeOrUpdateKeyValue(user, EncryptionUtils.PUBLIC_KEY, remoteCertificate) + } + return DownloadKeyResult.CompareKeys(same = true) + } + + private fun matchesPrivateKey(certificate: X509Certificate, privateKey: String): Boolean { + val encodedKey = CryptoStringUtils.rawPrivateKey(privateKey).replace("\\s+".toRegex(), "") + val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(encodedKey)) + val key = KeyFactory.getInstance("RSA").generatePrivate(keySpec) + val message = certificate.publicKey.encoded + val signer = Signature.getInstance("SHA256withRSA") + signer.initSign(key) + signer.update(message) + val signature = signer.sign() + + val verifier = Signature.getInstance("SHA256withRSA") + verifier.initVerify(certificate.publicKey) + verifier.update(message) + return verifier.verify(signature) + } + + companion object { + private const val TAG = "E2EECertificateManager" + } +} diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/E2EEDialogPresenter.kt b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEDialogPresenter.kt index 6acb8e841d51..cb273cc3765a 100644 --- a/app/src/main/java/com/nextcloud/utils/e2ee/E2EEDialogPresenter.kt +++ b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEDialogPresenter.kt @@ -7,7 +7,10 @@ package com.nextcloud.utils.e2ee +import com.nextcloud.utils.SnackbarUtil +import com.nextcloud.utils.e2ee.model.E2EEAction import com.nextcloud.utils.e2ee.model.E2EEDialog +import com.nextcloud.utils.e2ee.model.E2EEKeyCheckResult import com.owncloud.android.R import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.dialog.ConfirmationDialogFragment @@ -47,4 +50,25 @@ class E2EEDialogPresenter(private val fragment: OCFileListFragment) { override fun onNeutral(callerTag: String?) = Unit override fun onCancel(callerTag: String?) = Unit } + + fun showKeyCheckError(result: E2EEKeyCheckResult, action: E2EEAction) { + val messageId = result.getMessageId(action) ?: return + if (result.errorMessageId == null) { + SnackbarUtil.show(fragment, messageId) + return + } + if (fragment.parentFragmentManager.findFragmentByTag(ENCRYPTION_KEY_ALERT_DIALOG_TAG) != null) { + return + } + + ConfirmationDialogFragment.newInstance( + titleResId = R.string.end_to_end_encryption_title, + titleIconId = R.drawable.ic_lock_open_white, + messageResId = messageId, + positiveButtonTextId = R.string.common_ok, + neutralButtonTextId = NO_BUTTON, + negativeButtonTextId = NO_BUTTON, + messageArguments = null + ).show(fragment.parentFragmentManager, ENCRYPTION_KEY_ALERT_DIALOG_TAG) + } } diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/E2EEKeyInspector.kt b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEKeyInspector.kt index 18b4c98d4542..79625be1a370 100644 --- a/app/src/main/java/com/nextcloud/utils/e2ee/E2EEKeyInspector.kt +++ b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEKeyInspector.kt @@ -10,19 +10,19 @@ package com.nextcloud.utils.e2ee import android.content.Context import com.google.gson.reflect.TypeToken import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.repository.ClientRepository +import com.owncloud.android.R import com.owncloud.android.datamodel.ArbitraryDataProvider import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFolderMetadataFileV1 import com.owncloud.android.lib.common.OwnCloudClient -import com.owncloud.android.lib.common.OwnCloudClientFactory import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.e2ee.GetMetadataRemoteOperation import com.owncloud.android.lib.resources.status.OCCapability import com.owncloud.android.lib.resources.users.GetCertificateRemoteOperation import com.owncloud.android.lib.resources.users.GetServerPublicKeyRemoteOperation import com.owncloud.android.operations.GetCapabilitiesOperation -import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator import com.owncloud.android.ui.dialog.setupEncryption.model.DownloadKeyResult import com.owncloud.android.utils.EncryptionUtils import com.owncloud.android.utils.EncryptionUtilsV2 @@ -34,9 +34,10 @@ import javax.inject.Inject class E2EEKeyInspector @Inject constructor( private val context: Context, private val storageManager: FileDataStorageManager, - private val certificateValidator: CertificateValidator, + private val certificateManager: E2EECertificateManager, private val arbitraryDataProvider: ArbitraryDataProvider, - private val accountManager: UserAccountManager + private val accountManager: UserAccountManager, + private val clientRepository: ClientRepository ) { companion object { private const val TAG = "E2EEKeyInspector" @@ -46,47 +47,50 @@ class E2EEKeyInspector @Inject constructor( arbitraryDataProvider.getValue(accountManager.user, EncryptionUtils.PRIVATE_KEY).isEmpty() || arbitraryDataProvider.getValue(accountManager.user, EncryptionUtils.PUBLIC_KEY).isEmpty() - suspend fun fetchCapabilities(): Boolean = withContext(Dispatchers.IO) { - val result = GetCapabilitiesOperation(storageManager).execute(context).isSuccess - if (result) { - Log_OC.i(TAG, "capabilities fetched") - } else { - Log_OC.e(TAG, "error fetching capabilities") + suspend fun fetchCapabilities(): DownloadKeyResult.RequestFailed? = withContext(Dispatchers.IO) { + val client = clientRepository.getOwncloudClient() + ?: return@withContext DownloadKeyResult.RequestFailed(R.string.e2ee_key_request_connection_failed) + val result = GetCapabilitiesOperation(storageManager).execute(client) + if (!result.isSuccess) { + Log_OC.e(TAG, "error fetching capabilities: ${result.httpCode} ${result.code}") + return@withContext E2EEKeyRequestError.from(result) } - return@withContext result + Log_OC.i(TAG, "capabilities fetched") + null } suspend fun compareWithServerKey(storedPublicKey: String): DownloadKeyResult = withContext(Dispatchers.IO) { Log_OC.d(TAG, "comparing local public key with remote public key") val user = accountManager.user + val client = clientRepository.getNextcloudClient() + ?: return@withContext DownloadKeyResult.RequestFailed(R.string.e2ee_key_request_connection_failed) - val certificateResult = GetCertificateRemoteOperation().executeNextcloudClient(user, context) + val certificateResult = GetCertificateRemoteOperation().execute(client) if (!certificateResult.isSuccess) { return@withContext if (certificateResult.httpCode == HttpStatus.SC_NOT_FOUND) { DownloadKeyResult.NoServerKey } else { - DownloadKeyResult.CertificateUnavailable() + E2EEKeyRequestError.from(certificateResult) } } - val serverPublicKeyResult = GetServerPublicKeyRemoteOperation().executeNextcloudClient(user, context) - if (!serverPublicKeyResult.isSuccess) return@withContext DownloadKeyResult.ServerPublicKeyUnavailable() + val serverPublicKeyResult = GetServerPublicKeyRemoteOperation().execute(client) + if (!serverPublicKeyResult.isSuccess) return@withContext E2EEKeyRequestError.from(serverPublicKeyResult) - val isCertificateValid = certificateValidator.validate( - serverPublicKeyResult.resultData, - certificateResult.resultData + return@withContext certificateManager.compareAndRefresh( + user, + storedPublicKey, + certificateResult.resultData, + serverPublicKeyResult.resultData ) - if (!isCertificateValid) return@withContext DownloadKeyResult.CertificateVerificationFailed() - - return@withContext DownloadKeyResult.CompareKeys(same = (storedPublicKey == certificateResult.resultData)) } suspend fun canDecryptFolderMetadata(folder: OCFile, capability: OCCapability): Boolean = withContext(Dispatchers.IO) { Log_OC.d(TAG, "checking folder metadata key") - val client = OwnCloudClientFactory.createOwnCloudClient(accountManager.currentAccount, context) + val client = clientRepository.getOwncloudClient() ?: return@withContext false val metadataResult = GetMetadataRemoteOperation(folder.localId).execute(client) if (!metadataResult.isSuccess) { diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/E2EEKeyRequestError.kt b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEKeyRequestError.kt new file mode 100644 index 000000000000..9f35fbc72308 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEKeyRequestError.kt @@ -0,0 +1,66 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee + +import androidx.annotation.StringRes +import com.owncloud.android.R +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.ui.dialog.setupEncryption.model.DownloadKeyResult +import org.apache.commons.httpclient.HttpStatus +import org.json.JSONException +import java.io.IOException +import java.net.SocketTimeoutException +import javax.net.ssl.SSLException + +object E2EEKeyRequestError { + private const val HTTP_TOO_MANY_REQUESTS = 429 + private const val LAST_SERVER_ERROR = 599 + private const val LAST_CLIENT_ERROR = 499 + private val SERVER_ERRORS = HttpStatus.SC_INTERNAL_SERVER_ERROR..LAST_SERVER_ERROR + private val CLIENT_ERRORS = HttpStatus.SC_BAD_REQUEST..LAST_CLIENT_ERROR + + fun from(result: RemoteOperationResult<*>): DownloadKeyResult.RequestFailed { + val descriptionId = when (result.httpCode) { + HttpStatus.SC_UNAUTHORIZED -> R.string.e2ee_key_request_unauthorized + HttpStatus.SC_FORBIDDEN -> R.string.e2ee_key_request_forbidden + HttpStatus.SC_NOT_FOUND -> R.string.e2ee_key_request_not_found + HttpStatus.SC_REQUEST_TIMEOUT, HttpStatus.SC_GATEWAY_TIMEOUT -> R.string.e2ee_key_request_timeout + HTTP_TOO_MANY_REQUESTS -> R.string.e2ee_key_request_rate_limited + in SERVER_ERRORS -> R.string.e2ee_key_request_server_error + in CLIENT_ERRORS -> R.string.e2ee_key_request_rejected + else -> operationError(result) + } + return DownloadKeyResult.RequestFailed(descriptionId) + } + + @StringRes + private fun operationError(result: RemoteOperationResult<*>): Int = when (result.code) { + ResultCode.UNAUTHORIZED, ResultCode.OAUTH2_ERROR, ResultCode.OAUTH2_ERROR_ACCESS_DENIED -> + R.string.e2ee_key_request_unauthorized + + ResultCode.FORBIDDEN -> R.string.e2ee_key_request_forbidden + + ResultCode.TIMEOUT -> R.string.e2ee_key_request_timeout + + ResultCode.SSL_ERROR, ResultCode.SSL_RECOVERABLE_PEER_UNVERIFIED -> R.string.e2ee_key_request_tls_error + + ResultCode.NO_NETWORK_CONNECTION, ResultCode.HOST_NOT_AVAILABLE, ResultCode.INCORRECT_ADDRESS, + ResultCode.WRONG_CONNECTION -> R.string.e2ee_key_request_connection_failed + + ResultCode.WRONG_SERVER_RESPONSE -> R.string.e2ee_key_request_invalid_response + + else -> when (result.exception) { + is JSONException -> R.string.e2ee_key_request_invalid_response + is SSLException -> R.string.e2ee_key_request_tls_error + is SocketTimeoutException -> R.string.e2ee_key_request_timeout + is IOException -> R.string.e2ee_key_request_connection_failed + else -> R.string.e2ee_key_request_failed + } + } +} diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEKeyCheckResult.kt b/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEKeyCheckResult.kt new file mode 100644 index 000000000000..ae2fd1aae492 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEKeyCheckResult.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee.model + +import androidx.annotation.StringRes + +data class E2EEKeyCheckResult(val state: E2EEKeyCheck, @param:StringRes val errorMessageId: Int? = null) { + @StringRes + fun getMessageId(action: E2EEAction): Int? = errorMessageId ?: state.getMessageId(action) +} diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/CertificateValidator.kt b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/CertificateValidator.kt index 5c76f170610d..b41d8a16d141 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/CertificateValidator.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/CertificateValidator.kt @@ -7,14 +7,22 @@ package com.owncloud.android.ui.dialog.setupEncryption -import android.util.Base64 import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.dialog.setupEncryption.model.CertificateValidationResult import com.owncloud.android.utils.crypto.CryptoStringUtils import java.io.ByteArrayInputStream +import java.security.GeneralSecurityException +import java.security.InvalidKeyException import java.security.KeyFactory +import java.security.NoSuchAlgorithmException +import java.security.SignatureException +import java.security.cert.CertificateException +import java.security.cert.CertificateExpiredException import java.security.cert.CertificateFactory +import java.security.cert.CertificateNotYetValidException import java.security.cert.X509Certificate import java.security.spec.X509EncodedKeySpec +import java.util.Base64 import javax.inject.Inject @Suppress("EmptyClassBlock") @@ -27,30 +35,55 @@ class CertificateValidator @Inject constructor() { * @param serverPublicKeyString Public key with header * @param certificate Certificate in PEM format */ - fun validate(serverPublicKeyString: String, certificate: String): Boolean { - val contentOfServerKey = CryptoStringUtils.rawPublicKey(serverPublicKeyString) - - return try { - val decodedPublicKey = Base64.decode(contentOfServerKey, Base64.NO_WRAP) - - val keySpec = X509EncodedKeySpec(decodedPublicKey) - val keyFactory = KeyFactory.getInstance("RSA") - val serverPublicKey = keyFactory.generatePublic(keySpec) + fun validate(serverPublicKeyString: String, certificate: String): CertificateValidationResult { + val serverPublicKey = try { + val decodedPublicKey = Base64.getDecoder().decode(CryptoStringUtils.rawPublicKey(serverPublicKeyString)) + KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(decodedPublicKey)) + } catch (e: IllegalArgumentException) { + return failure(CertificateValidationResult.INVALID_SERVER_PUBLIC_KEY, e) + } catch (e: GeneralSecurityException) { + val result = if (e is NoSuchAlgorithmException) { + CertificateValidationResult.UNSUPPORTED_ALGORITHM + } else { + CertificateValidationResult.INVALID_SERVER_PUBLIC_KEY + } + return failure(result, e) + } - val certificateFactory = CertificateFactory.getInstance("X.509") - val certificateInputStream = ByteArrayInputStream(certificate.toByteArray()) - val x509Certificate = certificateFactory.generateCertificate(certificateInputStream) as X509Certificate + val x509Certificate = try { + parseCertificate(certificate) + } catch (e: CertificateException) { + return failure(CertificateValidationResult.INVALID_CERTIFICATE, e) + } - // Check date of the certificate + return try { x509Certificate.checkValidity() - - // Verify certificate with serverPublicKey x509Certificate.verify(serverPublicKey) Log_OC.d(tag, "Client certificate is valid against server public key") - true + CertificateValidationResult.VALID + } catch (e: GeneralSecurityException) { + val result = when (e) { + is CertificateExpiredException -> CertificateValidationResult.EXPIRED + is CertificateNotYetValidException -> CertificateValidationResult.NOT_YET_VALID + is SignatureException -> CertificateValidationResult.INVALID_SIGNATURE + is InvalidKeyException -> CertificateValidationResult.INVALID_SERVER_PUBLIC_KEY + is NoSuchAlgorithmException -> CertificateValidationResult.UNSUPPORTED_ALGORITHM + is CertificateException -> CertificateValidationResult.INVALID_CERTIFICATE + else -> CertificateValidationResult.VERIFICATION_FAILED + } + failure(result, e) } catch (e: Exception) { - Log_OC.d(tag, "Client certificate is not valid against the server public key: $e") - false + failure(CertificateValidationResult.VERIFICATION_FAILED, e) } } + + fun parseCertificate(certificate: String): X509Certificate = + ByteArrayInputStream(certificate.toByteArray(Charsets.UTF_8)).use { + CertificateFactory.getInstance("X.509").generateCertificate(it) as X509Certificate + } + + private fun failure(result: CertificateValidationResult, exception: Exception): CertificateValidationResult { + Log_OC.d(tag, "Client certificate verification failed ($result): $exception") + return result + } } diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/SetupEncryptionDialogFragment.kt b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/SetupEncryptionDialogFragment.kt index d7f08af54111..cac13d9f108d 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/SetupEncryptionDialogFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/SetupEncryptionDialogFragment.kt @@ -19,8 +19,9 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputLayout import com.nextcloud.client.account.User import com.nextcloud.client.di.Injectable -import com.nextcloud.client.network.ClientFactory +import com.nextcloud.repository.ClientRepository import com.nextcloud.utils.SnackbarUtil +import com.nextcloud.utils.e2ee.E2EEKeyRequestError import com.nextcloud.utils.e2ee.model.E2EEAction import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.getSerializableArgument @@ -34,6 +35,7 @@ import com.owncloud.android.lib.resources.users.GetPrivateKeyRemoteOperation import com.owncloud.android.lib.resources.users.GetCertificateRemoteOperation import com.owncloud.android.lib.resources.users.GetServerPublicKeyRemoteOperation import com.owncloud.android.ui.dialog.extensions.themeButtons +import com.owncloud.android.ui.dialog.setupEncryption.model.CertificateValidationResult import com.owncloud.android.ui.dialog.setupEncryption.model.DownloadKeyResult import com.owncloud.android.utils.ClipboardUtil import com.owncloud.android.utils.EncryptionUtils @@ -44,7 +46,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.apache.commons.httpclient.HttpStatus import java.io.IOException -import java.lang.ref.WeakReference import javax.inject.Inject class SetupEncryptionDialogFragment : @@ -54,9 +55,8 @@ class SetupEncryptionDialogFragment : @Inject lateinit var viewThemeUtils: ViewThemeUtils - @JvmField @Inject - var clientFactory: ClientFactory? = null + lateinit var clientRepository: ClientRepository @JvmField @Inject @@ -263,34 +263,36 @@ class SetupEncryptionDialogFragment : positiveButton?.visibility = View.INVISIBLE downloadKeyResult = withContext(Dispatchers.IO) { - val weakContext = WeakReference(context).get() ?: return@withContext DownloadKeyResult.UnexpectedError() val user = user ?: return@withContext DownloadKeyResult.UnexpectedError() val dataProvider = arbitraryDataProvider ?: return@withContext DownloadKeyResult.UnexpectedError() + val client = clientRepository.getNextcloudClient() + ?: return@withContext DownloadKeyResult.RequestFailed(R.string.e2ee_key_request_connection_failed) val certificateOperation = GetCertificateRemoteOperation() - val certificateResult = certificateOperation.executeNextcloudClient(user, weakContext) + val certificateResult = certificateOperation.execute(client) if (!certificateResult.isSuccess) { // The certificate might not be available on the server yet. // Therefore, the user needs to generate a new passphrase first, send csr. return@withContext if (certificateResult.httpCode == HttpStatus.SC_NOT_FOUND) { DownloadKeyResult.GeneratePassphraseSendCSR } else { - DownloadKeyResult.CertificateUnavailable() + E2EEKeyRequestError.from(certificateResult) } } val serverPublicKeyOperation = GetServerPublicKeyRemoteOperation() - val serverPublicKeyResult = serverPublicKeyOperation.executeNextcloudClient(user, weakContext) + val serverPublicKeyResult = serverPublicKeyOperation.execute(client) if (!serverPublicKeyResult.isSuccess) { - return@withContext DownloadKeyResult.ServerPublicKeyUnavailable() + return@withContext E2EEKeyRequestError.from(serverPublicKeyResult) } val serverKey = serverPublicKeyResult.resultData val certificateAsString = certificateResult.resultData - val isCertificateValid = certificateValidator?.validate(serverKey, certificateAsString) + val validation = certificateValidator?.validate(serverKey, certificateAsString) + ?: return@withContext DownloadKeyResult.UnexpectedError() - if (isCertificateValid == false) { - return@withContext DownloadKeyResult.CertificateVerificationFailed() + if (validation != CertificateValidationResult.VALID) { + return@withContext DownloadKeyResult.CertificateVerificationFailed(validation) } dataProvider.storeOrUpdateKeyValue( @@ -300,30 +302,43 @@ class SetupEncryptionDialogFragment : ) val privateKeyOperation = GetPrivateKeyRemoteOperation() - val privateKeyResult = privateKeyOperation.executeNextcloudClient(user, weakContext) + val privateKeyResult = privateKeyOperation.execute(client) return@withContext if (privateKeyResult.isSuccess) { Log_OC.d(TAG, "private key successful downloaded for " + user.accountName) keyResult = KEY_EXISTING_USED val privateKey = privateKeyResult.resultData?.getKey() if (privateKey == null) { - DownloadKeyResult.ServerPrivateKeyUnavailable() + DownloadKeyResult.RequestFailed(R.string.e2ee_key_request_invalid_response) } else { DownloadKeyResult.Success(privateKey) } } else { - DownloadKeyResult.ServerPrivateKeyUnavailable() + E2EEKeyRequestError.from(privateKeyResult) } } - downloadKeyResult?.let { result -> - if (result is DownloadKeyResult.Success || result is DownloadKeyResult.GeneratePassphraseSendCSR) { - handlePrivateKey(result) - } else { - val descriptionId = result.descriptionId ?: return - val description = getString(descriptionId) - dismiss() - SnackbarUtil.show(requireActivity(), description) + downloadKeyResult?.let { showDownloadKeyResult(it) } + } + + private fun showDownloadKeyResult(result: DownloadKeyResult) { + if (result is DownloadKeyResult.Success || result is DownloadKeyResult.GeneratePassphraseSendCSR) { + handlePrivateKey(result) + } else if ( + result is DownloadKeyResult.CertificateVerificationFailed || result is DownloadKeyResult.RequestFailed + ) { + val descriptionId = result.descriptionId ?: return + keyResult = KEY_FAILED + requireDialog().setTitle(R.string.common_error) + binding.encryptionStatus.setText(descriptionId) + (dialog as? AlertDialog)?.getButton(AlertDialog.BUTTON_POSITIVE)?.apply { + setText(R.string.end_to_end_encryption_dialog_close) + visibility = View.VISIBLE } + } else { + val descriptionId = result.descriptionId ?: return + val description = getString(descriptionId) + dismiss() + SnackbarUtil.show(requireActivity(), description) } } diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/model/CertificateValidationResult.kt b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/model/CertificateValidationResult.kt new file mode 100644 index 000000000000..ee03a03228ce --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/model/CertificateValidationResult.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.dialog.setupEncryption.model + +import androidx.annotation.StringRes +import com.owncloud.android.R + +enum class CertificateValidationResult(@param:StringRes val descriptionId: Int?) { + VALID(null), + INVALID_SERVER_PUBLIC_KEY(R.string.e2ee_certificate_invalid_server_key), + INVALID_CERTIFICATE(R.string.e2ee_certificate_invalid_format), + EXPIRED(R.string.e2ee_certificate_expired), + NOT_YET_VALID(R.string.e2ee_certificate_not_yet_valid), + INVALID_SIGNATURE(R.string.e2ee_certificate_invalid_signature), + UNSUPPORTED_ALGORITHM(R.string.e2ee_certificate_unsupported_algorithm), + VERIFICATION_FAILED(R.string.e2ee_certificate_verification_failed) +} diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/model/DownloadKeyResult.kt b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/model/DownloadKeyResult.kt index a57fd121608d..5c174c79dc15 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/model/DownloadKeyResult.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/model/DownloadKeyResult.kt @@ -7,13 +7,20 @@ package com.owncloud.android.ui.dialog.setupEncryption.model +import androidx.annotation.StringRes import com.owncloud.android.R sealed class DownloadKeyResult(open val descriptionId: Int? = null) { + data class RequestFailed(@param:StringRes override val descriptionId: Int) : DownloadKeyResult(descriptionId) + data class CertificateVerificationFailed( - override val descriptionId: Int = R.string.end_to_end_encryption_certificate_verification_failed + val reason: CertificateValidationResult, + override val descriptionId: Int = requireNotNull(reason.descriptionId) ) : DownloadKeyResult(descriptionId) + data class LocalKeysInvalid(override val descriptionId: Int = R.string.e2ee_local_keys_invalid) : + DownloadKeyResult(descriptionId) + data class ServerPublicKeyUnavailable( override val descriptionId: Int = R.string.end_to_end_encryption_server_public_key_unavailable ) : DownloadKeyResult(descriptionId) diff --git a/app/src/main/java/com/owncloud/android/ui/events/EncryptionEvent.kt b/app/src/main/java/com/owncloud/android/ui/events/EncryptionEvent.kt index 98474ec56c20..11d7fe00d2ec 100644 --- a/app/src/main/java/com/owncloud/android/ui/events/EncryptionEvent.kt +++ b/app/src/main/java/com/owncloud/android/ui/events/EncryptionEvent.kt @@ -7,7 +7,6 @@ package com.owncloud.android.ui.events import androidx.lifecycle.lifecycleScope -import com.nextcloud.utils.SnackbarUtil import com.nextcloud.utils.e2ee.model.E2EEAction import com.nextcloud.utils.e2ee.model.E2EEKeyCheck import com.nextcloud.utils.extensions.showEncryptionDialog @@ -20,9 +19,10 @@ import kotlinx.coroutines.launch class EncryptionEvent(val localId: Long, val remoteId: String, val remotePath: String, val shouldBeEncrypted: Boolean) { fun onResult(fragment: OCFileListFragment) { fragment.lifecycleScope.launch { - when (val state = fragment.e2eeActionResolver.checkKeys()) { + val result = fragment.e2eeActionResolver.checkKeys() + when (val state = result.state) { E2EEKeyCheck.NO_NETWORK, E2EEKeyCheck.E2EE_UNAVAILABLE, E2EEKeyCheck.CHECK_FAILED -> { - state.getMessageId(E2EEAction.ENCRYPT)?.let { SnackbarUtil.show(fragment, it) } + fragment.e2eeDialogPresenter.showKeyCheckError(result, E2EEAction.ENCRYPT) } E2EEKeyCheck.ONLY_ON_SERVER, E2EEKeyCheck.MISSING_EVERYWHERE -> { diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/EncryptedFolderClickHandler.kt b/app/src/main/java/com/owncloud/android/ui/fragment/EncryptedFolderClickHandler.kt index 64db2b2a59df..91cd34cef17e 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/EncryptedFolderClickHandler.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/EncryptedFolderClickHandler.kt @@ -39,11 +39,11 @@ class EncryptedFolderClickHandler(private val fragment: OCFileListFragment) { ) fragment.lifecycleScope.launch { - val state = fragment.e2eeActionResolver.checkKeys() + val result = fragment.e2eeActionResolver.checkKeys() dismissCheckingSnackbar() - when (state) { + when (val state = result.state) { E2EEKeyCheck.NO_NETWORK, E2EEKeyCheck.CHECK_FAILED, E2EEKeyCheck.E2EE_UNAVAILABLE -> { - state.getMessageId(E2EEAction.NEW_FOLDER)?.let { SnackbarUtil.show(fragment, it) } + fragment.e2eeDialogPresenter.showKeyCheckError(result, E2EEAction.NEW_FOLDER) } E2EEKeyCheck.ONLY_ON_SERVER, E2EEKeyCheck.MISSING_EVERYWHERE -> { @@ -76,13 +76,13 @@ class EncryptedFolderClickHandler(private val fragment: OCFileListFragment) { fragment.run { lifecycleScope.launch { - val state = e2eeActionResolver.checkKeys() + val result = e2eeActionResolver.checkKeys() dismissCheckingSnackbar() - when (state) { + when (val state = result.state) { E2EEKeyCheck.NO_NETWORK, E2EEKeyCheck.CHECK_FAILED, E2EEKeyCheck.E2EE_UNAVAILABLE -> { - state.getMessageId(E2EEAction.OPEN)?.let { SnackbarUtil.show(fragment, it) } + e2eeDialogPresenter.showKeyCheckError(result, E2EEAction.OPEN) } E2EEKeyCheck.ONLY_ON_SERVER -> { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3d6f60bf3e1..46d3e546674e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1019,7 +1019,25 @@ No app available to select contacts You cannot create a share, sharing is already active from this user. - Failed to verify public key + Could not verify your encryption certificate: the server signing key is invalid or incompatible. Ask your administrator to check the encryption configuration. + Could not verify your encryption certificate: the certificate received from the server is malformed. Try again. If this continues, contact your administrator. + Your encryption certificate has expired. Check your device date and time. If they are correct, ask your administrator about renewing the certificate while keeping your encryption keys. + Your encryption certificate is not valid yet. Check your device date and time. If they are correct, ask your administrator to check the certificate dates. + Your encryption certificate does not match the server signing key. Try again. If this continues, ask your administrator to check the certificate and server signing key. + Your device cannot verify the encryption certificate because its signing algorithm is not supported. Update the app and Android, or contact your administrator. + Could not verify your encryption certificate because of a cryptographic error. Try again. If this continues, contact your administrator. + The encryption keys stored on this device could not be verified. Make sure you have your 12 word passphrase before removing local encryption settings and setting them up again. + The server rejected authentication while retrieving encryption keys (HTTP 401 or an authentication error). Check that you can sign in to your account, then try again. + The server refused access while retrieving encryption keys (HTTP 403 or an access error). Ask your administrator to check account permissions and server access policies. + The server could not find the requested encryption key or endpoint (HTTP 404). Ask your administrator to check the server address and end-to-end encryption configuration. + Encryption keys could not be retrieved because the server is limiting requests (HTTP 429). Wait before trying again. If this continues, ask your administrator to check rate limiting on the server and reverse proxy. + The request timed out while retrieving encryption keys, or the server reported a timeout (HTTP 408 or 504). Check your connection and try again later. If this continues, contact your administrator. + The server or gateway reported an error while retrieving encryption keys (HTTP 5xx). Try again later. If this continues, ask your administrator to check the server logs. + The server rejected the request for encryption keys (HTTP 4xx). Ask your administrator to check the server address, encryption app compatibility, and server logs. + A connection to the server could not be established to retrieve encryption keys. Check your internet connection, server address, and account, then try again. + A secure connection to the server could not be verified while retrieving encryption keys. Check your device date and time. If they are correct, ask your administrator to check the HTTPS certificate and TLS configuration. + The server returned an unreadable or incomplete response while retrieving encryption keys. Ask your administrator to check the encryption app, reverse proxy, and server logs. + The request for encryption keys could not be completed. Try again. If this continues, share the app logs with your administrator. This does not establish whether your encryption keys are valid. Unable to retrieve public key Unable to retrieve private key Unable to retrieve server certificate diff --git a/app/src/test/java/com/nextcloud/utils/e2ee/CertificateTestFixtures.kt b/app/src/test/java/com/nextcloud/utils/e2ee/CertificateTestFixtures.kt new file mode 100644 index 000000000000..8344186e95e6 --- /dev/null +++ b/app/src/test/java/com/nextcloud/utils/e2ee/CertificateTestFixtures.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee + +import org.bouncycastle.asn1.ASN1ObjectIdentifier +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.AlgorithmIdentifier +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.operator.ContentSigner +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import java.math.BigInteger +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.util.Base64 +import java.util.Date + +internal object CertificateTestFixtures { + const val DAY_MILLIS = 86_400_000L + private const val KEY_SIZE = 2048 + private const val PEM_LINE_LENGTH = 64 + private const val UNKNOWN_ALGORITHM_OID = "1.2.3.4.5" + + val serverKeys: KeyPair by lazy { generateKeyPair() } + val userKeys: KeyPair by lazy { generateKeyPair() } + val otherKeys: KeyPair by lazy { generateKeyPair() } + + fun certificate( + keys: KeyPair = userKeys, + serial: Long = 1, + notBefore: Long = System.currentTimeMillis() - DAY_MILLIS, + notAfter: Long = System.currentTimeMillis() + DAY_MILLIS, + unsupportedAlgorithm: Boolean = false + ): String { + val builder = JcaX509v3CertificateBuilder( + X500Name("CN=Encryption server"), + BigInteger.valueOf(serial), + Date(notBefore), + Date(notAfter), + X500Name("CN=Encryption user"), + keys.public + ) + val signer = JcaContentSignerBuilder("SHA256withRSA").build(serverKeys.private) + val contentSigner = if (unsupportedAlgorithm) { + object : ContentSigner by signer { + override fun getAlgorithmIdentifier(): AlgorithmIdentifier = + AlgorithmIdentifier(ASN1ObjectIdentifier(UNKNOWN_ALGORITHM_OID)) + } + } else { + signer + } + return pem("CERTIFICATE", builder.build(contentSigner).encoded) + } + + fun publicKey(keys: KeyPair = serverKeys): String = pem("PUBLIC KEY", keys.public.encoded) + + fun privateKey(keys: KeyPair = userKeys): String = Base64.getEncoder().encodeToString(keys.private.encoded) + + fun pem(label: String, bytes: ByteArray): String { + val body = Base64.getMimeEncoder(PEM_LINE_LENGTH, "\n".toByteArray()).encodeToString(bytes) + return "-----BEGIN $label-----\n$body\n-----END $label-----\n" + } + + private fun generateKeyPair(): KeyPair = KeyPairGenerator.getInstance("RSA").apply { + initialize(KEY_SIZE) + }.generateKeyPair() +} diff --git a/app/src/test/java/com/nextcloud/utils/e2ee/CertificateValidatorTest.kt b/app/src/test/java/com/nextcloud/utils/e2ee/CertificateValidatorTest.kt new file mode 100644 index 000000000000..b436c1dab94d --- /dev/null +++ b/app/src/test/java/com/nextcloud/utils/e2ee/CertificateValidatorTest.kt @@ -0,0 +1,106 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee + +import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator +import com.owncloud.android.ui.dialog.setupEncryption.model.CertificateValidationResult +import org.junit.Assert.assertEquals +import org.junit.Test + +class CertificateValidatorTest { + private val validator = CertificateValidator() + + @Test + fun acceptsCertificateSignedByServer() { + assertEquals( + CertificateValidationResult.VALID, + validator.validate(CertificateTestFixtures.publicKey(), CertificateTestFixtures.certificate()) + ) + } + + @Test + fun acceptsCrLfAndWhitespaceInServerKey() { + val serverKey = CertificateTestFixtures.publicKey().replace("\n", "\r\n") + " \t" + assertEquals( + CertificateValidationResult.VALID, + validator.validate(serverKey, CertificateTestFixtures.certificate()) + ) + } + + @Test + fun reportsExpiredCertificate() { + val now = System.currentTimeMillis() + val certificate = CertificateTestFixtures.certificate( + notBefore = now - CertificateTestFixtures.DAY_MILLIS * 2, + notAfter = now - CertificateTestFixtures.DAY_MILLIS + ) + assertEquals( + CertificateValidationResult.EXPIRED, + validator.validate(CertificateTestFixtures.publicKey(), certificate) + ) + } + + @Test + fun reportsCertificateThatIsNotYetValid() { + val now = System.currentTimeMillis() + val certificate = CertificateTestFixtures.certificate( + notBefore = now + CertificateTestFixtures.DAY_MILLIS, + notAfter = now + CertificateTestFixtures.DAY_MILLIS * 2 + ) + assertEquals( + CertificateValidationResult.NOT_YET_VALID, + validator.validate(CertificateTestFixtures.publicKey(), certificate) + ) + } + + @Test + fun reportsMalformedServerKey() { + assertEquals( + CertificateValidationResult.INVALID_SERVER_PUBLIC_KEY, + validator.validate("not a public key", CertificateTestFixtures.certificate()) + ) + } + + @Test + fun reportsInvalidServerKeyEncoding() { + assertEquals( + CertificateValidationResult.INVALID_SERVER_PUBLIC_KEY, + validator.validate("AAAA", CertificateTestFixtures.certificate()) + ) + } + + @Test + fun reportsMalformedCertificate() { + assertEquals( + CertificateValidationResult.INVALID_CERTIFICATE, + validator.validate(CertificateTestFixtures.publicKey(), "not a certificate") + ) + } + + @Test + fun reportsMismatchedServerSigningKey() { + assertEquals( + CertificateValidationResult.INVALID_SIGNATURE, + validator.validate( + CertificateTestFixtures.publicKey(CertificateTestFixtures.otherKeys), + CertificateTestFixtures.certificate() + ) + ) + } + + @Test + fun reportsUnsupportedCertificateSignatureAlgorithm() { + assertEquals( + CertificateValidationResult.UNSUPPORTED_ALGORITHM, + validator.validate( + CertificateTestFixtures.publicKey(), + CertificateTestFixtures.certificate(unsupportedAlgorithm = true) + ) + ) + } +} diff --git a/app/src/test/java/com/nextcloud/utils/e2ee/E2EEActionResolverTest.kt b/app/src/test/java/com/nextcloud/utils/e2ee/E2EEActionResolverTest.kt new file mode 100644 index 000000000000..04384d8beab8 --- /dev/null +++ b/app/src/test/java/com/nextcloud/utils/e2ee/E2EEActionResolverTest.kt @@ -0,0 +1,133 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee + +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.utils.e2ee.model.E2EEAction +import com.nextcloud.utils.e2ee.model.E2EEKeyCheck +import com.owncloud.android.R +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.resources.status.CapabilityBooleanType +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.ui.dialog.setupEncryption.model.CertificateValidationResult +import com.owncloud.android.ui.dialog.setupEncryption.model.DownloadKeyResult +import com.owncloud.android.utils.EncryptionUtils +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class E2EEActionResolverTest { + private val user: User = mock() + private val accountManager: UserAccountManager = mock { + on { user }.thenReturn(this@E2EEActionResolverTest.user) + } + private val provider: ArbitraryDataProvider = mock() + private val storageManager: FileDataStorageManager = mock() + private val connectivityService: ConnectivityService = mock() + private val inspector: E2EEKeyInspector = mock() + private val resolver = E2EEActionResolver(storageManager, provider, accountManager, connectivityService, inspector) + + @Test + fun preservesEachCertificateFailureForEveryEncryptionActionWithoutMarkingFolderReadOnly() = runTest { + val capability: OCCapability = mock { + on { endToEndEncryptionKeysExist }.thenReturn(CapabilityBooleanType.TRUE) + on { endToEndEncryption }.thenReturn(CapabilityBooleanType.TRUE) + } + whenever(inspector.fetchCapabilities()).thenReturn(null) + whenever(storageManager.getCapability(user)).thenReturn(capability) + whenever(provider.getValue(user, EncryptionUtils.PUBLIC_KEY)).thenReturn(STORED_CERTIFICATE) + + CertificateValidationResult.entries.filter { it != CertificateValidationResult.VALID }.forEach { reason -> + whenever(inspector.compareWithServerKey(STORED_CERTIFICATE)) + .thenReturn(DownloadKeyResult.CertificateVerificationFailed(reason)) + + val result = resolver.checkKeys() + + assertEquals(E2EEKeyCheck.CHECK_FAILED, result.state) + E2EEAction.entries.forEach { action -> assertEquals(reason.descriptionId, result.getMessageId(action)) } + } + verify(storageManager, never()).setReadOnly(any(), any()) + } + + @Test + fun allowsOpeningAfterCertificateRefresh() = runTest { + val capability: OCCapability = mock { + on { endToEndEncryptionKeysExist }.thenReturn(CapabilityBooleanType.TRUE) + on { endToEndEncryption }.thenReturn(CapabilityBooleanType.TRUE) + } + whenever(inspector.fetchCapabilities()).thenReturn(null) + whenever(storageManager.getCapability(user)).thenReturn(capability) + whenever(provider.getValue(user, EncryptionUtils.PUBLIC_KEY)).thenReturn(STORED_CERTIFICATE) + whenever(inspector.compareWithServerKey(STORED_CERTIFICATE)).thenReturn(DownloadKeyResult.CompareKeys(true)) + + assertEquals(E2EEKeyCheck.SAME_AS_SERVER, resolver.checkKeys().state) + } + + @Test + fun preservesRateLimitingBeforeKeyDownloadWithoutTreatingKeysAsMissing() = runTest { + whenever(inspector.isLocalKeysAbsent()).thenReturn(true) + whenever(inspector.fetchCapabilities()).thenReturn( + DownloadKeyResult.RequestFailed(R.string.e2ee_key_request_rate_limited) + ) + doAnswer { + it.getArgument<(Boolean) -> Unit>(0)(true) + }.whenever(connectivityService).isNetworkAndServerAvailable(any()) + + val result = resolver.checkKeys() + + E2EEAction.entries.forEach { action -> + assertEquals(R.string.e2ee_key_request_rate_limited, result.getMessageId(action)) + } + verify(inspector, never()).compareWithServerKey(any()) + verify(storageManager, never()).setReadOnly(any(), any()) + verify(provider, never()).storeOrUpdateKeyValue(any(), any(), any()) + } + + @Test + fun preservesHttpFailureReasonsForAllEncryptionActionsWithoutChangingKeys() = runTest { + val capability: OCCapability = mock { + on { endToEndEncryptionKeysExist }.thenReturn(CapabilityBooleanType.TRUE) + on { endToEndEncryption }.thenReturn(CapabilityBooleanType.TRUE) + } + whenever(inspector.fetchCapabilities()).thenReturn(null) + whenever(storageManager.getCapability(user)).thenReturn(capability) + whenever(provider.getValue(user, EncryptionUtils.PUBLIC_KEY)).thenReturn(STORED_CERTIFICATE) + + for (httpCode in listOf(401, 403, 404, 429, 500, 503, 504)) { + val response = mock> { + on { this.httpCode } doReturn httpCode + } + val failure = E2EEKeyRequestError.from(response) + whenever(inspector.compareWithServerKey(STORED_CERTIFICATE)).thenReturn(failure) + + val result = resolver.checkKeys() + + assertEquals(E2EEKeyCheck.CHECK_FAILED, result.state) + E2EEAction.entries.forEach { action -> + assertEquals(failure.descriptionId, result.getMessageId(action)) + } + } + verify(storageManager, never()).setReadOnly(any(), any()) + verify(provider, never()).storeOrUpdateKeyValue(any(), any(), any()) + } + + companion object { + private const val STORED_CERTIFICATE = "stored certificate" + } +} diff --git a/app/src/test/java/com/nextcloud/utils/e2ee/E2EECertificateManagerTest.kt b/app/src/test/java/com/nextcloud/utils/e2ee/E2EECertificateManagerTest.kt new file mode 100644 index 000000000000..45bd46bfa6f1 --- /dev/null +++ b/app/src/test/java/com/nextcloud/utils/e2ee/E2EECertificateManagerTest.kt @@ -0,0 +1,156 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee + +import com.nextcloud.client.account.User +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator +import com.owncloud.android.ui.dialog.setupEncryption.model.CertificateValidationResult +import com.owncloud.android.ui.dialog.setupEncryption.model.DownloadKeyResult +import com.owncloud.android.utils.EncryptionUtils +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class E2EECertificateManagerTest { + private val user: User = mock() + private val provider: ArbitraryDataProvider = mock() + private val manager = E2EECertificateManager(CertificateValidator(), provider) + + @Before + fun setUp() { + whenever(provider.getValue(user, EncryptionUtils.PRIVATE_KEY)).thenReturn(CertificateTestFixtures.privateKey()) + } + + @Test + fun acceptsUnchangedCertificateWithoutWritingKeys() { + val certificate = CertificateTestFixtures.certificate() + assertEquals(DownloadKeyResult.CompareKeys(true), compare(certificate, certificate)) + verifyNoCertificateUpdate() + } + + @Test + fun refreshesRenewedCertificateWithSamePublicKeyEvenIfStoredCertificateExpired() { + val now = System.currentTimeMillis() + val stored = CertificateTestFixtures.certificate( + notBefore = now - CertificateTestFixtures.DAY_MILLIS * 2, + notAfter = now - CertificateTestFixtures.DAY_MILLIS + ) + val renewed = CertificateTestFixtures.certificate(serial = 2) + + assertEquals(DownloadKeyResult.CompareKeys(true), compare(stored, renewed)) + verify(provider).storeOrUpdateKeyValue(user, EncryptionUtils.PUBLIC_KEY, renewed) + verify(provider, never()).storeOrUpdateKeyValue(eq(user), eq(EncryptionUtils.PRIVATE_KEY), any()) + verify(provider, never()).storeOrUpdateKeyValue(eq(user), eq(EncryptionUtils.MNEMONIC), any()) + } + + @Test + fun acceptsEquivalentCertificateWithDifferentPemFormatting() { + val remote = CertificateTestFixtures.certificate() + val stored = remote.replace("\n", "\r\n") + " \n" + + assertEquals(DownloadKeyResult.CompareKeys(true), compare(stored, remote)) + verify(provider).storeOrUpdateKeyValue(user, EncryptionUtils.PUBLIC_KEY, remote) + } + + @Test + fun rejectsActualPublicKeyChangeWithoutOverwritingLocalCertificate() { + val stored = CertificateTestFixtures.certificate() + val remote = CertificateTestFixtures.certificate(keys = CertificateTestFixtures.otherKeys) + + assertEquals(DownloadKeyResult.CompareKeys(false), compare(stored, remote)) + verifyNoCertificateUpdate() + } + + @Test + fun rejectsExpiredRemoteCertificateEvenWithSameKey() { + val now = System.currentTimeMillis() + val expired = CertificateTestFixtures.certificate( + notBefore = now - CertificateTestFixtures.DAY_MILLIS * 2, + notAfter = now - CertificateTestFixtures.DAY_MILLIS + ) + + assertEquals( + DownloadKeyResult.CertificateVerificationFailed(CertificateValidationResult.EXPIRED), + compare(CertificateTestFixtures.certificate(), expired) + ) + verifyNoCertificateUpdate() + } + + @Test + fun rejectsUntrustedRemoteCertificateBeforeRefreshingLocalState() { + assertEquals( + DownloadKeyResult.CertificateVerificationFailed(CertificateValidationResult.INVALID_SIGNATURE), + manager.compareAndRefresh( + user, + CertificateTestFixtures.certificate(), + CertificateTestFixtures.certificate(serial = 2), + CertificateTestFixtures.publicKey(CertificateTestFixtures.otherKeys) + ) + ) + verifyNoCertificateUpdate() + } + + @Test + fun rejectsMismatchedLocalPrivateKeyWithoutOverwritingCertificate() { + whenever(provider.getValue(user, EncryptionUtils.PRIVATE_KEY)) + .thenReturn(CertificateTestFixtures.privateKey(CertificateTestFixtures.otherKeys)) + + assertEquals( + DownloadKeyResult.LocalKeysInvalid(), + compare(CertificateTestFixtures.certificate(), CertificateTestFixtures.certificate(serial = 2)) + ) + verifyNoCertificateUpdate() + } + + @Test + fun reportsMalformedLocalCertificateWithoutChangingKeys() { + assertEquals( + DownloadKeyResult.LocalKeysInvalid(), + compare("invalid local certificate", CertificateTestFixtures.certificate()) + ) + verifyNoCertificateUpdate() + } + + @Test + fun reportsMissingPrivateKeyWithoutChangingCertificate() { + whenever(provider.getValue(user, EncryptionUtils.PRIVATE_KEY)).thenReturn("") + + assertEquals( + DownloadKeyResult.LocalKeysInvalid(), + compare(CertificateTestFixtures.certificate(), CertificateTestFixtures.certificate(serial = 2)) + ) + verifyNoCertificateUpdate() + } + + @Test + fun acceptsPrivateKeyWithPemHeadersAndCrLf() { + val pem = CertificateTestFixtures.pem("PRIVATE KEY", CertificateTestFixtures.userKeys.private.encoded) + whenever(provider.getValue(user, EncryptionUtils.PRIVATE_KEY)).thenReturn(pem.replace("\n", "\r\n")) + val certificate = CertificateTestFixtures.certificate() + + assertEquals(DownloadKeyResult.CompareKeys(true), compare(certificate, certificate)) + } + + private fun compare(stored: String, remote: String): DownloadKeyResult = manager.compareAndRefresh( + user, + stored, + remote, + CertificateTestFixtures.publicKey() + ) + + private fun verifyNoCertificateUpdate() { + verify(provider, never()).storeOrUpdateKeyValue(eq(user), eq(EncryptionUtils.PUBLIC_KEY), any()) + } +} diff --git a/app/src/test/java/com/nextcloud/utils/e2ee/E2EEKeyRequestErrorTest.kt b/app/src/test/java/com/nextcloud/utils/e2ee/E2EEKeyRequestErrorTest.kt new file mode 100644 index 000000000000..268147a97dbd --- /dev/null +++ b/app/src/test/java/com/nextcloud/utils/e2ee/E2EEKeyRequestErrorTest.kt @@ -0,0 +1,114 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee + +import com.owncloud.android.R +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import org.json.JSONException +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import java.io.IOException +import java.net.SocketTimeoutException +import javax.net.ssl.SSLHandshakeException + +class E2EEKeyRequestErrorTest { + @Test + fun identifiesRateLimitingDespiteTheLibraryReportingAnUnhandledHttpCode() { + assertFailure( + response(httpCode = 429, code = ResultCode.UNHANDLED_HTTP_CODE), + R.string.e2ee_key_request_rate_limited + ) + } + + @Test + fun distinguishesAuthenticationAccessAndMissingResources() { + assertFailure(response(httpCode = 401), R.string.e2ee_key_request_unauthorized) + assertFailure(response(httpCode = 403), R.string.e2ee_key_request_forbidden) + assertFailure(response(httpCode = 404), R.string.e2ee_key_request_not_found) + assertFailure(response(httpCode = 302, code = ResultCode.UNAUTHORIZED), R.string.e2ee_key_request_unauthorized) + } + + @Test + fun distinguishesClientRejectionsServerErrorsAndTimeouts() { + listOf(400, 405, 409, 422, 499).forEach { + assertFailure(response(httpCode = it), R.string.e2ee_key_request_rejected) + } + listOf(500, 502, 503, 599).forEach { + assertFailure(response(httpCode = it), R.string.e2ee_key_request_server_error) + } + listOf(408, 504).forEach { + assertFailure(response(httpCode = it), R.string.e2ee_key_request_timeout) + } + } + + @Test + fun preservesNetworkAndTlsFailureReasonsWithoutAnHttpResponse() { + listOf( + ResultCode.NO_NETWORK_CONNECTION, + ResultCode.HOST_NOT_AVAILABLE, + ResultCode.INCORRECT_ADDRESS, + ResultCode.WRONG_CONNECTION + ).forEach { + assertFailure(response(code = it), R.string.e2ee_key_request_connection_failed) + } + listOf(ResultCode.SSL_ERROR, ResultCode.SSL_RECOVERABLE_PEER_UNVERIFIED).forEach { + assertFailure(response(code = it), R.string.e2ee_key_request_tls_error) + } + assertFailure(response(code = ResultCode.TIMEOUT), R.string.e2ee_key_request_timeout) + } + + @Test + fun keepsOperationAuthenticationFailuresSeparateFromConnectionFailures() { + listOf(ResultCode.UNAUTHORIZED, ResultCode.OAUTH2_ERROR, ResultCode.OAUTH2_ERROR_ACCESS_DENIED).forEach { + assertFailure(response(code = it), R.string.e2ee_key_request_unauthorized) + } + assertFailure(response(code = ResultCode.FORBIDDEN), R.string.e2ee_key_request_forbidden) + } + + @Test + fun identifiesAnUnreadableSuccessfulHttpResponseWithoutClaimingTheEncryptionKeyIsInvalid() { + assertFailure( + response(httpCode = 200, exception = JSONException("Missing public-key")), + R.string.e2ee_key_request_invalid_response + ) + assertFailure(response(code = ResultCode.WRONG_SERVER_RESPONSE), R.string.e2ee_key_request_invalid_response) + } + + @Test + fun usesExceptionDetailsWhenTheLibraryHasNoSpecificResultCode() { + assertFailure(response(exception = SSLHandshakeException("TLS")), R.string.e2ee_key_request_tls_error) + assertFailure(response(exception = SocketTimeoutException()), R.string.e2ee_key_request_timeout) + assertFailure(response(exception = IOException()), R.string.e2ee_key_request_connection_failed) + assertFailure(response(exception = IllegalStateException()), R.string.e2ee_key_request_failed) + } + + @Test + fun prioritizesAnExplicitServerRejectionOverResponseParsingErrors() { + assertFailure( + response(httpCode = 429, exception = JSONException("Not JSON")), + R.string.e2ee_key_request_rate_limited + ) + } + + private fun assertFailure(response: RemoteOperationResult, expectedDescription: Int) { + assertEquals(expectedDescription, E2EEKeyRequestError.from(response).descriptionId) + } + + private fun response( + httpCode: Int = -1, + code: ResultCode = ResultCode.UNKNOWN_ERROR, + exception: Exception? = null + ): RemoteOperationResult = mock { + on { this.httpCode } doReturn httpCode + on { this.code } doReturn code + on { this.exception } doReturn exception + } +}