From 248056dc555f023dd1c25daeed4ea8519db4b0df Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 17 Jul 2026 08:47:00 +0200 Subject: [PATCH 1/4] feat(e2ee): renew certificate Signed-off-by: alperozturk96 --- .../e2e/E2ECertificateRenewalResult.kt | 16 +++ .../e2e/E2ECertificateRenewalService.kt | 129 ++++++++++++++++++ .../operations/e2e/E2ECertificateValidity.kt | 30 ++++ .../android/ui/activity/SettingsActivity.java | 55 ++++++++ app/src/main/res/values/strings.xml | 7 + app/src/main/res/xml/preferences.xml | 3 + 6 files changed, 240 insertions(+) create mode 100644 app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateRenewalResult.kt create mode 100644 app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateRenewalService.kt create mode 100644 app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateValidity.kt 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..5f1e5cf850d4 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateRenewalService.kt @@ -0,0 +1,129 @@ +/* + * 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.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) + + 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 (!EncryptionUtils.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 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..ec982357a838 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateValidity.kt @@ -0,0 +1,30 @@ +/* + * 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.Calendar +import java.util.Date + +data class E2ECertificateValidity(val notBefore: Date, val notAfter: Date) { + val isExpired: Boolean + get() = Date().after(notAfter) + + val isNearExpiry: Boolean + get() { + val warningThreshold = Calendar.getInstance().apply { + time = notAfter + add(Calendar.MONTH, -NEAR_EXPIRY_WARNING_MONTHS) + }.time + + return !Date().before(warningThreshold) + } + + companion object { + private const val NEAR_EXPIRY_WARNING_MONTHS = 1 + } +} 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/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 @@ + Date: Fri, 17 Jul 2026 08:53:53 +0200 Subject: [PATCH 2/4] fix is matching Signed-off-by: alperozturk96 --- .../operations/e2e/E2ECertificateRenewalService.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 index 5f1e5cf850d4..ca9739eaa0fa 100644 --- a/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateRenewalService.kt +++ b/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateRenewalService.kt @@ -25,6 +25,7 @@ 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 /** @@ -96,7 +97,7 @@ class E2ECertificateRenewalService( return E2ECertificateRenewalResult.Failure(R.string.renew_e2e_certificate_error) } - if (!EncryptionUtils.isMatchingKeys(keyPair, renewedCertificate)) { + if (!isMatchingKeys(keyPair, renewedCertificate)) { EncryptionUtils.reportE2eError(arbitraryDataProvider, user) return E2ECertificateRenewalResult.Failure(R.string.renew_e2e_certificate_error) } @@ -107,6 +108,12 @@ class E2ECertificateRenewalService( 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) From 7e940b76c8d461f7c901eedb996b08b39a0118cf Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 17 Jul 2026 09:04:59 +0200 Subject: [PATCH 3/4] clear confusion between certificate and public key Signed-off-by: alperozturk96 --- .../e2e/E2ECertificateRenewalService.kt | 1 + .../operations/e2e/E2ECertificateValidity.kt | 20 +------------------ .../SetupEncryptionDialogFragment.kt | 9 +++++---- .../android/utils/EncryptionUtils.java | 4 ++++ 4 files changed, 11 insertions(+), 23 deletions(-) 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 index ca9739eaa0fa..ec23d71fec6f 100644 --- a/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateRenewalService.kt +++ b/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateRenewalService.kt @@ -87,6 +87,7 @@ class E2ECertificateRenewalService( 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) } 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 index ec982357a838..cd042a7ad49e 100644 --- a/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateValidity.kt +++ b/app/src/main/java/com/owncloud/android/operations/e2e/E2ECertificateValidity.kt @@ -7,24 +7,6 @@ package com.owncloud.android.operations.e2e -import java.util.Calendar import java.util.Date -data class E2ECertificateValidity(val notBefore: Date, val notAfter: Date) { - val isExpired: Boolean - get() = Date().after(notAfter) - - val isNearExpiry: Boolean - get() { - val warningThreshold = Calendar.getInstance().apply { - time = notAfter - add(Calendar.MONTH, -NEAR_EXPIRY_WARNING_MONTHS) - }.time - - return !Date().before(warningThreshold) - } - - companion object { - private const val NEAR_EXPIRY_WARNING_MONTHS = 1 - } -} +data class E2ECertificateValidity(val notBefore: Date, val notAfter: Date) 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; From 27530b3e0c8c463db0e96d3c35d50c417fd2bb22 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 17 Jul 2026 10:31:54 +0200 Subject: [PATCH 4/4] add tests Signed-off-by: alperozturk96 --- .../android/utils/EncryptionUtilsV2.kt | 1 - ...tificateRenewalMetadataVerificationTest.kt | 179 ++++++++++++++++++ 2 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/owncloud/android/utils/E2ECertificateRenewalMetadataVerificationTest.kt 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/test/java/com/owncloud/android/utils/E2ECertificateRenewalMetadataVerificationTest.kt b/app/src/test/java/com/owncloud/android/utils/E2ECertificateRenewalMetadataVerificationTest.kt new file mode 100644 index 000000000000..ab8a4fd3f20f --- /dev/null +++ b/app/src/test/java/com/owncloud/android/utils/E2ECertificateRenewalMetadataVerificationTest.kt @@ -0,0 +1,179 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.utils + +import android.util.Base64 +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedMetadata +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedUser +import com.owncloud.android.datamodel.e2e.v2.encrypted.EncryptedFolderMetadataFile +import com.owncloud.android.datamodel.e2e.v2.encrypted.EncryptedMetadata +import com.owncloud.android.datamodel.e2e.v2.encrypted.EncryptedUser +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers +import org.mockito.MockedStatic +import org.mockito.Mockito +import java.math.BigInteger +import java.security.KeyPair +import java.security.Security +import java.security.cert.X509Certificate +import java.util.Date + +class E2ECertificateRenewalMetadataVerificationTest { + + private val sut = EncryptionUtilsV2() + private lateinit var base64Mock: MockedStatic + + @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" + } +}