diff --git a/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateRenewalResult.kt b/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateRenewalResult.kt new file mode 100644 index 000000000000..deec20b21280 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateRenewalResult.kt @@ -0,0 +1,16 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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 +} diff --git a/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateRenewalService.kt b/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateRenewalService.kt new file mode 100644 index 000000000000..ec23d71fec6f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateRenewalService.kt @@ -0,0 +1,137 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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 + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateValidity.kt b/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateValidity.kt new file mode 100644 index 000000000000..cd042a7ad49e --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateValidity.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java index d1093bcb5d93..0020d1e9e3be 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java @@ -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; @@ -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; @@ -139,6 +143,7 @@ public class SettingsActivity extends PreferenceActivity private String pendingLock; private E2EDeletionService e2EDeletionService; + private E2ECertificateRenewalService e2eCertificateRenewalService; private User user; @Inject ArbitraryDataProvider arbitraryDataProvider; @@ -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(); @@ -373,6 +379,8 @@ private void setupMoreCategory() { removeE2EFilesAndKeys(preferenceCategoryMore); + setupRenewE2ECertificate(preferenceCategoryMore); + setupHelpPreference(preferenceCategoryMore); setupRecommendPreference(preferenceCategoryMore); @@ -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) 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 857b87bbfe62..1aa573e71c29 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 @@ -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) { @@ -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() @@ -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") } @@ -439,7 +440,7 @@ class SetupEncryptionDialogFragment : arbitraryDataProvider?.storeOrUpdateKeyValue( user.accountName, EncryptionUtils.PUBLIC_KEY, - publicKeyString + certificate ) arbitraryDataProvider?.storeOrUpdateKeyValue( user.accountName, 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 3379bae8f00d..0a77ebdd0b6b 100644 --- a/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java @@ -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; diff --git a/app/src/main/java/com/owncloud/android/utils/EncryptionUtilsV2.kt b/app/src/main/java/com/owncloud/android/utils/EncryptionUtilsV2.kt index 991d45247465..2a4381e60906 100644 --- a/app/src/main/java/com/owncloud/android/utils/EncryptionUtilsV2.kt +++ b/app/src/main/java/com/owncloud/android/utils/EncryptionUtilsV2.kt @@ -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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7734d4ce2a34..261b71084e0c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1387,6 +1387,13 @@ You can remove end-to-end encryption locally on this client Remove local encryption 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. + Renew encryption certificate + Renew your end-to-end encryption certificate while keeping your existing keys + Valid until %1$s. Tap to renew while keeping your existing keys. + This will renew your end-to-end encryption certificate. Your existing keys and encrypted files remain unchanged. Continue? + Encryption certificate renewed + Could not renew encryption certificate + Encryption private key not found 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! Error showing encryption setup dialog! Add end-to-end encryption to this client diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index fb7f89111f0f..aa73c18f2f11 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -116,6 +116,9 @@ + + + @Before + fun setUp() { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(BouncyCastleProvider()) + } + + base64Mock = Mockito.mockStatic(Base64::class.java) + base64Mock.`when` { + Base64.encodeToString(ArgumentMatchers.any(ByteArray::class.java), ArgumentMatchers.anyInt()) + }.thenAnswer { invocation -> + java.util.Base64.getEncoder().encodeToString(invocation.getArgument(0)) + } + base64Mock.`when` { + Base64.decode(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt()) + }.thenAnswer { invocation -> + java.util.Base64.getDecoder().decode(invocation.getArgument(0)) + } + } + + @After + fun tearDown() { + base64Mock.close() + } + + @Test + fun oldCertificateInMetadataVerifiesSignatureCreatedWithRenewedCertificate() { + val keyPair = EncryptionUtils.generateKeyPair() + val oldCertificate = createCertificate(keyPair, OLD_CERTIFICATE_SERIAL) + val renewedCertificate = createCertificate(keyPair, RENEWED_CERTIFICATE_SERIAL) + + // Renewal keeps the key pair: identical public key, but different certificate bytes. + assertArrayEquals(oldCertificate.publicKey.encoded, renewedCertificate.publicKey.encoded) + assertFalse(oldCertificate.encoded.contentEquals(renewedCertificate.encoded)) + + val encryptedMetadata = buildEncryptedMetadata(toPem(oldCertificate)) + val message = EncryptionUtils.serializeJSON(encryptedMetadata, true) + + // Metadata is re-signed after renewal, so the signature is produced with the renewed certificate, + // while the users array still embeds the old certificate carried over from the stored metadata. + val signature = sut.getMessageSignature(renewedCertificate, keyPair.private, message) + val decryptedMetadata = buildDecryptedMetadata(toPem(oldCertificate)) + + assertTrue(sut.verifyMetadata(encryptedMetadata, decryptedMetadata, INITIAL_COUNTER, signature)) + } + + @Test + fun unchangedOldMetadataStillVerifiesAfterRenewal() { + val keyPair = EncryptionUtils.generateKeyPair() + val oldCertificate = createCertificate(keyPair, OLD_CERTIFICATE_SERIAL) + + val encryptedMetadata = buildEncryptedMetadata(toPem(oldCertificate)) + val message = EncryptionUtils.serializeJSON(encryptedMetadata, true) + val signature = sut.getMessageSignature(oldCertificate, keyPair.private, message) + val decryptedMetadata = buildDecryptedMetadata(toPem(oldCertificate)) + + assertTrue(sut.verifyMetadata(encryptedMetadata, decryptedMetadata, INITIAL_COUNTER, signature)) + } + + @Test + fun signatureIsInterchangeableBetweenOldAndRenewedCertificate() { + val keyPair = EncryptionUtils.generateKeyPair() + val oldCertificate = createCertificate(keyPair, OLD_CERTIFICATE_SERIAL) + val renewedCertificate = createCertificate(keyPair, RENEWED_CERTIFICATE_SERIAL) + + val message = EncryptionUtils.serializeJSON(buildEncryptedMetadata(toPem(oldCertificate)), true) + val signatureWithOld = sut.getMessageSignature(oldCertificate, keyPair.private, message) + val signatureWithRenewed = sut.getMessageSignature(renewedCertificate, keyPair.private, message) + + assertTrue(sut.verifySignedData(sut.getSignedData(signatureWithOld, message), listOf(renewedCertificate))) + assertTrue(sut.verifySignedData(sut.getSignedData(signatureWithRenewed, message), listOf(oldCertificate))) + } + + private fun buildEncryptedMetadata(certificatePem: String) = EncryptedFolderMetadataFile( + metadata = EncryptedMetadata( + ciphertext = CIPHERTEXT, + nonce = NONCE, + authenticationTag = AUTHENTICATION_TAG + ), + users = listOf(EncryptedUser(USER_ID, certificatePem, ENCRYPTED_METADATA_KEY)), + filedrop = mutableMapOf(), + version = E2EE_VERSION + ) + + private fun buildDecryptedMetadata(certificatePem: String): DecryptedFolderMetadataFile { + val metadataKey = EncryptionUtils.generateKey() + val metadata = DecryptedMetadata( + keyChecksums = mutableListOf(sut.hashMetadataKey(metadataKey)), + counter = INITIAL_COUNTER + 1, + metadataKey = metadataKey + ) + + return DecryptedFolderMetadataFile( + metadata = metadata, + users = mutableListOf(DecryptedUser(USER_ID, certificatePem, null)), + filedrop = mutableMapOf(), + version = E2EE_VERSION + ) + } + + private fun createCertificate(keyPair: KeyPair, serial: Long): X509Certificate { + val name = X500Name("CN=$USER_ID") + val builder = JcaX509v3CertificateBuilder( + name, + BigInteger.valueOf(serial), + Date(CERTIFICATE_NOT_BEFORE_MILLIS), + Date(CERTIFICATE_NOT_AFTER_MILLIS), + name, + keyPair.public + ) + val signer = JcaContentSignerBuilder(SIGNATURE_ALGORITHM).build(keyPair.private) + return JcaX509CertificateConverter().getCertificate(builder.build(signer)) + } + + private fun toPem(certificate: X509Certificate): String { + val encoded = java.util.Base64.getMimeEncoder(PEM_LINE_LENGTH, LINE_SEPARATOR.toByteArray()) + .encodeToString(certificate.encoded) + return "$PEM_HEADER\n$encoded\n$PEM_FOOTER\n" + } + + companion object { + private const val USER_ID = "alice" + private const val E2EE_VERSION = "2.0" + private const val SIGNATURE_ALGORITHM = "SHA256withRSA" + private const val INITIAL_COUNTER = 0L + private const val OLD_CERTIFICATE_SERIAL = 1L + private const val RENEWED_CERTIFICATE_SERIAL = 2L + private const val PEM_LINE_LENGTH = 64 + private const val LINE_SEPARATOR = "\n" + private const val PEM_HEADER = "-----BEGIN CERTIFICATE-----" + private const val PEM_FOOTER = "-----END CERTIFICATE-----" + private const val CERTIFICATE_NOT_BEFORE_MILLIS = 1_700_000_000_000L + private const val CERTIFICATE_NOT_AFTER_MILLIS = 4_100_000_000_000L + private const val CIPHERTEXT = "dGVzdC1jaXBoZXJ0ZXh0" + private const val NONCE = "dGVzdC1ub25jZQ==" + private const val AUTHENTICATION_TAG = "dGVzdC10YWc=" + private const val ENCRYPTED_METADATA_KEY = "dGVzdC1lbmNyeXB0ZWQta2V5" + } +}