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,70 @@
/*
* 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 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.junit.Assert.assertTrue
import org.junit.Test

class EncryptionUtilsMetadataVerificationTests {

private val sut = EncryptionUtilsV2()

@Test
fun testVerifyMetadataWhenGivenValidInputsShouldReturnTrue() {
val metadata = EncryptedFolderMetadataFile(
metadata =
EncryptedMetadata(
authenticationTag = "xkVxj0NbQEXIEMlulYZJgg==",
nonce = "HzRiseUfoFJ5lqUi",
ciphertext = "EOnzuyVn9R8qDUBY4yeuJbhQdkOHBMy3nyRGwY0y/+oWctV17XvE0RIbOhH7+smKV3orJKatu5fG6iIZN+HZUQASTCdQ0mdFVPJmdk20UH5nFZ/ilQIyyXAFhLHdYwWA/M7wKYoh5W9fDXNX9cZvHgjWPdT9Pq99PUv37atYxj7Je25GenbtxkVxj0NbQEXIEMlulYZJgg=="
),
users = listOf(
EncryptedUser(
userId = "admin",
certificate = "-----BEGIN CERTIFICATE-----\nMIIC7jCCAdagAwIBAgIBADANBgkqhkiG9w0BAQUFADAQMQ4wDAYDVQQDEwVhZG1p\nbjAeFw0yNjA0MjcwNzI5NDdaFw00NjA0MjIwNzI5NDdaMBAxDjAMBgNVBAMTBWFk\nbWluMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE513/kdkIp+Z5pI\n7rq1UKV5LBiB6dl4Wh46nI3mhVacOA1dJJWIUxRkkrUNWJewe8eJ7QWmhSpeBauA\n06PrAOTd1ZA4gSUWKpsYJqKm5Nxjp+BUMK1nHGQCkNQWjRllhyKTJeG/9PPc0ZrJ\nh4V27bCXC9iX5l/ve35fp99VR4tQ947HWObe07EdPIEFNYfT/IurPwMySZ1WH1gy\nDTw7IxMXcPVg+GUlfBoSVgQ1UdCkvgHc9pE1LuFmyBguAzGLbXDfspUuTs85RLGX\nGYdv2vZU/R2kJEs3ePMtaGXw6DVSx82RkPFVaLDCdShX24yk6gLNEv9oTUXY40i3\nn8njRQIDAQABo1MwUTAdBgNVHQ4EFgQUDE381mprCEvSLaFeOwZRliBSJnwwHwYD\nVR0jBBgwFoAUDE381mprCEvSLaFeOwZRliBSJnwwDwYDVR0TAQH/BAUwAwEB/zAN\nBgkqhkiG9w0BAQUFAAOCAQEAgU0o8Qp5wn3vkcQLYao2heWKsbYYl8wqkztRVVKb\n+qMe2m/FOOMK1Rxv/anEVHHN+SnTc481fHd8z3w6II28LxJ5M+IxzoAFTj6gCv8+\nrL1R9kE91401d1+ulAiJR92ykOcB1h8bk5yoCZSRLIXwViCGUrbC3iu2NLWQDYk4\nvjxwqCSJOWUQh+qaYGCjB6mgkBMAnXGJCN2fV7sAR7N8Hy7Yh5jvuQOgY574FSoS\nuKCMGJZ6ecJlw+rB5pqanlLS9+HNnQ655/gTYgVBJClFClh4nwdPHtpyTySwgx1V\nr3VDvglfnZM+gD/D2d9nTLIlT3MZqhGOIkxKvpVVkdJKzg==\n-----END CERTIFICATE-----",
encryptedMetadataKey = "coawvmhMoAl3iL5okD7K4a4au0Jt0SqUXp6pHP8WD1YTOemFVPsz+ts7TD5kB7ha6Ja3tLdGMq76LP/d2/pbHUiKBd6rytUo6ioHsNmmlTGHAlk9VTDY9fcvtVgkNzy7qyXvsdsUn0gBQ18l526J/bt1uRlClYNKvaEnIh2l3B8X58pzNZqhAKNI7z7WRDbXOVskr4rnqWr2ExBeaZgFwo5nNi9yiqpckICb1S2qwuZJbItqZ8VR2bOG+WpCMwrgcE5UJ6ZvaKLREfmR+qoYYB1oyUuy78eA+sDa3rO5bSgs/9I/cli1b3lZ8JFfgHXRiUYUmBcxZOmUE2IfRSHFTA=="
)
),
version = "2.0",
filedrop = mutableMapOf()
)
val message = EncryptionUtils.serializeJSON(metadata, true)


val cert = """
-----BEGIN CERTIFICATE-----
MIIC7jCCAdagAwIBAgIBADANBgkqhkiG9w0BAQUFADAQMQ4wDAYDVQQDEwVhZG1p
bjAeFw0yNjA0MjcwNzI5NDdaFw00NjA0MjIwNzI5NDdaMBAxDjAMBgNVBAMTBWFk
bWluMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE513/kdkIp+Z5pI
7rq1UKV5LBiB6dl4Wh46nI3mhVacOA1dJJWIUxRkkrUNWJewe8eJ7QWmhSpeBauA
06PrAOTd1ZA4gSUWKpsYJqKm5Nxjp+BUMK1nHGQCkNQWjRllhyKTJeG/9PPc0ZrJ
h4V27bCXC9iX5l/ve35fp99VR4tQ947HWObe07EdPIEFNYfT/IurPwMySZ1WH1gy
DTw7IxMXcPVg+GUlfBoSVgQ1UdCkvgHc9pE1LuFmyBguAzGLbXDfspUuTs85RLGX
GYdv2vZU/R2kJEs3ePMtaGXw6DVSx82RkPFVaLDCdShX24yk6gLNEv9oTUXY40i3
n8njRQIDAQABo1MwUTAdBgNVHQ4EFgQUDE381mprCEvSLaFeOwZRliBSJnwwHwYD
VR0jBBgwFoAUDE381mprCEvSLaFeOwZRliBSJnwwDwYDVR0TAQH/BAUwAwEB/zAN
BgkqhkiG9w0BAQUFAAOCAQEAgU0o8Qp5wn3vkcQLYao2heWKsbYYl8wqkztRVVKb
+qMe2m/FOOMK1Rxv/anEVHHN+SnTc481fHd8z3w6II28LxJ5M+IxzoAFTj6gCv8+
rL1R9kE91401d1+ulAiJR92ykOcB1h8bk5yoCZSRLIXwViCGUrbC3iu2NLWQDYk4
vjxwqCSJOWUQh+qaYGCjB6mgkBMAnXGJCN2fV7sAR7N8Hy7Yh5jvuQOgY574FSoS
uKCMGJZ6ecJlw+rB5pqanlLS9+HNnQ655/gTYgVBJClFClh4nwdPHtpyTySwgx1V
r3VDvglfnZM+gD/D2d9nTLIlT3MZqhGOIkxKvpVVkdJKzg==
-----END CERTIFICATE-----
""".trimIndent()
val certs = listOf(EncryptionUtils.convertCertFromString(cert))
val signature = """
MIIE1wYJKoZIhvcNAQcCoIIEyDCCBMQCAQExDzANBglghkgBZQMEAgEFADALBgkqhkiG9w0BBwGgggLyMIIC7jCCAdagAwIBAgIBADANBgkqhkiG9w0BAQUFADAQMQ4wDAYDVQQDEwVhZG1pbjAeFw0yNjA0MjcwNzI5NDdaFw00NjA0MjIwNzI5NDdaMBAxDjAMBgNVBAMTBWFkbWluMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE513/kdkIp+Z5pI7rq1UKV5LBiB6dl4Wh46nI3mhVacOA1dJJWIUxRkkrUNWJewe8eJ7QWmhSpeBauA06PrAOTd1ZA4gSUWKpsYJqKm5Nxjp+BUMK1nHGQCkNQWjRllhyKTJeG/9PPc0ZrJh4V27bCXC9iX5l/ve35fp99VR4tQ947HWObe07EdPIEFNYfT/IurPwMySZ1WH1gyDTw7IxMXcPVg+GUlfBoSVgQ1UdCkvgHc9pE1LuFmyBguAzGLbXDfspUuTs85RLGXGYdv2vZU/R2kJEs3ePMtaGXw6DVSx82RkPFVaLDCdShX24yk6gLNEv9oTUXY40i3n8njRQIDAQABo1MwUTAdBgNVHQ4EFgQUDE381mprCEvSLaFeOwZRliBSJnwwHwYDVR0jBBgwFoAUDE381mprCEvSLaFeOwZRliBSJnwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAgU0o8Qp5wn3vkcQLYao2heWKsbYYl8wqkztRVVKb+qMe2m/FOOMK1Rxv/anEVHHN+SnTc481fHd8z3w6II28LxJ5M+IxzoAFTj6gCv8+rL1R9kE91401d1+ulAiJR92ykOcB1h8bk5yoCZSRLIXwViCGUrbC3iu2NLWQDYk4vjxwqCSJOWUQh+qaYGCjB6mgkBMAnXGJCN2fV7sAR7N8Hy7Yh5jvuQOgY574FSoSuKCMGJZ6ecJlw+rB5pqanlLS9+HNnQ655/gTYgVBJClFClh4nwdPHtpyTySwgx1Vr3VDvglfnZM+gD/D2d9nTLIlT3MZqhGOIkxKvpVVkdJKzjGCAakwggGlAgEAMBUwEDEOMAwGA1UEAxMFYWRtaW4CAQAwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMC8GCSqGSIb3DQEJBDEiBCDJDYSA3+VA0KfGQbP7BQsnL/s24W/WIb99zb+4uQ8KLjAcBgkqhkiG9w0BCQUxDxcNMjYwNDI3MDczMDAyWjALBgkqhkiG9w0BAQsEggEAYgDB02/z+KaLvieL1hMMA9IZN8KKc4igvilBoS5W7isiArP8D/GIxghMZkrC0Tzqs+/VRlfFREUgf4aBd9GVzd86Qfrhcrzrdd8hoDQvOw/X3UGftqbgJQmOjZUDpI3TiupyQvOU/zqlIjOq5BiZN6RNti2BTcbNyjaTeVh6u1tcqVVSp/Z0keUb+CnJFtIk6WhFepJMWI0vN84OyegNsjzIMSU2WjiN3i0jmYc62MpxUN0ZzmNgdZ7y6exe1Sb8EYUYL83BehQUPKO5EwEjEwX+ScYziWK0atXZioZYI2XLejVbQm1/czPTlA3frywKyM1dnkiufzmRpB49QN4o3g==
""".trimIndent()
val signedData = sut.getSignedData(signature, message)
val result = sut.verifySignedData(signedData, certs)
assertTrue(result)
}
}
23 changes: 23 additions & 0 deletions app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Nextcloud - Android Client
#
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: AGPL-3.0-or-later

cmake_minimum_required(VERSION 3.10.2)

project(cms_verifier VERSION 1.0)

find_package(openssl REQUIRED CONFIG)

add_library(
cms_verifier
SHARED
cms_verifier.cpp
)

target_link_libraries(
cms_verifier
openssl::crypto
android
log
)
100 changes: 100 additions & 0 deletions app/src/main/cpp/cms_verifier.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

#include <jni.h>
#include <android/log.h>
#include <openssl/bio.h>
#include <openssl/cms.h>
#include <openssl/pem.h>
#include <openssl/x509.h>
#include <cstring>

#define LOG_TAG "CmsVerifier"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)

extern "C" JNIEXPORT jboolean JNICALL
Java_com_nextcloud_utils_CmsSignatureVerifier_verifySignedData(
JNIEnv* env,
jobject /* thiz */,
jbyteArray cmsDataArray,
jbyteArray messageDataArray,
jobjectArray certPemArray
) {
jsize cmsLen = env->GetArrayLength(cmsDataArray);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jbyte* cmsBytes = env->GetByteArrayElements(cmsDataArray, nullptr);
BIO* cmsBio = BIO_new_mem_buf(cmsBytes, static_cast<int>(cmsLen));

jsize msgLen = env->GetArrayLength(messageDataArray);
jbyte* msgBytes = env->GetByteArrayElements(messageDataArray, nullptr);
BIO* dataBio = BIO_new_mem_buf(msgBytes, static_cast<int>(msgLen));

CMS_ContentInfo* contentInfo = d2i_CMS_bio(cmsBio, nullptr);

BIO_free(cmsBio);
env->ReleaseByteArrayElements(cmsDataArray, cmsBytes, JNI_ABORT);

if (contentInfo == nullptr) {
LOGE("Failed to parse CMS content info");
BIO_free(dataBio);
env->ReleaseByteArrayElements(messageDataArray, msgBytes, JNI_ABORT);
return JNI_FALSE;
}

int verifyResult = CMS_verify(
contentInfo,
nullptr,
nullptr,
dataBio,
nullptr,
CMS_DETACHED | CMS_NO_SIGNER_CERT_VERIFY
);

BIO_free(dataBio);
env->ReleaseByteArrayElements(messageDataArray, msgBytes, JNI_ABORT);

if (verifyResult != 1) {
LOGE("CMS_verify failed");
CMS_ContentInfo_free(contentInfo);
return JNI_FALSE;
}

STACK_OF(CMS_SignerInfo)* signerInfos = CMS_get0_SignerInfos(contentInfo);
int numSigners = sk_CMS_SignerInfo_num(signerInfos);
jsize numCerts = env->GetArrayLength(certPemArray);
jboolean matched = JNI_FALSE;

for (jsize i = 0; i < numCerts && !matched; ++i) {
auto certPem = reinterpret_cast<jstring>(env->GetObjectArrayElement(certPemArray, i));
const char* pemChars = env->GetStringUTFChars(certPem, nullptr);

BIO* certBio = BIO_new(BIO_s_mem());
BIO_write(certBio, pemChars, static_cast<int>(strlen(pemChars)));
X509* certX509 = PEM_read_bio_X509(certBio, nullptr, nullptr, nullptr);

BIO_free(certBio);
env->ReleaseStringUTFChars(certPem, pemChars);
env->DeleteLocalRef(certPem);

if (certX509 == nullptr) {
LOGE("Failed to parse PEM certificate at index %d", i);
continue;
}

for (int j = 0; j < numSigners; ++j) {
CMS_SignerInfo* signerInfo = sk_CMS_SignerInfo_value(signerInfos, j);
if (CMS_SignerInfo_cert_cmp(signerInfo, certX509) == 0) {
matched = JNI_TRUE;
break;
}
}

X509_free(certX509);
}

CMS_ContentInfo_free(contentInfo);
return matched;
}
18 changes: 18 additions & 0 deletions app/src/main/java/com/nextcloud/utils/CmsSignatureVerifier.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

