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
Expand Up @@ -11,6 +11,8 @@ import androidx.test.platform.app.InstrumentationRegistry
import com.google.gson.Gson
import com.owncloud.android.datamodel.Credentials
import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator
import com.owncloud.android.ui.dialog.setupEncryption.model.CertificateValidationResult
import org.junit.Assert.assertEquals
import org.junit.After
import org.junit.Before
import org.junit.Test
Expand Down Expand Up @@ -39,7 +41,6 @@ class CertificateValidatorTests {
Gson().fromJson(reader, Credentials::class.java)
}

val isCertificateValid = sut?.validate(credentials.publicKey, credentials.certificate) ?: false
assert(isCertificateValid)
assertEquals(CertificateValidationResult.VALID, sut?.validate(credentials.publicKey, credentials.certificate))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import com.nextcloud.client.database.entity.FileEntity
import com.nextcloud.client.network.NetworkModule
import com.nextcloud.test.SinceServer
import com.nextcloud.utils.e2ee.E2EEActionResolver
import com.nextcloud.utils.e2ee.E2EECertificateManager
import com.nextcloud.utils.e2ee.E2EEKeyInspector
import com.nextcloud.repository.ClientRepository
import org.mockito.kotlin.mock
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.lib.resources.e2ee.ToggleEncryptionRemoteOperation
import com.owncloud.android.lib.resources.status.GetCapabilitiesRemoteOperation
Expand Down Expand Up @@ -61,12 +64,17 @@ open class EncryptedFoldersIT : AbstractOnServerIT() {

init {
val accountManager: UserAccountManager = UserAccountManagerImpl.fromContext(targetContext)
val clientRepository: ClientRepository = mock {
onBlocking { getOwncloudClient() }.thenReturn(client)
onBlocking { getNextcloudClient() }.thenReturn(nextcloudClient)
}
val inspector = E2EEKeyInspector(
targetContext,
storageManager,
CertificateValidator(),
E2EECertificateManager(CertificateValidator(), arbitraryDataProvider),
arbitraryDataProvider,
accountManager
accountManager,
clientRepository
)
e2eeActionResolver = E2EEActionResolver(
storageManager,
Expand Down
11 changes: 7 additions & 4 deletions app/src/main/java/com/nextcloud/client/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import com.nextcloud.client.utils.Throttler
import com.nextcloud.repository.ClientRepository
import com.nextcloud.repository.RemoteClientRepository
import com.nextcloud.utils.e2ee.E2EEActionResolver
import com.nextcloud.utils.e2ee.E2EECertificateManager
import com.nextcloud.utils.e2ee.E2EEKeyInspector
import com.nextcloud.utils.thumbnail.FolderThumbnailGenerator
import com.owncloud.android.authentication.PassCodeManager
Expand Down Expand Up @@ -241,15 +242,17 @@ internal class AppModule {
fun e2eeKeyInspector(
context: Context,
fileDataStorageManager: FileDataStorageManager,
certificateValidator: CertificateValidator,
certificateManager: E2EECertificateManager,
arbitraryDataProvider: ArbitraryDataProvider,
accountManager: UserAccountManager
accountManager: UserAccountManager,
clientRepository: ClientRepository
): E2EEKeyInspector = E2EEKeyInspector(
context,
fileDataStorageManager,
certificateValidator,
certificateManager,
arbitraryDataProvider,
accountManager
accountManager,
clientRepository
)

@Provides
Expand Down
29 changes: 16 additions & 13 deletions app/src/main/java/com/nextcloud/utils/e2ee/E2EEActionResolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.client.di.Injectable
import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.utils.e2ee.model.E2EEKeyCheck
import com.nextcloud.utils.e2ee.model.E2EEKeyCheckResult
import com.nextcloud.utils.extensions.isNetworkAndServerAvailableSuspended
import com.owncloud.android.datamodel.ArbitraryDataProvider
import com.owncloud.android.datamodel.FileDataStorageManager
Expand Down Expand Up @@ -45,45 +46,47 @@ class E2EEActionResolver @Inject constructor(
return@withContext canDecrypt
}

suspend fun checkKeys(): E2EEKeyCheck = withContext(Dispatchers.IO) { resolveKeyCheck() }
suspend fun checkKeys(): E2EEKeyCheckResult = withContext(Dispatchers.IO) {
resolveKeyCheck().also { Log_OC.d(TAG, "e2ee key check result: ${it.state}") }
}

private suspend fun resolveKeyCheck(): E2EEKeyCheck {
private suspend fun resolveKeyCheck(): E2EEKeyCheckResult {
val keysAbsentLocally = inspector.isLocalKeysAbsent()

if (!inspector.fetchCapabilities()) {
return if (connectivityService.isNetworkAndServerAvailableSuspended()) {
inspector.fetchCapabilities()?.let { capabilitiesError ->
val state = if (connectivityService.isNetworkAndServerAvailableSuspended()) {
E2EEKeyCheck.CHECK_FAILED
} else {
E2EEKeyCheck.NO_NETWORK
}
return E2EEKeyCheckResult(state, capabilitiesError.descriptionId)
}

val capability = storageManager.getCapability(accountManager.user)
val keysExistOnServer = capability.endToEndEncryptionKeysExist

val result = when {
val state = when {
keysExistOnServer.isUnknown || capability.endToEndEncryption.isUnknown -> E2EEKeyCheck.E2EE_UNAVAILABLE
keysAbsentLocally && keysExistOnServer.isTrue -> E2EEKeyCheck.ONLY_ON_SERVER
keysAbsentLocally -> E2EEKeyCheck.MISSING_EVERYWHERE
!keysExistOnServer.isTrue -> E2EEKeyCheck.ONLY_ON_DEVICE
else -> compareKeys()
else -> return compareKeys()
}

Log_OC.d(TAG, "e2ee key check result: $result")

return result
return E2EEKeyCheckResult(state)
}

private suspend fun compareKeys(): E2EEKeyCheck {
private suspend fun compareKeys(): E2EEKeyCheckResult {
val storedPublicKey = arbitraryDataProvider.getValue(accountManager.user, EncryptionUtils.PUBLIC_KEY)

return when (val result = inspector.compareWithServerKey(storedPublicKey)) {
is DownloadKeyResult.CompareKeys ->
is DownloadKeyResult.CompareKeys -> E2EEKeyCheckResult(
if (result.same) E2EEKeyCheck.SAME_AS_SERVER else E2EEKeyCheck.DIFFERS_FROM_SERVER
)

is DownloadKeyResult.NoServerKey -> E2EEKeyCheck.ONLY_ON_DEVICE
is DownloadKeyResult.NoServerKey -> E2EEKeyCheckResult(E2EEKeyCheck.ONLY_ON_DEVICE)

else -> E2EEKeyCheck.CHECK_FAILED
else -> E2EEKeyCheckResult(E2EEKeyCheck.CHECK_FAILED, result.descriptionId)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

package com.nextcloud.utils.e2ee

import com.nextcloud.client.account.User
import com.owncloud.android.datamodel.ArbitraryDataProvider
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator
import com.owncloud.android.ui.dialog.setupEncryption.model.CertificateValidationResult
import com.owncloud.android.ui.dialog.setupEncryption.model.DownloadKeyResult
import com.owncloud.android.utils.EncryptionUtils
import com.owncloud.android.utils.crypto.CryptoStringUtils
import java.security.GeneralSecurityException
import java.security.KeyFactory
import java.security.Signature
import java.security.cert.X509Certificate
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Base64
import javax.inject.Inject

class E2EECertificateManager @Inject constructor(
private val validator: CertificateValidator,
private val arbitraryDataProvider: ArbitraryDataProvider
) {
fun compareAndRefresh(
user: User,
storedCertificate: String,
remoteCertificate: String,
serverPublicKey: String
): DownloadKeyResult {
val validation = validator.validate(serverPublicKey, remoteCertificate)
if (validation != CertificateValidationResult.VALID) {
return DownloadKeyResult.CertificateVerificationFailed(validation)
}

val remote = validator.parseCertificate(remoteCertificate)
try {
val stored = validator.parseCertificate(storedCertificate)
if (!stored.publicKey.encoded.contentEquals(remote.publicKey.encoded)) {
return DownloadKeyResult.CompareKeys(same = false)
}

val privateKey = arbitraryDataProvider.getValue(user, EncryptionUtils.PRIVATE_KEY)
if (!matchesPrivateKey(remote, privateKey)) {
return DownloadKeyResult.LocalKeysInvalid()
}
} catch (e: GeneralSecurityException) {
Log_OC.w(TAG, "Cannot verify local encryption keys: $e")
return DownloadKeyResult.LocalKeysInvalid()
} catch (e: IllegalArgumentException) {
Log_OC.w(TAG, "Cannot decode local encryption keys: $e")
return DownloadKeyResult.LocalKeysInvalid()
}

if (storedCertificate != remoteCertificate) {
arbitraryDataProvider.storeOrUpdateKeyValue(user, EncryptionUtils.PUBLIC_KEY, remoteCertificate)
}
return DownloadKeyResult.CompareKeys(same = true)
}

private fun matchesPrivateKey(certificate: X509Certificate, privateKey: String): Boolean {
val encodedKey = CryptoStringUtils.rawPrivateKey(privateKey).replace("\\s+".toRegex(), "")
val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(encodedKey))
val key = KeyFactory.getInstance("RSA").generatePrivate(keySpec)
val message = certificate.publicKey.encoded
val signer = Signature.getInstance("SHA256withRSA")
signer.initSign(key)
signer.update(message)
val signature = signer.sign()

val verifier = Signature.getInstance("SHA256withRSA")
verifier.initVerify(certificate.publicKey)
verifier.update(message)
return verifier.verify(signature)
}

companion object {
private const val TAG = "E2EECertificateManager"
}
}
24 changes: 24 additions & 0 deletions app/src/main/java/com/nextcloud/utils/e2ee/E2EEDialogPresenter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@

package com.nextcloud.utils.e2ee

import com.nextcloud.utils.SnackbarUtil
import com.nextcloud.utils.e2ee.model.E2EEAction
import com.nextcloud.utils.e2ee.model.E2EEDialog
import com.nextcloud.utils.e2ee.model.E2EEKeyCheckResult
import com.owncloud.android.R
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.ui.dialog.ConfirmationDialogFragment
Expand Down Expand Up @@ -47,4 +50,25 @@ class E2EEDialogPresenter(private val fragment: OCFileListFragment) {
override fun onNeutral(callerTag: String?) = Unit
override fun onCancel(callerTag: String?) = Unit
}

fun showKeyCheckError(result: E2EEKeyCheckResult, action: E2EEAction) {
val messageId = result.getMessageId(action) ?: return
if (result.errorMessageId == null) {
SnackbarUtil.show(fragment, messageId)
return
}
if (fragment.parentFragmentManager.findFragmentByTag(ENCRYPTION_KEY_ALERT_DIALOG_TAG) != null) {
return
}

ConfirmationDialogFragment.newInstance(
titleResId = R.string.end_to_end_encryption_title,
titleIconId = R.drawable.ic_lock_open_white,
messageResId = messageId,
positiveButtonTextId = R.string.common_ok,
neutralButtonTextId = NO_BUTTON,
negativeButtonTextId = NO_BUTTON,
messageArguments = null
).show(fragment.parentFragmentManager, ENCRYPTION_KEY_ALERT_DIALOG_TAG)
}
}
48 changes: 26 additions & 22 deletions app/src/main/java/com/nextcloud/utils/e2ee/E2EEKeyInspector.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,19 @@ package com.nextcloud.utils.e2ee
import android.content.Context
import com.google.gson.reflect.TypeToken
import com.nextcloud.client.account.UserAccountManager
import com.nextcloud.repository.ClientRepository
import com.owncloud.android.R
import com.owncloud.android.datamodel.ArbitraryDataProvider
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFolderMetadataFileV1
import com.owncloud.android.lib.common.OwnCloudClient
import com.owncloud.android.lib.common.OwnCloudClientFactory
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.lib.resources.e2ee.GetMetadataRemoteOperation
import com.owncloud.android.lib.resources.status.OCCapability
import com.owncloud.android.lib.resources.users.GetCertificateRemoteOperation
import com.owncloud.android.lib.resources.users.GetServerPublicKeyRemoteOperation
import com.owncloud.android.operations.GetCapabilitiesOperation
import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator
import com.owncloud.android.ui.dialog.setupEncryption.model.DownloadKeyResult
import com.owncloud.android.utils.EncryptionUtils
import com.owncloud.android.utils.EncryptionUtilsV2
Expand All @@ -34,9 +34,10 @@ import javax.inject.Inject
class E2EEKeyInspector @Inject constructor(
private val context: Context,
private val storageManager: FileDataStorageManager,
private val certificateValidator: CertificateValidator,
private val certificateManager: E2EECertificateManager,
private val arbitraryDataProvider: ArbitraryDataProvider,
private val accountManager: UserAccountManager
private val accountManager: UserAccountManager,
private val clientRepository: ClientRepository
) {
companion object {
private const val TAG = "E2EEKeyInspector"
Expand All @@ -46,47 +47,50 @@ class E2EEKeyInspector @Inject constructor(
arbitraryDataProvider.getValue(accountManager.user, EncryptionUtils.PRIVATE_KEY).isEmpty() ||
arbitraryDataProvider.getValue(accountManager.user, EncryptionUtils.PUBLIC_KEY).isEmpty()

suspend fun fetchCapabilities(): Boolean = withContext(Dispatchers.IO) {
val result = GetCapabilitiesOperation(storageManager).execute(context).isSuccess
if (result) {
Log_OC.i(TAG, "capabilities fetched")
} else {
Log_OC.e(TAG, "error fetching capabilities")
suspend fun fetchCapabilities(): DownloadKeyResult.RequestFailed? = withContext(Dispatchers.IO) {
val client = clientRepository.getOwncloudClient()
?: return@withContext DownloadKeyResult.RequestFailed(R.string.e2ee_key_request_connection_failed)
val result = GetCapabilitiesOperation(storageManager).execute(client)
if (!result.isSuccess) {
Log_OC.e(TAG, "error fetching capabilities: ${result.httpCode} ${result.code}")
return@withContext E2EEKeyRequestError.from(result)
}
return@withContext result
Log_OC.i(TAG, "capabilities fetched")
null
}

suspend fun compareWithServerKey(storedPublicKey: String): DownloadKeyResult = withContext(Dispatchers.IO) {
Log_OC.d(TAG, "comparing local public key with remote public key")

val user = accountManager.user
val client = clientRepository.getNextcloudClient()
?: return@withContext DownloadKeyResult.RequestFailed(R.string.e2ee_key_request_connection_failed)

val certificateResult = GetCertificateRemoteOperation().executeNextcloudClient(user, context)
val certificateResult = GetCertificateRemoteOperation().execute(client)
if (!certificateResult.isSuccess) {
return@withContext if (certificateResult.httpCode == HttpStatus.SC_NOT_FOUND) {
DownloadKeyResult.NoServerKey
} else {
DownloadKeyResult.CertificateUnavailable()
E2EEKeyRequestError.from(certificateResult)
}
}

val serverPublicKeyResult = GetServerPublicKeyRemoteOperation().executeNextcloudClient(user, context)
if (!serverPublicKeyResult.isSuccess) return@withContext DownloadKeyResult.ServerPublicKeyUnavailable()
val serverPublicKeyResult = GetServerPublicKeyRemoteOperation().execute(client)
if (!serverPublicKeyResult.isSuccess) return@withContext E2EEKeyRequestError.from(serverPublicKeyResult)

val isCertificateValid = certificateValidator.validate(
serverPublicKeyResult.resultData,
certificateResult.resultData
return@withContext certificateManager.compareAndRefresh(
user,
storedPublicKey,
certificateResult.resultData,
serverPublicKeyResult.resultData
)
if (!isCertificateValid) return@withContext DownloadKeyResult.CertificateVerificationFailed()

return@withContext DownloadKeyResult.CompareKeys(same = (storedPublicKey == certificateResult.resultData))
}

suspend fun canDecryptFolderMetadata(folder: OCFile, capability: OCCapability): Boolean =
withContext(Dispatchers.IO) {
Log_OC.d(TAG, "checking folder metadata key")

val client = OwnCloudClientFactory.createOwnCloudClient(accountManager.currentAccount, context)
val client = clientRepository.getOwncloudClient() ?: return@withContext false
val metadataResult = GetMetadataRemoteOperation(folder.localId).execute(client)

if (!metadataResult.isSuccess) {
Expand Down
Loading