From 9d22f9ffa77748a4145d50dded4f5b0e40cc1c22 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Mon, 21 Sep 2026 17:24:16 +0200 Subject: [PATCH 01/16] Refactoring to extract key generation for better testing Signed-off-by: daniele-verducci --- .../owncloud/android/EncryptedFoldersIT.kt | 131 ++++++++++++++++++ .../setupEncryption/EncryptionKeyGenerator.kt | 118 ++++++++++++++++ .../SetupEncryptionDialogFragment.kt | 97 +------------ 3 files changed, 253 insertions(+), 93 deletions(-) create mode 100644 app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt create mode 100644 app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt diff --git a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt new file mode 100644 index 000000000000..23f6337bf24b --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt @@ -0,0 +1,131 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.owncloud.android + +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.status.NextcloudVersion +import com.owncloud.android.operations.CreateFolderOperation +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +open class EncryptedFoldersIT : AbstractOnServerIT() { + companion object { + val TAG = EncryptedFoldersIT::class.simpleName + val FOLDER = "/encryptedFolder/" + val SUBFOLDER = "${FOLDER}encryptedSubfolder/" + val KEYWORDS = arrayListOf( + "ability", + "able", + "about", + "above", + "absent", + "absorb", + "abstract", + "absurd", + "abuse", + "access", + "accident", + "account", + "accuse" + ) + } + + @Test + fun testCreateEncryptedFolder() { + val result = createEncryptedFolder(FOLDER) + assertTrue(result.isSuccess) + } + + @Test + fun testCreateEncryptedSubfolder() { + // Create parent folder only for Nextcloud < 32; 32+ handles this automatically. + if (capability.version.isOlderThan(NextcloudVersion.nextcloud_32)) { + assertTrue(createEncryptedFolder(FOLDER).isSuccess) + } + val result = createEncryptedFolder(SUBFOLDER) + assertTrue(result.isSuccess) + } + + @Test + fun testReadEncryptedFolder() { + // Check keys + val state = e2eeActionResolver.checkKeys() + // Create folder + assertTrue(createEncryptedFolder(FOLDER).isSuccess) + // Open folder + + } + + @Test + fun testReadEncryptedSubfolder() { + + } + + @Test + fun testUpdateEncryptedFolder() { + // Rename folder + } + + @Test + fun testUpdateEncryptedSubfolder() { + + } + + @Test + fun testDeleteEncryptedFolder() { + + } + + @Test + fun testDeleteEncryptedSubfolder() { + + } + + @Test + fun testUploadInEncryptedFolder() { + } + + @Test + fun testUploadInEncryptedSubfolder() { + + } + + @Test + fun testEncryptExistingFolder() { + + } + + @After + fun cleanup() { + storageManager.deleteAllFiles() + } + + @Before + fun encryptionSetup() { + //val privateKey: String = EncryptionKeyGenerator.generatePrivateKey(targetContext, user ?: return, KEYWORDS) + val capability = storageManager.getCapability(user.accountName) + if (capability.endToEndEncryption.isFalse || capability.endToEndEncryption.isUnknown) { + Log_OC.e(TAG, "Server does not support E2EE") + } + } + + private fun createEncryptedFolder(remotePath: String): RemoteOperationResult<*> { + return CreateFolderOperation(remotePath, user, targetContext, storageManager).apply { + setEncrypt(true) + }.execute(client) + } + + private fun listEncryptedFolder() { + + } + + +} diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt new file mode 100644 index 000000000000..ceb7d578f7a3 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt @@ -0,0 +1,118 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Daniele Verducci + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.dialog.setupEncryption + +import android.accounts.AccountManager +import android.content.Context +import com.nextcloud.client.account.User +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl +import com.owncloud.android.lib.common.accounts.AccountUtils +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.e2ee.CsrHelper +import com.owncloud.android.lib.resources.users.DeletePublicKeyRemoteOperation +import com.owncloud.android.lib.resources.users.SendCSRRemoteOperation +import com.owncloud.android.lib.resources.users.StorePrivateKeyRemoteOperation +import com.owncloud.android.utils.EncryptionUtils +import com.owncloud.android.utils.crypto.CryptoHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object EncryptionKeyGenerator { + + val TAG: String = EncryptionKeyGenerator::class.java.simpleName + + suspend fun generatePrivateKey( + context: Context, + user: User, + keyWords: ArrayList? + ): String= withContext(Dispatchers.IO) { + + val arbitraryDataProvider = ArbitraryDataProviderImpl(context) + + // - create CSR, push to server, store returned public key in database + // - encrypt private key, push key to server, store unencrypted private key in database + try { + val certificate: String + + // Create public/private key pair + val keyPair = EncryptionUtils.generateKeyPair() + + // create CSR + val accountManager = AccountManager.get(context) + + val userId = accountManager.getUserData(user.toPlatformAccount(), AccountUtils.Constants.KEY_USER_ID) + val urlEncoded = CsrHelper().generateCsrPemEncodedString(keyPair, userId) + val operation = SendCSRRemoteOperation(urlEncoded) + val result = operation.executeNextcloudClient(user, context) + + if (result.isSuccess) { + certificate = result.resultData + if (!EncryptionUtils.isMatchingKeys(keyPair, certificate)) { + EncryptionUtils.reportE2eError(arbitraryDataProvider, user) + throw RuntimeException("Wrong CSR returned") + } + Log_OC.d(TAG, "public key success") + } else { + return@withContext "" + } + + val privateKey = keyPair.private + val privateKeyString = EncryptionUtils.encodeBytesToBase64String(privateKey.encoded) + val privatePemKeyString = EncryptionUtils.privateKeyToPEM(privateKey) + val encryptedPrivateKey = CryptoHelper.encryptPrivateKey( + privatePemKeyString, + generateMnemonicString(keyWords, false) + ) + + // upload encryptedPrivateKey + val storePrivateKeyOperation = StorePrivateKeyRemoteOperation(encryptedPrivateKey) + val storePrivateKeyResult = storePrivateKeyOperation.executeNextcloudClient(user, context) + if (storePrivateKeyResult.isSuccess) { + Log_OC.d(TAG, "private key success") + arbitraryDataProvider.storeOrUpdateKeyValue( + user.accountName, + EncryptionUtils.PRIVATE_KEY, + privateKeyString + ) + arbitraryDataProvider.storeOrUpdateKeyValue( + user.accountName, + EncryptionUtils.PUBLIC_KEY, + certificate + ) + arbitraryDataProvider.storeOrUpdateKeyValue( + user.accountName, + EncryptionUtils.MNEMONIC, + generateMnemonicString(keyWords, true) + ) + + return@withContext storePrivateKeyResult.resultData + } else { + val deletePublicKeyOperation = DeletePublicKeyRemoteOperation() + deletePublicKeyOperation.executeNextcloudClient(user, context) + } + } catch (e: Exception) { + Log_OC.e(TAG, e.message) + } + return@withContext "" + } + + fun generateMnemonicString(keyWords: ArrayList?, withWhitespace: Boolean): String { + val stringBuilder = StringBuilder() + + keyWords?.let { + for (string in it) { + stringBuilder.append(string) + if (withWhitespace) { + stringBuilder.append(' ') + } + } + } + + return stringBuilder.toString() + } +} \ No newline at end of file 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 36c72feeae1b..e65aaed252dd 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 @@ -6,7 +6,6 @@ */ package com.owncloud.android.ui.dialog.setupEncryption -import android.accounts.AccountManager import android.app.Dialog import android.content.DialogInterface import android.os.Bundle @@ -30,15 +29,10 @@ import com.owncloud.android.R import com.owncloud.android.databinding.SetupEncryptionDialogBinding import com.owncloud.android.datamodel.ArbitraryDataProvider import com.owncloud.android.datamodel.ArbitraryDataProviderImpl -import com.owncloud.android.lib.common.accounts.AccountUtils import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.lib.resources.e2ee.CsrHelper -import com.owncloud.android.lib.resources.users.DeletePublicKeyRemoteOperation import com.owncloud.android.lib.resources.users.GetPrivateKeyRemoteOperation import com.owncloud.android.lib.resources.users.GetPublicKeyRemoteOperation import com.owncloud.android.lib.resources.users.GetServerPublicKeyRemoteOperation -import com.owncloud.android.lib.resources.users.SendCSRRemoteOperation -import com.owncloud.android.lib.resources.users.StorePrivateKeyRemoteOperation import com.owncloud.android.ui.dialog.extensions.themeButtons import com.owncloud.android.ui.dialog.setupEncryption.model.DownloadKeyResult import com.owncloud.android.utils.ClipboardUtil @@ -363,81 +357,13 @@ class SetupEncryptionDialogFragment : private suspend fun generateNewKeys() { binding.encryptionStatus.setText(R.string.end_to_end_encryption_generating_keys) val context = context ?: return - val privateKey: String = withContext(Dispatchers.IO) { - // - create CSR, push to server, store returned public key in database - // - encrypt private key, push key to server, store unencrypted private key in database - try { - val certificate: String - - // Create public/private key pair - val keyPair = EncryptionUtils.generateKeyPair() - - // create CSR - val accountManager = AccountManager.get(context) - val user = user ?: return@withContext "" - - val userId = accountManager.getUserData(user.toPlatformAccount(), AccountUtils.Constants.KEY_USER_ID) - val urlEncoded = CsrHelper().generateCsrPemEncodedString(keyPair, userId) - val operation = SendCSRRemoteOperation(urlEncoded) - val result = operation.executeNextcloudClient(user, context) - - if (result.isSuccess) { - certificate = result.resultData - if (!EncryptionUtils.isMatchingKeys(keyPair, certificate)) { - EncryptionUtils.reportE2eError(arbitraryDataProvider, user) - throw RuntimeException("Wrong CSR returned") - } - Log_OC.d(TAG, "public key success") - } else { - keyResult = KEY_FAILED - return@withContext "" - } - - val privateKey = keyPair.private - val privateKeyString = EncryptionUtils.encodeBytesToBase64String(privateKey.encoded) - val privatePemKeyString = EncryptionUtils.privateKeyToPEM(privateKey) - val encryptedPrivateKey = CryptoHelper.encryptPrivateKey( - privatePemKeyString, - generateMnemonicString(false) - ) - - // upload encryptedPrivateKey - val storePrivateKeyOperation = StorePrivateKeyRemoteOperation(encryptedPrivateKey) - val storePrivateKeyResult = storePrivateKeyOperation.executeNextcloudClient(user, context) - if (storePrivateKeyResult.isSuccess) { - Log_OC.d(TAG, "private key success") - arbitraryDataProvider?.storeOrUpdateKeyValue( - user.accountName, - EncryptionUtils.PRIVATE_KEY, - privateKeyString - ) - arbitraryDataProvider?.storeOrUpdateKeyValue( - user.accountName, - EncryptionUtils.PUBLIC_KEY, - certificate - ) - arbitraryDataProvider?.storeOrUpdateKeyValue( - user.accountName, - EncryptionUtils.MNEMONIC, - generateMnemonicString(true) - ) - keyResult = KEY_CREATED - - return@withContext storePrivateKeyResult.resultData - } else { - val deletePublicKeyOperation = DeletePublicKeyRemoteOperation() - deletePublicKeyOperation.executeNextcloudClient(user, context) - } - } catch (e: Exception) { - Log_OC.e(TAG, e.message) - } - keyResult = KEY_FAILED - return@withContext "" - } + val privateKey: String = EncryptionKeyGenerator.generatePrivateKey(context, user ?: return, keyWords) if (privateKey.isEmpty()) { + keyResult = KEY_FAILED errorSavingKeys() } else { + keyResult = KEY_GENERATE if (dialog == null) { Log_OC.e(TAG, "Dialog is null cannot proceed further.") return @@ -447,21 +373,6 @@ class SetupEncryptionDialogFragment : } } - private fun generateMnemonicString(withWhitespace: Boolean): String { - val stringBuilder = StringBuilder() - - keyWords?.let { - for (string in it) { - stringBuilder.append(string) - if (withWhitespace) { - stringBuilder.append(' ') - } - } - } - - return stringBuilder.toString() - } - @VisibleForTesting fun showMnemonicInfo() { if (dialog == null) { @@ -471,7 +382,7 @@ class SetupEncryptionDialogFragment : requireDialog().setTitle(R.string.end_to_end_encryption_passphrase_title) binding.encryptionStatus.setText(R.string.end_to_end_encryption_keywords_description) viewThemeUtils.material.colorTextInputLayout(binding.encryptionPasswordInputContainer) - binding.encryptionPassphrase.text = generateMnemonicString(true) + binding.encryptionPassphrase.text = EncryptionKeyGenerator.generateMnemonicString(keyWords, true) binding.encryptionPassphrase.visibility = View.VISIBLE setupCopyPassphraseButton() From 64a5ade762e0abda3ec2c03e90593f5764f07d89 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Tue, 22 Sep 2026 18:41:25 +0200 Subject: [PATCH 02/16] Working create and read tests for folder and subfolder Signed-off-by: daniele-verducci --- .../owncloud/android/EncryptedFoldersIT.kt | 56 +++++++++++-------- .../operations/e2e/E2EDeletionService.kt | 47 ++++++++-------- 2 files changed, 58 insertions(+), 45 deletions(-) diff --git a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt index 23f6337bf24b..381f7bf2e9c2 100644 --- a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt @@ -7,11 +7,18 @@ package com.owncloud.android +import com.nextcloud.client.database.entity.FileEntity +import com.nextcloud.client.network.NetworkModule import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.status.NextcloudVersion import com.owncloud.android.operations.CreateFolderOperation -import org.junit.After +import com.owncloud.android.operations.e2e.E2EDeletionService +import com.owncloud.android.ui.dialog.setupEncryption.EncryptionKeyGenerator +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -56,16 +63,20 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { @Test fun testReadEncryptedFolder() { - // Check keys - val state = e2eeActionResolver.checkKeys() + val remotePath = FOLDER // Create folder - assertTrue(createEncryptedFolder(FOLDER).isSuccess) - // Open folder - + assertTrue(createEncryptedFolder(remotePath).isSuccess) + val files = listEncryptedFolder(remotePath) + assertEquals(files.size, 0) } @Test fun testReadEncryptedSubfolder() { + val remotePath = SUBFOLDER + // Create folder + assertTrue(createEncryptedFolder(remotePath).isSuccess) + val files = listEncryptedFolder(remotePath) + assertEquals(files.size, 0) } @@ -89,32 +100,25 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { } - @Test - fun testUploadInEncryptedFolder() { - } - - @Test - fun testUploadInEncryptedSubfolder() { - - } - @Test fun testEncryptExistingFolder() { } - @After - fun cleanup() { - storageManager.deleteAllFiles() - } - @Before fun encryptionSetup() { - //val privateKey: String = EncryptionKeyGenerator.generatePrivateKey(targetContext, user ?: return, KEYWORDS) val capability = storageManager.getCapability(user.accountName) if (capability.endToEndEncryption.isFalse || capability.endToEndEncryption.isUnknown) { Log_OC.e(TAG, "Server does not support E2EE") } + // Delete existing encryption key, if any + E2EDeletionService(NetworkModule().clientFactory(targetContext)).deleteKeysAndFiles(user) + // Create new encryption key + val privateKey: String = runBlocking { + EncryptionKeyGenerator.generatePrivateKey(targetContext, user, KEYWORDS) + } + // Check the key was generated + assertNotEquals(privateKey, "") } private fun createEncryptedFolder(remotePath: String): RemoteOperationResult<*> { @@ -123,8 +127,14 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { }.execute(client) } - private fun listEncryptedFolder() { - + private fun listEncryptedFolder(remotePath: String): List { + // Obtain folder id + val ocFile = storageManager.getFileByRemotePath(remotePath) + assertNotNull(ocFile) + // Open folder + return runBlocking { + storageManager.fileDao.getFolderContentSuspended(ocFile!!.fileId) + } } diff --git a/app/src/main/java/com/owncloud/android/operations/e2e/E2EDeletionService.kt b/app/src/main/java/com/owncloud/android/operations/e2e/E2EDeletionService.kt index d3b9038bf3e2..69de70a12141 100644 --- a/app/src/main/java/com/owncloud/android/operations/e2e/E2EDeletionService.kt +++ b/app/src/main/java/com/owncloud/android/operations/e2e/E2EDeletionService.kt @@ -19,6 +19,7 @@ import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.e2ee.DeleteEncryptedFilesRemoteOperation import com.owncloud.android.lib.resources.users.DeletePrivateKeyRemoteOperation import com.owncloud.android.lib.resources.users.DeletePublicKeyRemoteOperation +import org.jetbrains.annotations.VisibleForTesting @Suppress("MagicNumber") class E2EDeletionService(private val clientFactory: ClientFactory) { @@ -41,36 +42,38 @@ class E2EDeletionService(private val clientFactory: ClientFactory) { private fun deleteKeysAndFiles(user: User, onResult: (Boolean) -> Unit) { Thread { - val result = runCatching { - val client = clientFactory.createNextcloudClient(user) - var successfulOperationResultCount = 3 + val result = deleteKeysAndFiles(user) + mainHandler.post { onResult(result) } + }.start() + } - if (!DeletePrivateKeyRemoteOperation().execute(client).isSuccess) { - successfulOperationResultCount -= 1 - } + @VisibleForTesting + fun deleteKeysAndFiles(user: User): Boolean = runCatching { + val client = clientFactory.createNextcloudClient(user) + var successfulOperationResultCount = 3 - Log_OC.i(TAG, "🔑" + "private key is deleted") + if (!DeletePrivateKeyRemoteOperation().execute(client).isSuccess) { + successfulOperationResultCount -= 1 + } - if (!DeletePublicKeyRemoteOperation().execute(client).isSuccess) { - successfulOperationResultCount -= 1 - } + Log_OC.i(TAG, "🔑" + "private key is deleted") - Log_OC.i(TAG, "🗝" + "public key is deleted") + if (!DeletePublicKeyRemoteOperation().execute(client).isSuccess) { + successfulOperationResultCount -= 1 + } - if (!DeleteEncryptedFilesRemoteOperation().execute(client).isSuccess) { - successfulOperationResultCount -= 1 - } + Log_OC.i(TAG, "🗝" + "public key is deleted") - Log_OC.i(TAG, "🗂️" + "encrypted files are deleted") + if (!DeleteEncryptedFilesRemoteOperation().execute(client).isSuccess) { + successfulOperationResultCount -= 1 + } - successfulOperationResultCount == 3 - }.getOrElse { e -> - Log.e(TAG, "Cannot delete E2E keys and files", e) - false - } + Log_OC.i(TAG, "🗂️" + "encrypted files are deleted") - mainHandler.post { onResult(result) } - }.start() + successfulOperationResultCount == 3 + }.getOrElse { e -> + Log.e(TAG, "Cannot delete E2E keys and files", e) + false } companion object { From afe6da41a04fba7f1edb14bdfdf0a031cb231061 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Wed, 23 Sep 2026 10:02:16 +0200 Subject: [PATCH 03/16] Fix lint + a small refactoring to make generatePrivateKey() more readable Signed-off-by: daniele-verducci --- .../owncloud/android/EncryptedFoldersIT.kt | 26 ++--- .../setupEncryption/EncryptionKeyGenerator.kt | 101 +++++++++--------- .../SetupEncryptionDialogFragment.kt | 4 +- 3 files changed, 60 insertions(+), 71 deletions(-) diff --git a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt index 381f7bf2e9c2..3249d3b99940 100644 --- a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt @@ -77,7 +77,6 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { assertTrue(createEncryptedFolder(remotePath).isSuccess) val files = listEncryptedFolder(remotePath) assertEquals(files.size, 0) - } @Test @@ -87,22 +86,18 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { @Test fun testUpdateEncryptedSubfolder() { - } - @Test - fun testDeleteEncryptedFolder() { - - } - - @Test - fun testDeleteEncryptedSubfolder() { + @Test + fun testDeleteEncryptedFolder() { + } - } + @Test + fun testDeleteEncryptedSubfolder() { + } @Test fun testEncryptExistingFolder() { - } @Before @@ -115,17 +110,16 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { E2EDeletionService(NetworkModule().clientFactory(targetContext)).deleteKeysAndFiles(user) // Create new encryption key val privateKey: String = runBlocking { - EncryptionKeyGenerator.generatePrivateKey(targetContext, user, KEYWORDS) + EncryptionKeyGenerator(targetContext, user).generatePrivateKey(KEYWORDS) } // Check the key was generated assertNotEquals(privateKey, "") } - private fun createEncryptedFolder(remotePath: String): RemoteOperationResult<*> { - return CreateFolderOperation(remotePath, user, targetContext, storageManager).apply { + private fun createEncryptedFolder(remotePath: String): RemoteOperationResult<*> = + CreateFolderOperation(remotePath, user, targetContext, storageManager).apply { setEncrypt(true) }.execute(client) - } private fun listEncryptedFolder(remotePath: String): List { // Obtain folder id @@ -136,6 +130,4 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { storageManager.fileDao.getFolderContentSuspended(ocFile!!.fileId) } } - - } diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt index ceb7d578f7a3..1b3de5e0fc88 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt @@ -22,16 +22,28 @@ import com.owncloud.android.utils.crypto.CryptoHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -object EncryptionKeyGenerator { - - val TAG: String = EncryptionKeyGenerator::class.java.simpleName +class EncryptionKeyGenerator(val context: Context, val user: User) { + companion object { + val TAG: String = EncryptionKeyGenerator::class.java.simpleName + + fun generateMnemonicString(keyWords: ArrayList?, withWhitespace: Boolean): String { + val stringBuilder = StringBuilder() + + keyWords?.let { + for (string in it) { + stringBuilder.append(string) + if (withWhitespace) { + stringBuilder.append(' ') + } + } + } - suspend fun generatePrivateKey( - context: Context, - user: User, - keyWords: ArrayList? - ): String= withContext(Dispatchers.IO) { + return stringBuilder.toString() + } + } + @Suppress("TooGenericExceptionCaught", "TooGenericExceptionThrown", "ReturnCount") + suspend fun generatePrivateKey(keyWords: ArrayList?): String = withContext(Dispatchers.IO) { val arbitraryDataProvider = ArbitraryDataProviderImpl(context) // - create CSR, push to server, store returned public key in database @@ -50,17 +62,17 @@ object EncryptionKeyGenerator { val operation = SendCSRRemoteOperation(urlEncoded) val result = operation.executeNextcloudClient(user, context) - if (result.isSuccess) { - certificate = result.resultData - if (!EncryptionUtils.isMatchingKeys(keyPair, certificate)) { - EncryptionUtils.reportE2eError(arbitraryDataProvider, user) - throw RuntimeException("Wrong CSR returned") - } - Log_OC.d(TAG, "public key success") - } else { + if (!result.isSuccess) { return@withContext "" } + certificate = result.resultData + if (!EncryptionUtils.isMatchingKeys(keyPair, certificate)) { + EncryptionUtils.reportE2eError(arbitraryDataProvider, user) + throw RuntimeException("Wrong CSR returned") + } + Log_OC.d(TAG, "public key success") + val privateKey = keyPair.private val privateKeyString = EncryptionUtils.encodeBytesToBase64String(privateKey.encoded) val privatePemKeyString = EncryptionUtils.privateKeyToPEM(privateKey) @@ -72,47 +84,32 @@ object EncryptionKeyGenerator { // upload encryptedPrivateKey val storePrivateKeyOperation = StorePrivateKeyRemoteOperation(encryptedPrivateKey) val storePrivateKeyResult = storePrivateKeyOperation.executeNextcloudClient(user, context) - if (storePrivateKeyResult.isSuccess) { - Log_OC.d(TAG, "private key success") - arbitraryDataProvider.storeOrUpdateKeyValue( - user.accountName, - EncryptionUtils.PRIVATE_KEY, - privateKeyString - ) - arbitraryDataProvider.storeOrUpdateKeyValue( - user.accountName, - EncryptionUtils.PUBLIC_KEY, - certificate - ) - arbitraryDataProvider.storeOrUpdateKeyValue( - user.accountName, - EncryptionUtils.MNEMONIC, - generateMnemonicString(keyWords, true) - ) - - return@withContext storePrivateKeyResult.resultData - } else { + if (!storePrivateKeyResult.isSuccess) { val deletePublicKeyOperation = DeletePublicKeyRemoteOperation() deletePublicKeyOperation.executeNextcloudClient(user, context) + return@withContext "" } + + Log_OC.d(TAG, "private key success") + arbitraryDataProvider.storeOrUpdateKeyValue( + user.accountName, + EncryptionUtils.PRIVATE_KEY, + privateKeyString + ) + arbitraryDataProvider.storeOrUpdateKeyValue( + user.accountName, + EncryptionUtils.PUBLIC_KEY, + certificate + ) + arbitraryDataProvider.storeOrUpdateKeyValue( + user.accountName, + EncryptionUtils.MNEMONIC, + generateMnemonicString(keyWords, true) + ) + return@withContext storePrivateKeyResult.resultData } catch (e: Exception) { Log_OC.e(TAG, e.message) } return@withContext "" } - - fun generateMnemonicString(keyWords: ArrayList?, withWhitespace: Boolean): String { - val stringBuilder = StringBuilder() - - keyWords?.let { - for (string in it) { - stringBuilder.append(string) - if (withWhitespace) { - stringBuilder.append(' ') - } - } - } - - return stringBuilder.toString() - } -} \ No newline at end of file +} 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 e65aaed252dd..85cce33fe3cf 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 @@ -353,11 +353,11 @@ class SetupEncryptionDialogFragment : } } - @Suppress("LongMethod", "TooGenericExceptionCaught", "TooGenericExceptionThrown") + @Suppress("ReturnCount") private suspend fun generateNewKeys() { binding.encryptionStatus.setText(R.string.end_to_end_encryption_generating_keys) val context = context ?: return - val privateKey: String = EncryptionKeyGenerator.generatePrivateKey(context, user ?: return, keyWords) + val privateKey: String = EncryptionKeyGenerator(context, user ?: return).generatePrivateKey(keyWords) if (privateKey.isEmpty()) { keyResult = KEY_FAILED From b8631cec76463c63ffd9bfc0f70034e366f319b2 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Wed, 23 Sep 2026 10:09:42 +0200 Subject: [PATCH 04/16] Cleanup keys after tests run Signed-off-by: daniele-verducci --- .../java/com/owncloud/android/EncryptedFoldersIT.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt index 3249d3b99940..62a0eec172f9 100644 --- a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt @@ -17,6 +17,7 @@ import com.owncloud.android.operations.e2e.E2EDeletionService import com.owncloud.android.ui.dialog.setupEncryption.EncryptionKeyGenerator import junit.framework.TestCase.assertEquals import kotlinx.coroutines.runBlocking +import org.junit.After import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue @@ -116,6 +117,12 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { assertNotEquals(privateKey, "") } + @After + fun encryptionCleanup() { + // Delete existing encryption key, if any + E2EDeletionService(NetworkModule().clientFactory(targetContext)).deleteKeysAndFiles(user) + } + private fun createEncryptedFolder(remotePath: String): RemoteOperationResult<*> = CreateFolderOperation(remotePath, user, targetContext, storageManager).apply { setEncrypt(true) From 2fe4ec0603b30e654cfdd727ffd526c11ab8755c Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Wed, 23 Sep 2026 11:24:51 +0200 Subject: [PATCH 05/16] Added metadata folder check in folder opening check Signed-off-by: daniele-verducci --- .../owncloud/android/EncryptedFoldersIT.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt index 62a0eec172f9..a6376d965f54 100644 --- a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt @@ -7,13 +7,18 @@ package com.owncloud.android +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.account.UserAccountManagerImpl import com.nextcloud.client.database.entity.FileEntity import com.nextcloud.client.network.NetworkModule +import com.nextcloud.utils.e2ee.E2EEActionResolver +import com.nextcloud.utils.e2ee.E2EEKeyInspector import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.status.NextcloudVersion import com.owncloud.android.operations.CreateFolderOperation import com.owncloud.android.operations.e2e.E2EDeletionService +import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator import com.owncloud.android.ui.dialog.setupEncryption.EncryptionKeyGenerator import junit.framework.TestCase.assertEquals import kotlinx.coroutines.runBlocking @@ -46,6 +51,26 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { ) } + private var e2eeActionResolver: E2EEActionResolver + + init { + val accountManager: UserAccountManager = UserAccountManagerImpl.fromContext(targetContext) + val inspector = E2EEKeyInspector( + targetContext, + storageManager, + CertificateValidator(), + arbitraryDataProvider, + accountManager + ) + e2eeActionResolver = E2EEActionResolver( + storageManager, + arbitraryDataProvider, + accountManager, + connectivityServiceMock, + inspector + ) + } + @Test fun testCreateEncryptedFolder() { val result = createEncryptedFolder(FOLDER) @@ -132,6 +157,12 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { // Obtain folder id val ocFile = storageManager.getFileByRemotePath(remotePath) assertNotNull(ocFile) + + // Check folder metadata + runBlocking { + assertTrue(e2eeActionResolver.checkFolderMetadataKey(ocFile!!)) + } + // Open folder return runBlocking { storageManager.fileDao.getFolderContentSuspended(ocFile!!.fileId) From d168de3fdbb1001ac7659137a2815163e86628b5 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Wed, 23 Sep 2026 16:49:09 +0200 Subject: [PATCH 06/16] Fixed empty capability in test Signed-off-by: daniele-verducci --- .../java/com/owncloud/android/EncryptedFoldersIT.kt | 7 ++++++- .../java/com/nextcloud/utils/e2ee/E2EEActionResolver.kt | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt index a6376d965f54..df20e00bd9c3 100644 --- a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt @@ -15,6 +15,7 @@ import com.nextcloud.utils.e2ee.E2EEActionResolver import com.nextcloud.utils.e2ee.E2EEKeyInspector import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.status.GetCapabilitiesRemoteOperation import com.owncloud.android.lib.resources.status.NextcloudVersion import com.owncloud.android.operations.CreateFolderOperation import com.owncloud.android.operations.e2e.E2EDeletionService @@ -128,7 +129,11 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { @Before fun encryptionSetup() { - val capability = storageManager.getCapability(user.accountName) + // Fetch capabiliy + val capability = GetCapabilitiesRemoteOperation(null).execute(client).getResultData() + storageManager.saveCapabilities(capability) + + // Check end2end capability if (capability.endToEndEncryption.isFalse || capability.endToEndEncryption.isUnknown) { Log_OC.e(TAG, "Server does not support E2EE") } 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 db221aaeb968..21c23cdb6806 100644 --- a/app/src/main/java/com/nextcloud/utils/e2ee/E2EEActionResolver.kt +++ b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEActionResolver.kt @@ -40,7 +40,7 @@ class E2EEActionResolver @Inject constructor( } suspend fun checkFolderMetadataKey(file: OCFile): Boolean = withContext(Dispatchers.IO) { - val capability = storageManager.getCapability(accountManager.user) + val capability = storageManager.getCapability(accountManager.user.accountName) val canDecrypt = inspector.canDecryptFolderMetadata(file, capability) storageManager.setReadOnly(file, !canDecrypt) return@withContext canDecrypt From 8922d3e5f5d63f81fed4f812bfa20fb5786ddac1 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Wed, 23 Sep 2026 18:51:37 +0200 Subject: [PATCH 07/16] Working encrypted folder test Signed-off-by: daniele-verducci --- .../owncloud/android/EncryptedFoldersIT.kt | 56 +++++++++++---- .../setupEncryption/EncryptionKeyGenerator.kt | 68 +++++++++++++++++++ .../android/ui/fragment/FolderEncryption.kt | 64 +++-------------- 3 files changed, 123 insertions(+), 65 deletions(-) diff --git a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt index df20e00bd9c3..9beea98021d3 100644 --- a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt @@ -13,14 +13,16 @@ import com.nextcloud.client.database.entity.FileEntity import com.nextcloud.client.network.NetworkModule import com.nextcloud.utils.e2ee.E2EEActionResolver import com.nextcloud.utils.e2ee.E2EEKeyInspector -import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.e2ee.ToggleEncryptionRemoteOperation import com.owncloud.android.lib.resources.status.GetCapabilitiesRemoteOperation import com.owncloud.android.lib.resources.status.NextcloudVersion import com.owncloud.android.operations.CreateFolderOperation +import com.owncloud.android.operations.RefreshFolderOperation import com.owncloud.android.operations.e2e.E2EDeletionService import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator import com.owncloud.android.ui.dialog.setupEncryption.EncryptionKeyGenerator +import com.owncloud.android.utils.EncryptionUtils import junit.framework.TestCase.assertEquals import kotlinx.coroutines.runBlocking import org.junit.After @@ -53,6 +55,7 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { } private var e2eeActionResolver: E2EEActionResolver + private var encryptionKeyGenerator: EncryptionKeyGenerator init { val accountManager: UserAccountManager = UserAccountManagerImpl.fromContext(targetContext) @@ -70,29 +73,30 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { connectivityServiceMock, inspector ) + encryptionKeyGenerator = EncryptionKeyGenerator(targetContext, user) } @Test fun testCreateEncryptedFolder() { val result = createEncryptedFolder(FOLDER) - assertTrue(result.isSuccess) + assertTrue(result) } @Test fun testCreateEncryptedSubfolder() { // Create parent folder only for Nextcloud < 32; 32+ handles this automatically. if (capability.version.isOlderThan(NextcloudVersion.nextcloud_32)) { - assertTrue(createEncryptedFolder(FOLDER).isSuccess) + assertTrue(createEncryptedFolder(FOLDER)) } val result = createEncryptedFolder(SUBFOLDER) - assertTrue(result.isSuccess) + assertTrue(result) } @Test fun testReadEncryptedFolder() { val remotePath = FOLDER // Create folder - assertTrue(createEncryptedFolder(remotePath).isSuccess) + assertTrue(createEncryptedFolder(remotePath)) val files = listEncryptedFolder(remotePath) assertEquals(files.size, 0) } @@ -101,7 +105,8 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { fun testReadEncryptedSubfolder() { val remotePath = SUBFOLDER // Create folder - assertTrue(createEncryptedFolder(remotePath).isSuccess) + assertTrue(createEncryptedFolder(FOLDER)) + assertTrue(createEncryptedFolder(remotePath)) val files = listEncryptedFolder(remotePath) assertEquals(files.size, 0) } @@ -141,7 +146,7 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { E2EDeletionService(NetworkModule().clientFactory(targetContext)).deleteKeysAndFiles(user) // Create new encryption key val privateKey: String = runBlocking { - EncryptionKeyGenerator(targetContext, user).generatePrivateKey(KEYWORDS) + encryptionKeyGenerator.generatePrivateKey(KEYWORDS) } // Check the key was generated assertNotEquals(privateKey, "") @@ -153,24 +158,51 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { E2EDeletionService(NetworkModule().clientFactory(targetContext)).deleteKeysAndFiles(user) } - private fun createEncryptedFolder(remotePath: String): RemoteOperationResult<*> = - CreateFolderOperation(remotePath, user, targetContext, storageManager).apply { + private fun createEncryptedFolder(remotePath: String): Boolean { + val created = CreateFolderOperation(remotePath, user, targetContext, storageManager).apply { setEncrypt(true) }.execute(client) + val ocFile = storageManager.getFileByRemotePath(remotePath) + val encrypted = ToggleEncryptionRemoteOperation(ocFile!!.localId, remotePath, true) + .execute(client) + val publicKey = arbitraryDataProvider.getValue(user, EncryptionUtils.PUBLIC_KEY) + val privateKey = arbitraryDataProvider.getValue(user, EncryptionUtils.PRIVATE_KEY) + if (encrypted.isSuccess) encryptionKeyGenerator.uploadEncryptedFolderMetadata( + ocFile, + client, + publicKey, + privateKey, + storageManager, + arbitraryDataProvider + ) + return created.isSuccess && encrypted.isSuccess + } private fun listEncryptedFolder(remotePath: String): List { - // Obtain folder id val ocFile = storageManager.getFileByRemotePath(remotePath) assertNotNull(ocFile) + val parent = storageManager.getFileById(ocFile!!.parentId) + + // Refresh folder + val refreshResult = RefreshFolderOperation( + parent, + System.currentTimeMillis(), + false, + false, + storageManager, + user, + targetContext + ).execute(client) + assertTrue(refreshResult.isSuccess) // Check folder metadata runBlocking { - assertTrue(e2eeActionResolver.checkFolderMetadataKey(ocFile!!)) + assertTrue(e2eeActionResolver.checkFolderMetadataKey(ocFile)) } // Open folder return runBlocking { - storageManager.fileDao.getFolderContentSuspended(ocFile!!.fileId) + storageManager.fileDao.getFolderContentSuspended(ocFile.fileId) } } } diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt index 1b3de5e0fc88..6db3d469621e 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt @@ -10,14 +10,21 @@ package com.owncloud.android.ui.dialog.setupEncryption import android.accounts.AccountManager import android.content.Context import com.nextcloud.client.account.User +import com.nextcloud.utils.e2ee.E2EVersionHelper +import com.owncloud.android.datamodel.ArbitraryDataProvider import com.owncloud.android.datamodel.ArbitraryDataProviderImpl +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient import com.owncloud.android.lib.common.accounts.AccountUtils import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.e2ee.CsrHelper +import com.owncloud.android.lib.resources.status.E2EVersion import com.owncloud.android.lib.resources.users.DeletePublicKeyRemoteOperation import com.owncloud.android.lib.resources.users.SendCSRRemoteOperation import com.owncloud.android.lib.resources.users.StorePrivateKeyRemoteOperation import com.owncloud.android.utils.EncryptionUtils +import com.owncloud.android.utils.EncryptionUtilsV2 import com.owncloud.android.utils.crypto.CryptoHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -112,4 +119,65 @@ class EncryptionKeyGenerator(val context: Context, val user: User) { } return@withContext "" } + @Suppress("LongParameterList") + fun uploadEncryptedFolderMetadata( + folder: OCFile, + client: OwnCloudClient, + publicKey: String, + privateKey: String, + storageManager: FileDataStorageManager, + arbitraryDataProvider: ArbitraryDataProvider + ): Boolean { + val capability = storageManager.getCapability(user.accountName) + val isE2EEV2 = E2EVersionHelper.isV2Plus(capability) + var e2eCounter = EncryptionUtils.E2E_V1_INITIAL_COUNTER + if (isE2EEV2) { + e2eCounter = EncryptionUtils.E2E_V2_INITIAL_COUNTER + } + val token = EncryptionUtils.lockFolder(folder, client, e2eCounter) + + val result = when { + isE2EEV2 -> { + val result = EncryptionUtils.retrieveMetadata( + folder, + client, + privateKey, + publicKey, + storageManager, + user, + context, + arbitraryDataProvider + ) + val encryptionUtil = EncryptionUtilsV2() + encryptionUtil.serializeAndUploadMetadata( + folder, + result.second, + token, + client, + result.first, + context, + user, + storageManager + ) + EncryptionUtils.unlockFolder(folder, client, token) + true + } + + E2EVersionHelper.isV1(capability) -> { + EncryptionUtils.unlockFolderV1(folder, client, token) + false + } + + capability.endToEndEncryptionApiVersion == E2EVersion.UNKNOWN -> { + throw IllegalArgumentException("Unknown E2E version") + } + + else -> { + false + } + } + + return result + } + } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/FolderEncryption.kt b/app/src/main/java/com/owncloud/android/ui/fragment/FolderEncryption.kt index cef91faee6df..c76dd5bc6594 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/FolderEncryption.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/FolderEncryption.kt @@ -9,17 +9,15 @@ package com.owncloud.android.ui.fragment import com.nextcloud.client.account.User import com.nextcloud.utils.SnackbarUtil -import com.nextcloud.utils.e2ee.E2EVersionHelper import com.owncloud.android.R import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.common.OwnCloudClient import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.e2ee.ToggleEncryptionRemoteOperation -import com.owncloud.android.lib.resources.status.E2EVersion +import com.owncloud.android.ui.dialog.setupEncryption.EncryptionKeyGenerator import com.owncloud.android.ui.events.EncryptionEvent import com.owncloud.android.utils.EncryptionUtils -import com.owncloud.android.utils.EncryptionUtilsV2 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.apache.commons.httpclient.HttpStatus @@ -83,8 +81,7 @@ class FolderEncryption(private val fragment: OCFileListFragment) { } } - @Suppress("LongParameterList") - private suspend fun onToggleSuccess( + suspend fun onToggleSuccess( remoteId: String, shouldBeEncrypted: Boolean, folder: OCFile, @@ -94,54 +91,15 @@ class FolderEncryption(private val fragment: OCFileListFragment) { privateKey: String, storageManager: FileDataStorageManager ): Boolean { - val capability = storageManager.getCapability(user.accountName) - val isE2EEV2 = E2EVersionHelper.isV2Plus(capability) - var e2eCounter = EncryptionUtils.E2E_V1_INITIAL_COUNTER - if (isE2EEV2) { - e2eCounter = EncryptionUtils.E2E_V2_INITIAL_COUNTER - } - val token = EncryptionUtils.lockFolder(folder, client, e2eCounter) - - val result = when { - isE2EEV2 -> { - val result = EncryptionUtils.retrieveMetadata( - folder, - client, - privateKey, - publicKey, - storageManager, - user, - fragment.requireContext(), - fragment.arbitraryDataProvider - ) - val encryptionUtil = EncryptionUtilsV2() - encryptionUtil.serializeAndUploadMetadata( - folder, - result.second, - token, - client, - result.first, - fragment.requireContext(), - user, - storageManager - ) - EncryptionUtils.unlockFolder(folder, client, token) - true - } - - E2EVersionHelper.isV1(capability) -> { - EncryptionUtils.unlockFolderV1(folder, client, token) - false - } - - capability.endToEndEncryptionApiVersion == E2EVersion.UNKNOWN -> { - throw IllegalArgumentException("Unknown E2E version") - } - - else -> { - false - } - } + val result = EncryptionKeyGenerator( fragment.requireContext(), user) + .uploadEncryptedFolderMetadata( + folder, + client, + publicKey, + privateKey, + storageManager, + fragment.arbitraryDataProvider + ) withContext(Dispatchers.Main) { val isFileExists = (fragment.adapter.getFileByRemoteId(remoteId) != null) From ad689a72f0c87ada75c58ccfe87accecd53c1b3c Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Thu, 24 Sep 2026 09:32:31 +0200 Subject: [PATCH 08/16] Refactored & small fixes Signed-off-by: daniele-verducci --- .../owncloud/android/EncryptedFoldersIT.kt | 95 ++++++++++++------- .../setupEncryption/EncryptionKeyGenerator.kt | 2 +- .../android/ui/fragment/FolderEncryption.kt | 3 +- 3 files changed, 64 insertions(+), 36 deletions(-) diff --git a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt index 9beea98021d3..24a82a7fe336 100644 --- a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt @@ -13,12 +13,12 @@ import com.nextcloud.client.database.entity.FileEntity import com.nextcloud.client.network.NetworkModule import com.nextcloud.utils.e2ee.E2EEActionResolver import com.nextcloud.utils.e2ee.E2EEKeyInspector -import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.resources.e2ee.ToggleEncryptionRemoteOperation import com.owncloud.android.lib.resources.status.GetCapabilitiesRemoteOperation -import com.owncloud.android.lib.resources.status.NextcloudVersion import com.owncloud.android.operations.CreateFolderOperation import com.owncloud.android.operations.RefreshFolderOperation +import com.owncloud.android.operations.common.SyncOperation import com.owncloud.android.operations.e2e.E2EDeletionService import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator import com.owncloud.android.ui.dialog.setupEncryption.EncryptionKeyGenerator @@ -34,9 +34,8 @@ import org.junit.Test open class EncryptedFoldersIT : AbstractOnServerIT() { companion object { - val TAG = EncryptedFoldersIT::class.simpleName val FOLDER = "/encryptedFolder/" - val SUBFOLDER = "${FOLDER}encryptedSubfolder/" + val SUBFOLDER = "encryptedSubfolder/" val KEYWORDS = arrayListOf( "ability", "able", @@ -78,36 +77,28 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { @Test fun testCreateEncryptedFolder() { - val result = createEncryptedFolder(FOLDER) - assertTrue(result) + createEncryptedFolder(FOLDER) } @Test fun testCreateEncryptedSubfolder() { - // Create parent folder only for Nextcloud < 32; 32+ handles this automatically. - if (capability.version.isOlderThan(NextcloudVersion.nextcloud_32)) { - assertTrue(createEncryptedFolder(FOLDER)) - } - val result = createEncryptedFolder(SUBFOLDER) - assertTrue(result) + val parent = createEncryptedFolder(FOLDER) + createEncryptedSubfolder(SUBFOLDER, parent) } @Test fun testReadEncryptedFolder() { val remotePath = FOLDER - // Create folder - assertTrue(createEncryptedFolder(remotePath)) - val files = listEncryptedFolder(remotePath) + val ocFile = createEncryptedFolder(remotePath) + val files = listEncryptedFolder(ocFile) assertEquals(files.size, 0) } @Test fun testReadEncryptedSubfolder() { - val remotePath = SUBFOLDER - // Create folder - assertTrue(createEncryptedFolder(FOLDER)) - assertTrue(createEncryptedFolder(remotePath)) - val files = listEncryptedFolder(remotePath) + createEncryptedFolder(FOLDER) + val subOCFile = createEncryptedFolder(SUBFOLDER) + val files = listEncryptedFolder(subOCFile) assertEquals(files.size, 0) } @@ -134,20 +125,23 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { @Before fun encryptionSetup() { - // Fetch capabiliy + // Fetch capability val capability = GetCapabilitiesRemoteOperation(null).execute(client).getResultData() storageManager.saveCapabilities(capability) - // Check end2end capability - if (capability.endToEndEncryption.isFalse || capability.endToEndEncryption.isUnknown) { - Log_OC.e(TAG, "Server does not support E2EE") - } + // Check if server supports end2end capability + assertTrue(capability.endToEndEncryption.isTrue) + // Delete existing encryption key, if any - E2EDeletionService(NetworkModule().clientFactory(targetContext)).deleteKeysAndFiles(user) + assertTrue( + E2EDeletionService(NetworkModule().clientFactory(targetContext)).deleteKeysAndFiles(user) + ) + // Create new encryption key val privateKey: String = runBlocking { encryptionKeyGenerator.generatePrivateKey(KEYWORDS) } + // Check the key was generated assertNotEquals(privateKey, "") } @@ -155,19 +149,26 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { @After fun encryptionCleanup() { // Delete existing encryption key, if any - E2EDeletionService(NetworkModule().clientFactory(targetContext)).deleteKeysAndFiles(user) + assertTrue( + E2EDeletionService(NetworkModule().clientFactory(targetContext)).deleteKeysAndFiles(user) + ) } - private fun createEncryptedFolder(remotePath: String): Boolean { + private fun createEncryptedFolder(remotePath: String): OCFile { val created = CreateFolderOperation(remotePath, user, targetContext, storageManager).apply { setEncrypt(true) }.execute(client) + assertTrue(created.toString(), created.isSuccess) + val ocFile = storageManager.getFileByRemotePath(remotePath) + assertNotNull(ocFile) val encrypted = ToggleEncryptionRemoteOperation(ocFile!!.localId, remotePath, true) .execute(client) + assertTrue(encrypted.toString(), encrypted.isSuccess) + val publicKey = arbitraryDataProvider.getValue(user, EncryptionUtils.PUBLIC_KEY) val privateKey = arbitraryDataProvider.getValue(user, EncryptionUtils.PRIVATE_KEY) - if (encrypted.isSuccess) encryptionKeyGenerator.uploadEncryptedFolderMetadata( + val uploadedMetadata = encryptionKeyGenerator.uploadEncryptedFolderMetadata( ocFile, client, publicKey, @@ -175,13 +176,39 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { storageManager, arbitraryDataProvider ) - return created.isSuccess && encrypted.isSuccess + assertTrue(uploadedMetadata) + + // Set file as encrypted locally + ocFile.isEncrypted = true + assertTrue(storageManager.saveFile(ocFile)) + + return ocFile } - private fun listEncryptedFolder(remotePath: String): List { - val ocFile = storageManager.getFileByRemotePath(remotePath) + fun createEncryptedSubfolder(folderName: String, parent: OCFile) { + // An encrypted subfolder is a normal folder inside an encrypted one + assertTrue(parent.isFolder && parent.isEncrypted) + + // Create folder + val path = "${parent.remotePath}${OCFile.PATH_SEPARATOR}${folderName}${OCFile.PATH_SEPARATOR}" + val syncOp: SyncOperation = CreateFolderOperation( + path, + user, + targetContext, + storageManager + ) + val result = syncOp.execute(client) + assertTrue(result.toString(), result.isSuccess) + + // Check folder exists + val ocFile = storageManager.getFileByRemotePath(path) + assertTrue(ocFile?.isFolder ?: false) + } + + private fun listEncryptedFolder(ocFile: OCFile): List { assertNotNull(ocFile) - val parent = storageManager.getFileById(ocFile!!.parentId) + val parent = storageManager.getFileById(ocFile.parentId) + assertNotNull(parent) // Refresh folder val refreshResult = RefreshFolderOperation( @@ -193,7 +220,7 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { user, targetContext ).execute(client) - assertTrue(refreshResult.isSuccess) + assertTrue(refreshResult.toString(), refreshResult.isSuccess) // Check folder metadata runBlocking { diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt index 6db3d469621e..5a476e97c5ef 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt @@ -119,6 +119,7 @@ class EncryptionKeyGenerator(val context: Context, val user: User) { } return@withContext "" } + @Suppress("LongParameterList") fun uploadEncryptedFolderMetadata( folder: OCFile, @@ -179,5 +180,4 @@ class EncryptionKeyGenerator(val context: Context, val user: User) { return result } - } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/FolderEncryption.kt b/app/src/main/java/com/owncloud/android/ui/fragment/FolderEncryption.kt index c76dd5bc6594..232c1452f2aa 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/FolderEncryption.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/FolderEncryption.kt @@ -81,6 +81,7 @@ class FolderEncryption(private val fragment: OCFileListFragment) { } } + @Suppress("LongParameterList") suspend fun onToggleSuccess( remoteId: String, shouldBeEncrypted: Boolean, @@ -91,7 +92,7 @@ class FolderEncryption(private val fragment: OCFileListFragment) { privateKey: String, storageManager: FileDataStorageManager ): Boolean { - val result = EncryptionKeyGenerator( fragment.requireContext(), user) + val result = EncryptionKeyGenerator(fragment.requireContext(), user) .uploadEncryptedFolderMetadata( folder, client, From 8da63d1977bd9d23bf1378d904d0568113defe06 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Thu, 24 Sep 2026 09:51:46 +0200 Subject: [PATCH 09/16] Fixed copypasted file copyright text for a newly created file Signed-off-by: daniele-verducci --- .../androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt index 24a82a7fe336..24ac55c295a0 100644 --- a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt @@ -1,7 +1,7 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2024 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Daniele Verducci * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ From ae74111e02c2b09cc6b3bf1f45b0c4f8f6976646 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Thu, 24 Sep 2026 14:38:27 +0200 Subject: [PATCH 10/16] Implemented unencryption test Signed-off-by: daniele-verducci --- .../owncloud/android/EncryptedFoldersIT.kt | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt index 24ac55c295a0..a5888d1170fb 100644 --- a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt @@ -26,6 +26,7 @@ import com.owncloud.android.utils.EncryptionUtils import junit.framework.TestCase.assertEquals import kotlinx.coroutines.runBlocking import org.junit.After +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue @@ -75,6 +76,11 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { encryptionKeyGenerator = EncryptionKeyGenerator(targetContext, user) } + /** + * This test covers both encrypted folder creation and encryption of an existing (empty) folder, + * as they are basically the same action (folder creation + encryption), the only difference being + * the latter is executed manually by the user later. + */ @Test fun testCreateEncryptedFolder() { createEncryptedFolder(FOLDER) @@ -103,24 +109,14 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { } @Test - fun testUpdateEncryptedFolder() { - // Rename folder - } - - @Test - fun testUpdateEncryptedSubfolder() { - } - - @Test - fun testDeleteEncryptedFolder() { - } - - @Test - fun testDeleteEncryptedSubfolder() { - } + fun testUnencryptFolder() { + // Create encrypted folder + val ocFile = createEncryptedFolder(FOLDER) + assertTrue(ocFile.isFolder && ocFile.isEncrypted) - @Test - fun testEncryptExistingFolder() { + // Unencrypt it + encryptFolder(ocFile, false) + assertFalse(ocFile.isEncrypted) } @Before @@ -162,7 +158,14 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { val ocFile = storageManager.getFileByRemotePath(remotePath) assertNotNull(ocFile) - val encrypted = ToggleEncryptionRemoteOperation(ocFile!!.localId, remotePath, true) + + encryptFolder(ocFile!!, true) + + return ocFile + } + + fun encryptFolder(ocFile: OCFile, encrypt: Boolean) { + val encrypted = ToggleEncryptionRemoteOperation(ocFile.localId, ocFile.remotePath, encrypt) .execute(client) assertTrue(encrypted.toString(), encrypted.isSuccess) @@ -179,10 +182,8 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { assertTrue(uploadedMetadata) // Set file as encrypted locally - ocFile.isEncrypted = true + ocFile.isEncrypted = encrypt assertTrue(storageManager.saveFile(ocFile)) - - return ocFile } fun createEncryptedSubfolder(folderName: String, parent: OCFile) { From f3a83a04cf12063a5aabe7dac79dc2fef2cd0c55 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Thu, 24 Sep 2026 15:31:06 +0200 Subject: [PATCH 11/16] Restricted tests to Nextcloud >= 30 Signed-off-by: daniele-verducci --- .../java/com/owncloud/android/EncryptedFoldersIT.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt index a5888d1170fb..677d4773bc93 100644 --- a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt @@ -11,11 +11,13 @@ import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.account.UserAccountManagerImpl 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.E2EEKeyInspector import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.resources.e2ee.ToggleEncryptionRemoteOperation import com.owncloud.android.lib.resources.status.GetCapabilitiesRemoteOperation +import com.owncloud.android.lib.resources.status.NextcloudVersion import com.owncloud.android.operations.CreateFolderOperation import com.owncloud.android.operations.RefreshFolderOperation import com.owncloud.android.operations.common.SyncOperation @@ -33,6 +35,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +@SinceServer(majorVersion = 30) open class EncryptedFoldersIT : AbstractOnServerIT() { companion object { val FOLDER = "/encryptedFolder/" @@ -81,6 +84,8 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { * as they are basically the same action (folder creation + encryption), the only difference being * the latter is executed manually by the user later. */ + + @Test fun testCreateEncryptedFolder() { createEncryptedFolder(FOLDER) @@ -121,6 +126,8 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { @Before fun encryptionSetup() { + testOnlyOnServer(NextcloudVersion.nextcloud_30) + // Fetch capability val capability = GetCapabilitiesRemoteOperation(null).execute(client).getResultData() storageManager.saveCapabilities(capability) From ea4b44af477ceefccdc0276dcdd0662eef17fce7 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Thu, 24 Sep 2026 16:50:04 +0200 Subject: [PATCH 12/16] Fixed lint Signed-off-by: daniele-verducci --- .../androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt index 677d4773bc93..616754a99f4c 100644 --- a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt @@ -85,7 +85,6 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { * the latter is executed manually by the user later. */ - @Test fun testCreateEncryptedFolder() { createEncryptedFolder(FOLDER) From 8fd0a969084f80d077978f6b9b06ffaef35dce34 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Fri, 25 Sep 2026 13:22:39 +0200 Subject: [PATCH 13/16] Non-nullable keywords, replaced annotation Signed-off-by: daniele-verducci --- .../operations/e2e/E2EDeletionService.kt | 2 +- .../setupEncryption/EncryptionKeyGenerator.kt | 28 +++++++++---------- .../SetupEncryptionDialogFragment.kt | 4 +-- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/operations/e2e/E2EDeletionService.kt b/app/src/main/java/com/owncloud/android/operations/e2e/E2EDeletionService.kt index 69de70a12141..ea520435c84c 100644 --- a/app/src/main/java/com/owncloud/android/operations/e2e/E2EDeletionService.kt +++ b/app/src/main/java/com/owncloud/android/operations/e2e/E2EDeletionService.kt @@ -11,6 +11,7 @@ import android.content.Context import android.os.Handler import android.os.Looper import android.util.Log +import androidx.annotation.VisibleForTesting import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.client.account.User import com.nextcloud.client.network.ClientFactory @@ -19,7 +20,6 @@ import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.e2ee.DeleteEncryptedFilesRemoteOperation import com.owncloud.android.lib.resources.users.DeletePrivateKeyRemoteOperation import com.owncloud.android.lib.resources.users.DeletePublicKeyRemoteOperation -import org.jetbrains.annotations.VisibleForTesting @Suppress("MagicNumber") class E2EDeletionService(private val clientFactory: ClientFactory) { diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt index 5a476e97c5ef..3d831e0ec9dc 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt @@ -24,33 +24,32 @@ import com.owncloud.android.lib.resources.users.DeletePublicKeyRemoteOperation import com.owncloud.android.lib.resources.users.SendCSRRemoteOperation import com.owncloud.android.lib.resources.users.StorePrivateKeyRemoteOperation import com.owncloud.android.utils.EncryptionUtils +import com.owncloud.android.utils.EncryptionUtils.RSA import com.owncloud.android.utils.EncryptionUtilsV2 import com.owncloud.android.utils.crypto.CryptoHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.NoSuchAlgorithmException +import java.security.SecureRandom class EncryptionKeyGenerator(val context: Context, val user: User) { companion object { val TAG: String = EncryptionKeyGenerator::class.java.simpleName - fun generateMnemonicString(keyWords: ArrayList?, withWhitespace: Boolean): String { - val stringBuilder = StringBuilder() + fun generateMnemonicString(keyWords: List, withWhitespace: Boolean): String = + keyWords.joinToString("") { if (withWhitespace) "$it " else it } - keyWords?.let { - for (string in it) { - stringBuilder.append(string) - if (withWhitespace) { - stringBuilder.append(' ') - } - } - } - - return stringBuilder.toString() - } + @Throws(NoSuchAlgorithmException::class) + fun generateKeyPair(): KeyPair = + KeyPairGenerator.getInstance(RSA) + .apply { initialize(2048, SecureRandom()) } + .generateKeyPair() } @Suppress("TooGenericExceptionCaught", "TooGenericExceptionThrown", "ReturnCount") - suspend fun generatePrivateKey(keyWords: ArrayList?): String = withContext(Dispatchers.IO) { + suspend fun generatePrivateKey(keyWords: ArrayList): String = withContext(Dispatchers.IO) { val arbitraryDataProvider = ArbitraryDataProviderImpl(context) // - create CSR, push to server, store returned public key in database @@ -180,4 +179,5 @@ class EncryptionKeyGenerator(val context: Context, val user: User) { 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 85cce33fe3cf..d22a4146b0ca 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 @@ -357,7 +357,7 @@ class SetupEncryptionDialogFragment : private suspend fun generateNewKeys() { binding.encryptionStatus.setText(R.string.end_to_end_encryption_generating_keys) val context = context ?: return - val privateKey: String = EncryptionKeyGenerator(context, user ?: return).generatePrivateKey(keyWords) + val privateKey: String = EncryptionKeyGenerator(context, user ?: return).generatePrivateKey(keyWords ?: return) if (privateKey.isEmpty()) { keyResult = KEY_FAILED @@ -382,7 +382,7 @@ class SetupEncryptionDialogFragment : requireDialog().setTitle(R.string.end_to_end_encryption_passphrase_title) binding.encryptionStatus.setText(R.string.end_to_end_encryption_keywords_description) viewThemeUtils.material.colorTextInputLayout(binding.encryptionPasswordInputContainer) - binding.encryptionPassphrase.text = EncryptionKeyGenerator.generateMnemonicString(keyWords, true) + binding.encryptionPassphrase.text = EncryptionKeyGenerator.generateMnemonicString(keyWords ?: return, true) binding.encryptionPassphrase.visibility = View.VISIBLE setupCopyPassphraseButton() From b61d3c86985109ec2be573bb806be26f49d3fe40 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Fri, 25 Sep 2026 13:25:26 +0200 Subject: [PATCH 14/16] Moved generateKeyPair into EncryptionKeyGenerator Signed-off-by: daniele-verducci --- .../android/operations/DeleteE2ERemoteOperationIT.kt | 2 +- .../com/owncloud/android/util/EncryptionTestIT.java | 8 ++++---- .../com/owncloud/android/utils/EncryptionUtilsIT.kt | 2 +- .../dialog/setupEncryption/EncryptionKeyGenerator.kt | 2 +- .../com/owncloud/android/utils/EncryptionUtils.java | 11 ----------- .../E2ECertificateRenewalMetadataVerificationTest.kt | 6 +++--- 6 files changed, 10 insertions(+), 21 deletions(-) diff --git a/app/src/androidTest/java/com/owncloud/android/operations/DeleteE2ERemoteOperationIT.kt b/app/src/androidTest/java/com/owncloud/android/operations/DeleteE2ERemoteOperationIT.kt index 88d072a60efa..bb0ae8955607 100644 --- a/app/src/androidTest/java/com/owncloud/android/operations/DeleteE2ERemoteOperationIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/operations/DeleteE2ERemoteOperationIT.kt @@ -31,7 +31,7 @@ class DeleteE2ERemoteOperationIT : AbstractOnServerIT() { @Test fun testDeletePrivateKey() { - val keyPair = EncryptionUtils.generateKeyPair() + val keyPair = EncryptionKeyGenerator.generateKeyPair() val privateKey = keyPair.private val keyPhrase = "moreovertelevisionfactorytendencyindependenceinternationalintellectualimpress" + "interestvolunteer" diff --git a/app/src/androidTest/java/com/owncloud/android/util/EncryptionTestIT.java b/app/src/androidTest/java/com/owncloud/android/util/EncryptionTestIT.java index f794af49c67d..ec5fb1321351 100644 --- a/app/src/androidTest/java/com/owncloud/android/util/EncryptionTestIT.java +++ b/app/src/androidTest/java/com/owncloud/android/util/EncryptionTestIT.java @@ -154,7 +154,7 @@ public void encryptStringAsymmetric() throws Exception { @Test public void encryptStringAsymmetricCorrectPublicKey() throws Exception { - KeyPair keyPair = EncryptionUtils.generateKeyPair(); + KeyPair keyPair = EncryptionKeyGenerator.generateKeyPair(); byte[] key1 = generateKey(); String base64encodedKey = encodeBytesToBase64String(key1); @@ -169,8 +169,8 @@ public void encryptStringAsymmetricCorrectPublicKey() throws Exception { @Test(expected = BadPaddingException.class) public void encryptStringAsymmetricWrongPublicKey() throws Exception { - KeyPair keyPair1 = EncryptionUtils.generateKeyPair(); - KeyPair keyPair2 = EncryptionUtils.generateKeyPair(); + KeyPair keyPair1 = EncryptionKeyGenerator.generateKeyPair(); + KeyPair keyPair2 = EncryptionKeyGenerator.generateKeyPair(); byte[] key1 = generateKey(); String base64encodedKey = encodeBytesToBase64String(key1); @@ -181,7 +181,7 @@ public void encryptStringAsymmetricWrongPublicKey() throws Exception { @Test public void testModulus() throws Exception { - KeyPair keyPair = EncryptionUtils.generateKeyPair(); + KeyPair keyPair = EncryptionKeyGenerator.generateKeyPair(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAPrivateCrtKey privateKey = (RSAPrivateCrtKey) keyPair.getPrivate(); diff --git a/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsIT.kt b/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsIT.kt index 50c9815771c6..dbeca65a629e 100644 --- a/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsIT.kt @@ -27,7 +27,7 @@ class EncryptionUtilsIT : EncryptionIT() { @Test fun saveAndRestorePublicKey() { val arbitraryDataProvider = ArbitraryDataProviderImpl(targetContext) - val keyPair = EncryptionUtils.generateKeyPair() + val keyPair = EncryptionKeyGenerator.generateKeyPair() val e2eUser = "e2e-user" val key = CsrHelper().generateCsrPemEncodedString(keyPair, e2eUser) diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt index 3d831e0ec9dc..145d94dc7568 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt @@ -58,7 +58,7 @@ class EncryptionKeyGenerator(val context: Context, val user: User) { val certificate: String // Create public/private key pair - val keyPair = EncryptionUtils.generateKeyPair() + val keyPair = EncryptionKeyGenerator.generateKeyPair() // create CSR val accountManager = AccountManager.get(context) diff --git a/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java b/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java index 42fca11c449f..6c671410ecf2 100644 --- a/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java @@ -1073,17 +1073,6 @@ public static ArrayList getRandomWords(int count, Context context) throw return outputLines; } - /** - * Generates private/public key pair, used for asymmetric encryption - * - * @return KeyPair - */ - public static KeyPair generateKeyPair() throws NoSuchAlgorithmException { - KeyPairGenerator keyGen = KeyPairGenerator.getInstance(RSA); - keyGen.initialize(2048, new SecureRandom()); - return keyGen.generateKeyPair(); - } - /** * Generates key for symmetric encryption * diff --git a/app/src/test/java/com/owncloud/android/utils/E2ECertificateRenewalMetadataVerificationTest.kt b/app/src/test/java/com/owncloud/android/utils/E2ECertificateRenewalMetadataVerificationTest.kt index ab8a4fd3f20f..232f88fa2278 100644 --- a/app/src/test/java/com/owncloud/android/utils/E2ECertificateRenewalMetadataVerificationTest.kt +++ b/app/src/test/java/com/owncloud/android/utils/E2ECertificateRenewalMetadataVerificationTest.kt @@ -65,7 +65,7 @@ class E2ECertificateRenewalMetadataVerificationTest { @Test fun oldCertificateInMetadataVerifiesSignatureCreatedWithRenewedCertificate() { - val keyPair = EncryptionUtils.generateKeyPair() + val keyPair = EncryptionKeyGenerator.generateKeyPair() val oldCertificate = createCertificate(keyPair, OLD_CERTIFICATE_SERIAL) val renewedCertificate = createCertificate(keyPair, RENEWED_CERTIFICATE_SERIAL) @@ -86,7 +86,7 @@ class E2ECertificateRenewalMetadataVerificationTest { @Test fun unchangedOldMetadataStillVerifiesAfterRenewal() { - val keyPair = EncryptionUtils.generateKeyPair() + val keyPair = EncryptionKeyGenerator.generateKeyPair() val oldCertificate = createCertificate(keyPair, OLD_CERTIFICATE_SERIAL) val encryptedMetadata = buildEncryptedMetadata(toPem(oldCertificate)) @@ -99,7 +99,7 @@ class E2ECertificateRenewalMetadataVerificationTest { @Test fun signatureIsInterchangeableBetweenOldAndRenewedCertificate() { - val keyPair = EncryptionUtils.generateKeyPair() + val keyPair = EncryptionKeyGenerator.generateKeyPair() val oldCertificate = createCertificate(keyPair, OLD_CERTIFICATE_SERIAL) val renewedCertificate = createCertificate(keyPair, RENEWED_CERTIFICATE_SERIAL) From e05236d64ead6b500747b9c26149a45a190363e4 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Fri, 25 Sep 2026 17:05:50 +0200 Subject: [PATCH 15/16] Sealed class for generatePrivateKey() result Signed-off-by: daniele-verducci --- .../owncloud/android/EncryptedFoldersIT.kt | 5 ++-- .../setupEncryption/EncryptionKeyGenerator.kt | 15 ++++++---- .../SetupEncryptionDialogFragment.kt | 28 +++++++++++-------- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt index 616754a99f4c..abade10d4651 100644 --- a/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/EncryptedFoldersIT.kt @@ -29,7 +29,6 @@ import junit.framework.TestCase.assertEquals import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before @@ -140,12 +139,12 @@ open class EncryptedFoldersIT : AbstractOnServerIT() { ) // Create new encryption key - val privateKey: String = runBlocking { + val privateKey: EncryptionKeyGenerator.PrivateKeyResult = runBlocking { encryptionKeyGenerator.generatePrivateKey(KEYWORDS) } // Check the key was generated - assertNotEquals(privateKey, "") + assertTrue(privateKey is EncryptionKeyGenerator.PrivateKeyResult.Success) } @After diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt index 145d94dc7568..4e3bf8f906ad 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt @@ -49,7 +49,7 @@ class EncryptionKeyGenerator(val context: Context, val user: User) { } @Suppress("TooGenericExceptionCaught", "TooGenericExceptionThrown", "ReturnCount") - suspend fun generatePrivateKey(keyWords: ArrayList): String = withContext(Dispatchers.IO) { + suspend fun generatePrivateKey(keyWords: ArrayList): PrivateKeyResult = withContext(Dispatchers.IO) { val arbitraryDataProvider = ArbitraryDataProviderImpl(context) // - create CSR, push to server, store returned public key in database @@ -69,7 +69,7 @@ class EncryptionKeyGenerator(val context: Context, val user: User) { val result = operation.executeNextcloudClient(user, context) if (!result.isSuccess) { - return@withContext "" + return@withContext PrivateKeyResult.Failed } certificate = result.resultData @@ -93,7 +93,7 @@ class EncryptionKeyGenerator(val context: Context, val user: User) { if (!storePrivateKeyResult.isSuccess) { val deletePublicKeyOperation = DeletePublicKeyRemoteOperation() deletePublicKeyOperation.executeNextcloudClient(user, context) - return@withContext "" + return@withContext PrivateKeyResult.Failed } Log_OC.d(TAG, "private key success") @@ -112,11 +112,11 @@ class EncryptionKeyGenerator(val context: Context, val user: User) { EncryptionUtils.MNEMONIC, generateMnemonicString(keyWords, true) ) - return@withContext storePrivateKeyResult.resultData + return@withContext PrivateKeyResult.Success(storePrivateKeyResult.resultData) } catch (e: Exception) { Log_OC.e(TAG, e.message) } - return@withContext "" + return@withContext PrivateKeyResult.Failed } @Suppress("LongParameterList") @@ -180,4 +180,9 @@ class EncryptionKeyGenerator(val context: Context, val user: User) { return result } + sealed class PrivateKeyResult { + data class Success(val key: String): PrivateKeyResult() + data object Failed: PrivateKeyResult() + } + } 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 d22a4146b0ca..7012369918f3 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 @@ -357,19 +357,23 @@ class SetupEncryptionDialogFragment : private suspend fun generateNewKeys() { binding.encryptionStatus.setText(R.string.end_to_end_encryption_generating_keys) val context = context ?: return - val privateKey: String = EncryptionKeyGenerator(context, user ?: return).generatePrivateKey(keyWords ?: return) - - if (privateKey.isEmpty()) { - keyResult = KEY_FAILED - errorSavingKeys() - } else { - keyResult = KEY_GENERATE - if (dialog == null) { - Log_OC.e(TAG, "Dialog is null cannot proceed further.") - return + + val privateKeyResult = EncryptionKeyGenerator(context, user ?: return).generatePrivateKey(keyWords ?: return) + when(privateKeyResult) { + is EncryptionKeyGenerator.PrivateKeyResult.Success -> { + keyResult = KEY_GENERATE + if (dialog == null) { + Log_OC.e(TAG, "Dialog is null cannot proceed further.") + return + } + requireDialog().dismiss() + notifyResult() + } + + EncryptionKeyGenerator.PrivateKeyResult.Failed -> { + keyResult = KEY_FAILED + errorSavingKeys() } - requireDialog().dismiss() - notifyResult() } } From d1eace8e24917cdf20f72708f36f8600a6768bfb Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Fri, 25 Sep 2026 17:14:05 +0200 Subject: [PATCH 16/16] Missing imports Signed-off-by: daniele-verducci --- .../owncloud/android/operations/DeleteE2ERemoteOperationIT.kt | 1 + .../java/com/owncloud/android/util/EncryptionTestIT.java | 1 + .../java/com/owncloud/android/utils/EncryptionUtilsIT.kt | 1 + .../android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt | 1 + 4 files changed, 4 insertions(+) diff --git a/app/src/androidTest/java/com/owncloud/android/operations/DeleteE2ERemoteOperationIT.kt b/app/src/androidTest/java/com/owncloud/android/operations/DeleteE2ERemoteOperationIT.kt index bb0ae8955607..0140a769c797 100644 --- a/app/src/androidTest/java/com/owncloud/android/operations/DeleteE2ERemoteOperationIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/operations/DeleteE2ERemoteOperationIT.kt @@ -14,6 +14,7 @@ import com.owncloud.android.lib.resources.users.DeletePublicKeyRemoteOperation import com.owncloud.android.lib.resources.users.GetPrivateKeyRemoteOperation import com.owncloud.android.lib.resources.users.GetPublicKeyRemoteOperation import com.owncloud.android.lib.resources.users.StorePrivateKeyRemoteOperation +import com.owncloud.android.ui.dialog.setupEncryption.EncryptionKeyGenerator import com.owncloud.android.utils.EncryptionUtils import com.owncloud.android.utils.crypto.CryptoHelper import org.junit.Assert.assertFalse diff --git a/app/src/androidTest/java/com/owncloud/android/util/EncryptionTestIT.java b/app/src/androidTest/java/com/owncloud/android/util/EncryptionTestIT.java index ec5fb1321351..edf45c138d70 100644 --- a/app/src/androidTest/java/com/owncloud/android/util/EncryptionTestIT.java +++ b/app/src/androidTest/java/com/owncloud/android/util/EncryptionTestIT.java @@ -25,6 +25,7 @@ import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFolderMetadataFileV1; import com.owncloud.android.lib.common.utils.Log_OC; import com.owncloud.android.lib.resources.e2ee.CsrHelper; +import com.owncloud.android.ui.dialog.setupEncryption.EncryptionKeyGenerator; import com.owncloud.android.utils.EncryptionUtils; import com.owncloud.android.utils.crypto.CryptoHelper; diff --git a/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsIT.kt b/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsIT.kt index dbeca65a629e..6e248e0d0af5 100644 --- a/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsIT.kt @@ -15,6 +15,7 @@ import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFi import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedMetadata import com.owncloud.android.lib.resources.e2ee.CsrHelper import com.owncloud.android.operations.RefreshFolderOperation +import com.owncloud.android.ui.dialog.setupEncryption.EncryptionKeyGenerator import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt index 4e3bf8f906ad..02d6e281dfb4 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/EncryptionKeyGenerator.kt @@ -41,6 +41,7 @@ class EncryptionKeyGenerator(val context: Context, val user: User) { fun generateMnemonicString(keyWords: List, withWhitespace: Boolean): String = keyWords.joinToString("") { if (withWhitespace) "$it " else it } + @JvmStatic @Throws(NoSuchAlgorithmException::class) fun generateKeyPair(): KeyPair = KeyPairGenerator.getInstance(RSA)