package com.nextcloud.utils

class CmsSignatureVerifier {
external fun verifySignedData(cmsData: ByteArray, messageData: ByteArray, certificates: Array<String>): Boolean

companion object {
init {
System.loadLibrary("cms_verifier")
}
}
}
129 changes: 39 additions & 90 deletions app/src/main/java/com/owncloud/android/utils/EncryptionUtilsV2.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import android.content.Context
import androidx.annotation.VisibleForTesting
import com.google.gson.reflect.TypeToken
import com.nextcloud.client.account.User
import com.nextcloud.utils.CmsSignatureVerifier
import com.nextcloud.utils.autoRename.AutoRename
import com.nextcloud.utils.e2ee.E2EVersionHelper
import com.nextcloud.utils.extensions.showToast
Expand Down Expand Up @@ -668,97 +669,12 @@ class EncryptionUtilsV2 {
}
}

// TODO verify metadata
// if (!verifyMetadata(decryptedFolderMetadata)) {
// throw IllegalStateException("Metadata is corrupt!")
// }

// Auto rename if oc capability enabled for windows compatibility
decryptedFolderMetadata.metadata.files.values.forEach { file ->
file.filename = AutoRename.rename(file.filename, storageManager.getCapability(user))
}

return decryptedFolderMetadata

// handle filesDrops
// TODO re-add
// try {
// int filesDropCountBefore = encryptedFolderMetadata.getFiledrop().size();
// DecryptedFolderMetadataFile decryptedFolderMetadata = new EncryptionUtilsV2().decryptFolderMetadataFile(
// encryptedFolderMetadata,
// privateKey);
//
// boolean transferredFiledrop = filesDropCountBefore > 0 && decryptedFolderMetadata.getFiles().size() ==
// encryptedFolderMetadata.getFiles().size() + filesDropCountBefore;
//
// if (transferredFiledrop) {
// // lock folder, only if not already locked
// String token;
// if (existingLockToken == null) {
// token = EncryptionUtils.lockFolder(folder, client);
// } else {
// token = existingLockToken;
// }
//
// // upload metadata
// EncryptedFolderMetadataFile encryptedFolderMetadataNew =
// encryptFolderMetadata(decryptedFolderMetadata, privateKey);
//
// String serializedFolderMetadata = EncryptionUtils.serializeJSON(encryptedFolderMetadataNew);
//
// EncryptionUtils.uploadMetadata(folder,
// serializedFolderMetadata,
// token,
// client,
// true);
//
// // unlock folder, only if not previously locked
// if (existingLockToken == null) {
// RemoteOperationResult unlockFolderResult = EncryptionUtils.unlockFolder(folder, client, token);
//
// if (!unlockFolderResult.isSuccess()) {
// Log_OC.e(TAG, unlockFolderResult.getMessage());
//
// return null;
// }
// }
// }
//
// return decryptedFolderMetadata;
// } catch (Exception e) {
// Log_OC.e(TAG, e.getMessage());
// return null;
// }

