Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

package com.owncloud.android.operations.e2e

import androidx.annotation.StringRes

sealed interface E2ECertificateRenewalResult {
data object Success : E2ECertificateRenewalResult

data class Failure(@StringRes val messageId: Int) : E2ECertificateRenewalResult
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

package com.owncloud.android.operations.e2e

import android.accounts.AccountManager
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.nextcloud.client.account.User
import com.nextcloud.client.network.ClientFactory
import com.owncloud.android.R
import com.owncloud.android.datamodel.ArbitraryDataProvider
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.utils.EncryptionUtils
import java.security.KeyFactory
import java.security.KeyPair
import java.security.interfaces.RSAPrivateKey
import java.security.spec.PKCS8EncodedKeySpec

/**
* Renews the end-to-end encryption certificate while preserving the existing key pair.
*
*/
class E2ECertificateRenewalService(
private val clientFactory: ClientFactory,
private val arbitraryDataProvider: ArbitraryDataProvider
) {
private val mainHandler = Handler(Looper.getMainLooper())

fun getCertificateValidity(user: User): E2ECertificateValidity? {
val certificate = arbitraryDataProvider.getValue(user.accountName, EncryptionUtils.PUBLIC_KEY)
if (certificate.isEmpty()) {
return null
}

return runCatching {
val x509 = EncryptionUtils.convertCertFromString(certificate)
E2ECertificateValidity(x509.notBefore, x509.notAfter)
}.getOrNull()
}

fun showRenewCertificateDialog(context: Context, user: User, onResult: (E2ECertificateRenewalResult) -> Unit) {
MaterialAlertDialogBuilder(context, R.style.FallbackTheming_Dialog)
.setTitle(R.string.prefs_renew_e2e_certificate)
.setMessage(R.string.renew_e2e_certificate_dialog_message)
.setCancelable(true)
.setNegativeButton(R.string.common_cancel) { dialog, _ -> dialog.dismiss() }
.setPositiveButton(R.string.common_ok) { dialog, _ ->
dialog.dismiss()
renewCertificate(context.applicationContext, user, onResult)
}
.show()
}

private fun renewCertificate(context: Context, user: User, onResult: (E2ECertificateRenewalResult) -> Unit) {
Thread {
val result = runCatching { renew(context, user) }
.getOrElse { e ->
Log.e(TAG, "Cannot renew E2E certificate", e)
E2ECertificateRenewalResult.Failure(R.string.renew_e2e_certificate_error)
}

mainHandler.post { onResult(result) }
}.start()
}

@Suppress("ReturnCount")
private fun renew(context: Context, user: User): E2ECertificateRenewalResult {
val keyPair = reconstructKeyPair(user)
?: return E2ECertificateRenewalResult.Failure(R.string.renew_e2e_certificate_private_key_missing)

val userId = AccountManager
.get(context)
.getUserData(user.toPlatformAccount(), AccountUtils.Constants.KEY_USER_ID)
val csr = CsrHelper().generateCsrPemEncodedString(keyPair, userId)

val client = clientFactory.createNextcloudClient(user)

// Actually deletes certificate remote operation name is wrong
if (!DeletePublicKeyRemoteOperation().execute(client).isSuccess) {
return E2ECertificateRenewalResult.Failure(R.string.renew_e2e_certificate_error)
}

val sendCsrResult = SendCSRRemoteOperation(csr).execute(client)
val renewedCertificate = sendCsrResult.resultData
if (!sendCsrResult.isSuccess || renewedCertificate.isNullOrEmpty()) {
return E2ECertificateRenewalResult.Failure(R.string.renew_e2e_certificate_error)
}

if (!isMatchingKeys(keyPair, renewedCertificate)) {
EncryptionUtils.reportE2eError(arbitraryDataProvider, user)
return E2ECertificateRenewalResult.Failure(R.string.renew_e2e_certificate_error)
}

arbitraryDataProvider.storeOrUpdateKeyValue(user.accountName, EncryptionUtils.PUBLIC_KEY, renewedCertificate)
Log_OC.i(TAG, "E2E certificate renewed successfully for " + user.accountName)

return E2ECertificateRenewalResult.Success
}

private fun isMatchingKeys(keyPair: KeyPair, certificate: String): Boolean {
val privateKey = keyPair.private as? RSAPrivateKey ?: return false
val publicKey = EncryptionUtils.convertPublicKeyFromString(certificate)
return privateKey.modulus == publicKey.modulus
}

private fun reconstructKeyPair(user: User): KeyPair? {
val privateKeyString = arbitraryDataProvider.getValue(user.accountName, EncryptionUtils.PRIVATE_KEY)
val certificate = arbitraryDataProvider.getValue(user.accountName, EncryptionUtils.PUBLIC_KEY)
if (privateKeyString.isEmpty() || certificate.isEmpty()) {
return null
}

return runCatching {
val privateKeyBytes = EncryptionUtils.decodeStringToBase64Bytes(privateKeyString)
val keySpec = PKCS8EncodedKeySpec(privateKeyBytes)
val privateKey = KeyFactory.getInstance(EncryptionUtils.RSA).generatePrivate(keySpec)
val publicKey = EncryptionUtils.convertPublicKeyFromString(certificate)
KeyPair(publicKey, privateKey)
}.getOrNull()
}

companion object {
private val TAG = E2ECertificateRenewalService::class.java.simpleName
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

package com.owncloud.android.operations.e2e

import java.util.Date

data class E2ECertificateValidity(val notBefore: Date, val notAfter: Date)
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@
import com.owncloud.android.lib.common.ExternalLink;
import com.owncloud.android.lib.common.ExternalLinkType;
import com.owncloud.android.lib.common.utils.Log_OC;
import com.owncloud.android.operations.e2e.E2ECertificateRenewalResult;
import com.owncloud.android.operations.e2e.E2ECertificateRenewalService;
import com.owncloud.android.operations.e2e.E2ECertificateValidity;
import com.owncloud.android.operations.e2e.E2EDeletionService;
import com.owncloud.android.providers.DocumentsStorageProvider;
import com.owncloud.android.ui.ThemeableSwitchPreference;
Expand All @@ -80,6 +83,7 @@
import com.owncloud.android.utils.theme.CapabilityUtils;
import com.owncloud.android.utils.theme.ViewThemeUtils;

import java.text.DateFormat;
import java.util.Objects;

import javax.inject.Inject;
Expand Down Expand Up @@ -139,6 +143,7 @@ public class SettingsActivity extends PreferenceActivity
private String pendingLock;

private E2EDeletionService e2EDeletionService;
private E2ECertificateRenewalService e2eCertificateRenewalService;

private User user;
@Inject ArbitraryDataProvider arbitraryDataProvider;
Expand Down Expand Up @@ -167,6 +172,7 @@ public void onCreate(Bundle savedInstanceState) {

user = accountManager.getUser();
e2EDeletionService = new E2EDeletionService(clientFactory);
e2eCertificateRenewalService = new E2ECertificateRenewalService(clientFactory, arbitraryDataProvider);

// retrieve user's base uri
setupBaseUri();
Expand Down Expand Up @@ -373,6 +379,8 @@ private void setupMoreCategory() {

removeE2EFilesAndKeys(preferenceCategoryMore);

setupRenewE2ECertificate(preferenceCategoryMore);

setupHelpPreference(preferenceCategoryMore);

setupRecommendPreference(preferenceCategoryMore);
Expand Down Expand Up @@ -582,6 +590,53 @@ private void showRemoveE2EKeysAndFilesAlertDialog(PreferenceCategory preferenceC
});
}

private void setupRenewE2ECertificate(PreferenceCategory preferenceCategoryMore) {
Preference preference = findPreference("renew_e2e_certificate");
if (preference == null) {
return;
}

if (!BuildConfig.DEBUG || !FileOperationsHelper.isEndToEndEncryptionSetup(this, user)) {
preferenceCategoryMore.removePreference(preference);
return;
}

updateRenewE2ECertificateSummary(preference);

preference.setOnPreferenceClickListener(p -> {
if (!connectivityService.getConnectivity().isConnected()) {
DisplayUtils.showSnackMessage(this, R.string.e2e_offline);
return true;
}

e2eCertificateRenewalService.showRenewCertificateDialog(this, user, result -> {
onRenewE2ECertificateResult(preference, result);
return Unit.INSTANCE;
});
return true;
});
}

private void updateRenewE2ECertificateSummary(Preference preference) {
E2ECertificateValidity validity = e2eCertificateRenewalService.getCertificateValidity(user);
if (validity == null) {
preference.setSummary(R.string.renew_e2e_certificate_summary);
return;
}

String validUntil = DateFormat.getDateInstance().format(validity.getNotAfter());
preference.setSummary(getString(R.string.renew_e2e_certificate_valid_until, validUntil));
}

private void onRenewE2ECertificateResult(Preference preference, E2ECertificateRenewalResult result) {
if (result instanceof E2ECertificateRenewalResult.Success) {
updateRenewE2ECertificateSummary(preference);
DisplayUtils.showSnackMessage(this, R.string.renew_e2e_certificate_success);
} else if (result instanceof E2ECertificateRenewalResult.Failure) {
DisplayUtils.showSnackMessage(this, ((E2ECertificateRenewalResult.Failure) result).getMessageId());
}
}

private void showRemoveE2EAlertDialog(PreferenceCategory preferenceCategoryMore, Preference preference) {
new MaterialAlertDialogBuilder(this, R.style.FallbackTheming_Dialog)
.setTitle(R.string.prefs_e2e_mnemonic)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ class SetupEncryptionDialogFragment :
val user = user ?: return@withContext DownloadKeyResult.UnexpectedError()
val dataProvider = arbitraryDataProvider ?: return@withContext DownloadKeyResult.UnexpectedError()

// GetPublicKeyRemoteOperation is wrong naming it actually fetches certificate
val certificateOperation = GetPublicKeyRemoteOperation()
val certificateResult = certificateOperation.executeNextcloudClient(user, weakContext)
if (!certificateResult.isSuccess) {
Expand Down Expand Up @@ -392,7 +393,7 @@ class SetupEncryptionDialogFragment :
// - 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 publicKeyString: String
val certificate: String

// Create public/private key pair
val keyPair = EncryptionUtils.generateKeyPair()
Expand All @@ -407,8 +408,8 @@ class SetupEncryptionDialogFragment :
val result = operation.executeNextcloudClient(user, context)

if (result.isSuccess) {
publicKeyString = result.resultData
if (!EncryptionUtils.isMatchingKeys(keyPair, publicKeyString)) {
certificate = result.resultData
if (!EncryptionUtils.isMatchingKeys(keyPair, certificate)) {
EncryptionUtils.reportE2eError(arbitraryDataProvider, user)
throw RuntimeException("Wrong CSR returned")
}
Expand Down Expand Up @@ -439,7 +440,7 @@ class SetupEncryptionDialogFragment :
arbitraryDataProvider?.storeOrUpdateKeyValue(
user.accountName,
EncryptionUtils.PUBLIC_KEY,
publicKeyString
certificate
)
arbitraryDataProvider?.storeOrUpdateKeyValue(
user.accountName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,11 @@
public final class EncryptionUtils {
private static final String TAG = EncryptionUtils.class.getSimpleName();

// PUBLIC_KEY actually represents certificate this confusion caused by
// backend APIs: GetPublicKeyRemoteOperation() -> returns certificate not public key
// later PUBLIC_KEY must rename to CERTIFICATE
public static final String PUBLIC_KEY = "PUBLIC_KEY";

public static final String PRIVATE_KEY = "PRIVATE_KEY";
public static final String MNEMONIC = "MNEMONIC";
public static final int ivLength = 16;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import android.content.Context
import androidx.annotation.VisibleForTesting
import com.google.gson.reflect.TypeToken
import com.nextcloud.client.account.User
import com.nextcloud.client.account.UserAccountManagerImpl
import com.nextcloud.utils.CmsSignatureVerifier
import com.nextcloud.utils.autoRename.AutoRename
import com.nextcloud.utils.e2ee.E2EVersionHelper
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1387,6 +1387,13 @@
<string name="remove_e2e">You can remove end-to-end encryption locally on this client</string>
<string name="confirm_removal">Remove local encryption</string>
<string name="remove_e2e_message">You can remove end-to-end encryption locally on this client. The encrypted files will remain on server, but will not be synced to this computer any longer.</string>
<string name="prefs_renew_e2e_certificate">Renew encryption certificate</string>
<string name="renew_e2e_certificate_summary">Renew your end-to-end encryption certificate while keeping your existing keys</string>
<string name="renew_e2e_certificate_valid_until">Valid until %1$s. Tap to renew while keeping your existing keys.</string>
<string name="renew_e2e_certificate_dialog_message">This will renew your end-to-end encryption certificate. Your existing keys and encrypted files remain unchanged. Continue?</string>
<string name="renew_e2e_certificate_success">Encryption certificate renewed</string>
<string name="renew_e2e_certificate_error">Could not renew encryption certificate</string>
<string name="renew_e2e_certificate_private_key_missing">Encryption private key not found</string>
<string name="setup_e2e">During setup of end-to-end encryption, you will receive a random 12 word mnemonic, which you will need to open your files on other devices. This will only be stored on this device, and can be shown again in this screen. Please note it down in a secure place!</string>
<string name="error_showing_encryption_dialog">Error showing encryption setup dialog!</string>
<string name="prefs_keys_exist">Add end-to-end encryption to this client</string>
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/xml/preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@
<Preference
android:title="@string/prefs_remove_e2e_keys_and_files"
android:key="remove_e2e_files_and_keys" />
<Preference
android:title="@string/prefs_renew_e2e_certificate"
android:key="renew_e2e_certificate" />

<Preference
android:title="@string/prefs_help"
Expand Down
Loading
Loading