// TODO to check
// try {
// int filesDropCountBefore = 0;
// if (encryptedFolderMetadata.getFiledrop() != null) {
// filesDropCountBefore = encryptedFolderMetadata.getFiledrop().size();
// }
// DecryptedFolderMetadataFile decryptedFolderMetadata = EncryptionUtils.decryptFolderMetaData(
// encryptedFolderMetadata,
// privateKey,
// arbitraryDataProvider,
// user,
// folder.getLocalId());
//
// boolean transferredFiledrop = filesDropCountBefore > 0 &&
// decryptedFolderMetadata.getFiles().size() ==
// encryptedFolderMetadata.getFiles().size() + filesDropCountBefore;
//
// if (transferredFiledrop) {
// // lock folder
// String token = EncryptionUtils.lockFolder(folder, client);
//
// // upload metadata
// EncryptedFolderMetadata encryptedFolderMetadataNew =
// encryptFolderMetadata(decryptedFolderMetadata,
// publicKey,
// arbitraryDataProvider,
// user,
// folder.getLocalId());
//
}

@Throws(UploadException::class)
Expand Down Expand Up @@ -978,7 +894,7 @@ class EncryptionUtilsV2 {
return true
}

private fun getSignedData(base64encodedSignature: String, message: String): CMSSignedData {
fun getSignedData(base64encodedSignature: String, message: String): CMSSignedData {
val signature = EncryptionUtils.decodeStringToBase64Bytes(base64encodedSignature)
val asn1Signature = ASN1Sequence.fromByteArray(signature)
val contentInfo = ContentInfo.getInstance(asn1Signature)
Expand All @@ -990,21 +906,53 @@ class EncryptionUtilsV2 {
return CMSSignedData(cmsProcessableByteArray, contentInfo)
}

@Suppress("TooGenericExceptionCaught")
fun verifySignedData(data: CMSSignedData, certs: List<X509Certificate>): Boolean {
val signer = data.signerInfos.signers.first() as SignerInformation
val cmsBytes = data.toASN1Structure().encoded

val messageBytes = ByteArrayOutputStream().also { data.signedContent.write(it) }.toByteArray()

val certificatesAsPEMs = certs.map { cert -> toPemString(cert) }.toTypedArray()

return runCatching {
CmsSignatureVerifier().verifySignedData(cmsBytes, messageBytes, certificatesAsPEMs)
}.getOrElse {
Log_OC.w(TAG, "Exception verifySignedData: $it, trying bouncy castle")
verifySignedDataViaBouncyCastle(data, certs)
}
}

@Suppress("TooGenericExceptionCaught")
private fun verifySignedDataViaBouncyCastle(data: CMSSignedData, certs: List<X509Certificate>): Boolean {
val signers = data.signerInfos.signers
if (signers.isEmpty()) {
Log_OC.e(TAG, "signers are empty")
return false
}

val signer: SignerInformation? = signers.first()
if (signer == null) {
Log_OC.e(TAG, "signer is null")
return false
}

val verifierBuilder = JcaSimpleSignerInfoVerifierBuilder()

return certs.any { cert ->
runCatching {
signer.verify(verifierBuilder.build(cert.publicKey))
val verifier = verifierBuilder.build(cert.publicKey)
signer.verify(verifier)
}.getOrElse {
Log_OC.e(TAG, "Exception verifySignedData: $it")
Log_OC.e(TAG, "Exception verifySignedDataViaBouncyCastle: $it")
false
}
}
}

private fun toPemString(cert: X509Certificate): String {
val encoded = java.util.Base64.getMimeEncoder(PEM_LINE_LENGTH, "\n".toByteArray()).encodeToString(cert.encoded)
return "-----BEGIN CERTIFICATE-----\n$encoded\n-----END CERTIFICATE-----\n"
}

private fun signMessage(cert: X509Certificate, key: PrivateKey, data: ByteArray): CMSSignedData {
val content = CMSProcessableByteArray(data)
val certs = JcaCertStore(listOf(cert))
Expand Down Expand Up @@ -1096,5 +1044,6 @@ class EncryptionUtilsV2 {

companion object {
private val TAG = EncryptionUtils::class.java.simpleName
private const val PEM_LINE_LENGTH = 64
}
}
Loading
Loading