diff --git a/.gitignore b/.gitignore index 09a07f80c9..d8b2d2697d 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,4 @@ compile_commands.json /.vscode/ .qtcreator/ .env -compose.yml +docker-compose.yml diff --git a/src/apps/CMakeLists.txt b/src/apps/CMakeLists.txt index 394c493695..6b4dfb3747 100644 --- a/src/apps/CMakeLists.txt +++ b/src/apps/CMakeLists.txt @@ -1,5 +1,6 @@ # SPDX-FileCopyrightText: 2020-2025 Laurent Montel # SPDX-License-Identifier: BSD-3-Clause + include(ECMAddAppIcon) add_executable(ruqola main.cpp) @@ -11,6 +12,7 @@ target_link_libraries(ruqola KF6::XmlGui KF6::IconThemes ) + if(USE_DBUS) target_link_libraries(ruqola KF6::DBusAddons) else() @@ -25,3 +27,4 @@ ecm_add_app_icon(appIcons ICONS "${RUQOLA_ICONS}") target_sources(ruqola PRIVATE ${appIcons}) install(TARGETS ruqola ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) + diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index f5f977130f..ff712103e9 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -509,6 +509,8 @@ target_sources(libruqolacore PRIVATE servererrorinfohistorymanager.cpp servererrorinfohistorymanager.h + + localdatabase/localmessagelogger.cpp localdatabase/localmessagelogger.h @@ -526,10 +528,13 @@ target_sources(libruqolacore PRIVATE localdatabase/localdatabasebase.h localdatabase/localdatabasebase.cpp - + localdatabase/localaccountdatabase.h localdatabase/localaccountdatabase.cpp + localdatabase/e2ekeystore.h + localdatabase/e2ekeystore.cpp + localdatabase/globaldatabase.h localdatabase/globaldatabase.cpp diff --git a/src/core/autotests/CMakeLists.txt b/src/core/autotests/CMakeLists.txt index 47164d9b48..49af9502b1 100644 --- a/src/core/autotests/CMakeLists.txt +++ b/src/core/autotests/CMakeLists.txt @@ -1,4 +1,5 @@ -# SPDX-FileCopyrightText: 2020-2025 Laurent Montel +# SPDX-FileCopyrightText: 2020-2025 Laurent Montel , +# SPDX-FileCopyrightText: 2025 Andro Ranogajec # SPDX-License-Identifier: BSD-3-Clause add_definitions(-DRUQOLA_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data") add_definitions(-DRUQOLA_BINARY_DATA_DIR="${CMAKE_CURRENT_BINARY_DIR}/data") @@ -167,6 +168,38 @@ add_ruqola_test(textconvertertest.cpp) if(USE_E2E_SUPPORT) add_ruqola_test(encryptionutilstest.cpp) add_ruqola_test(masterkeytest.cpp) + add_ruqola_test(rsapairtest.cpp) + add_ruqola_test(sessionkeytest.cpp) + add_ruqola_test(messageencryptiondecryptiontest.cpp) + add_ruqola_test(uploaddownloadrsakeypairtest.cpp) + add_ruqola_test(sessionkeydistributiontest.cpp) + + +target_sources(sessionkeydistributiontest PRIVATE + ${CMAKE_SOURCE_DIR}/tests/encryptiontest/loginmanager.cpp + ${CMAKE_SOURCE_DIR}/tests/encryptiontest/envutils.cpp + ${CMAKE_SOURCE_DIR}/tests/encryptiontest/uploaddownloadrsakeypair.cpp +) + +target_include_directories(sessionkeydistributiontest PRIVATE + ${CMAKE_SOURCE_DIR}/tests/encryptiontest + ${CMAKE_SOURCE_DIR}/src/core/encryption) + +target_include_directories(uploaddownloadrsakeypairtest PRIVATE + ${CMAKE_SOURCE_DIR}/tests/encryptiontest + ${CMAKE_SOURCE_DIR}/src/core/encryption + ${CMAKE_SOURCE_DIR}/src/rocketchatrestapi-qt + ${CMAKE_SOURCE_DIR}/src/rocketchatrestapi-qt/e2e + ${CMAKE_BINARY_DIR}/src/core + ${CMAKE_BINARY_DIR}/src/rocketchatrestapi-qt +) + +target_sources(uploaddownloadrsakeypairtest PRIVATE + ${CMAKE_SOURCE_DIR}/tests/encryptiontest/loginmanager.cpp + ${CMAKE_SOURCE_DIR}/tests/encryptiontest/uploaddownloadrsakeypair.cpp + ${CMAKE_SOURCE_DIR}/tests/encryptiontest/envutils.cpp +) + endif() add_ruqola_test(channelstest.cpp) @@ -184,3 +217,4 @@ add_ruqola_test(actionbuttonsmanagertest.cpp) add_ruqola_test(previewcommandtest.cpp) add_ruqola_test(previewcommandutilstest.cpp) + diff --git a/src/core/autotests/masterkeytest.cpp b/src/core/autotests/masterkeytest.cpp index 922356972d..d6c43fc7a6 100644 --- a/src/core/autotests/masterkeytest.cpp +++ b/src/core/autotests/masterkeytest.cpp @@ -17,7 +17,12 @@ MasterKeyTest::MasterKeyTest(QObject *parent) /** * @brief This methods to going to test the determinism of the master key. * + * n, n1 = salt + * + * m1, m2 = password + * * if + * * n == n1 and m == m1 * then * getMasterKey(n, m) == getMasterKey(n1, m1) diff --git a/src/core/autotests/messageencryptiondecryptiontest.cpp b/src/core/autotests/messageencryptiondecryptiontest.cpp new file mode 100644 index 0000000000..dce08de82e --- /dev/null +++ b/src/core/autotests/messageencryptiondecryptiontest.cpp @@ -0,0 +1,41 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "messageencryptiondecryptiontest.h" +#include "encryption/encryptionutils.h" +#include + +QTEST_GUILESS_MAIN(MessageEncryptionDecryptionTest) +MessageEncryptionDecryptionTest::MessageEncryptionDecryptionTest(QObject *parent) + : QObject(parent) +{ +} + +/** + * @brief Tests symmetric encryption and decryption of a message using a session key. + * + * Verifies that for a message `m` and session key `k`, decryption function `D` + * and encryption function `E` the property holds: + * + * `D(E(m, k), k) = m` + */ +void MessageEncryptionDecryptionTest::messageEncryptionDecryptionTest() +{ + auto message = QStringLiteral("This is GSoC 2025, Andro Ranogajec got to the end of 'Phase 1' :)"); + const QByteArray sessionKey1 = EncryptionUtils::generateSessionKey(); + const QByteArray sessionKey2 = EncryptionUtils::generateSessionKey(); + QString decryptedMessage = QString::fromUtf8(EncryptionUtils::decryptMessage(EncryptionUtils::encryptMessage(message.toUtf8(), sessionKey1), sessionKey1)); + QVERIFY(message == decryptedMessage); + + for (int i = 1; i <= 10; ++i) { + QByteArray message = EncryptionUtils::generateRandomText(i).toUtf8(); + QByteArray encrypted = EncryptionUtils::encryptMessage(message, sessionKey1); + QByteArray decryptedWithWrongKey = EncryptionUtils::decryptMessage(encrypted, sessionKey2); + QVERIFY(decryptedWithWrongKey.isEmpty() && decryptedWithWrongKey != message); + } +} + +#include "moc_messageencryptiondecryptiontest.cpp" \ No newline at end of file diff --git a/src/core/autotests/messageencryptiondecryptiontest.h b/src/core/autotests/messageencryptiondecryptiontest.h new file mode 100644 index 0000000000..442bd6443b --- /dev/null +++ b/src/core/autotests/messageencryptiondecryptiontest.h @@ -0,0 +1,20 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#pragma once + +#include + +class MessageEncryptionDecryptionTest : public QObject +{ + Q_OBJECT +public: + explicit MessageEncryptionDecryptionTest(QObject *parent = nullptr); + ~MessageEncryptionDecryptionTest() override = default; + +private Q_SLOTS: + void messageEncryptionDecryptionTest(); +}; diff --git a/src/core/autotests/rsapairtest.cpp b/src/core/autotests/rsapairtest.cpp new file mode 100644 index 0000000000..1970e365f2 --- /dev/null +++ b/src/core/autotests/rsapairtest.cpp @@ -0,0 +1,70 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "rsapairtest.h" +#include "encryption/encryptionutils.h" +#include + +QTEST_GUILESS_MAIN(RsaPairTest) +RsaPairTest::RsaPairTest(QObject *parent) + : QObject(parent) +{ +} + +void RsaPairTest::rsaPairGenerationNonDeterminismTest() +{ + EncryptionUtils::RSAKeyPair rsaPair1; + EncryptionUtils::RSAKeyPair rsaPair2; + + for (int i = 0; i <= 10; i++) { + rsaPair1 = EncryptionUtils::generateRSAKey(); + rsaPair2 = EncryptionUtils::generateRSAKey(); + QVERIFY(rsaPair1.publicKey != rsaPair2.publicKey); + QVERIFY(rsaPair1.privateKey != rsaPair2.privateKey); + } +} + +/** + * @brief Tests the determinism of private key encryption and decryption using the master key. + * + * Definitions: + * - x = master key + * + * - y = initial private key + * + * - z = encrypt(x, y) = encrypted private key + * + * - w = decrypt(x, z) = decrypted private key + * + * The test verifies: + * + * If the same master key x and private key y are used, + * then decrypting the encrypted key yields the original key: + * + * - decrypt(x, encrypt(x, y)) = y = initial private key + * + * In other words, w = y iff x and y are unchanged. + */ +void RsaPairTest::encryptDecryptDeterminismTest() +{ + EncryptionUtils::RSAKeyPair rsaPair; + QByteArray privateKey; + QByteArray masterKey; + QByteArray encryptedPrivateKey; + QByteArray decryptedPrivateKey; + + for (int i = 0; i <= 10; i++) { + rsaPair = EncryptionUtils::generateRSAKey(); + privateKey = rsaPair.privateKey; + masterKey = EncryptionUtils::getMasterKey(EncryptionUtils::generateRandomText(32), EncryptionUtils::generateRandomText(32)); + encryptedPrivateKey = EncryptionUtils::encryptPrivateKey(rsaPair.privateKey, masterKey); + decryptedPrivateKey = EncryptionUtils::decryptPrivateKey(encryptedPrivateKey, masterKey); + + QVERIFY(decryptedPrivateKey == privateKey); + } +} + +#include "moc_rsapairtest.cpp" \ No newline at end of file diff --git a/src/core/autotests/rsapairtest.h b/src/core/autotests/rsapairtest.h new file mode 100644 index 0000000000..d8a6fa9987 --- /dev/null +++ b/src/core/autotests/rsapairtest.h @@ -0,0 +1,22 @@ + +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#pragma once + +#include + +class RsaPairTest : public QObject +{ + Q_OBJECT +public: + explicit RsaPairTest(QObject *parent = nullptr); + ~RsaPairTest() override = default; + +private Q_SLOTS: + void rsaPairGenerationNonDeterminismTest(); + void encryptDecryptDeterminismTest(); +}; diff --git a/src/core/autotests/sessionkeydistributiontest.cpp b/src/core/autotests/sessionkeydistributiontest.cpp new file mode 100644 index 0000000000..d89dfbcc25 --- /dev/null +++ b/src/core/autotests/sessionkeydistributiontest.cpp @@ -0,0 +1,179 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "sessionkeydistributiontest.h" +#include "e2e/acceptsuggestedgroupkeyjob.h" +#include "e2e/provideuserswithsuggestedgroupkeysjob.h" +#include "e2e/rejectsuggestedgroupkeyjob.h" +#include "encryption/encryptionutils.h" +#include "loginmanager.h" +#include "uploaddownloadrsakeypair.h" +#include +#include +#include +#include +#include +#include +QTEST_GUILESS_MAIN(SessionKeyDistributionTest) +SessionKeyDistributionTest::SessionKeyDistributionTest(QObject *parent) + : QObject(parent) +{ +} + +/* void SessionKeyDistributionTest::testSessionKeyDistribution() +{ + const auto app = QCoreApplication::instance(); + const auto networkManager = new QNetworkAccessManager(app); + const auto url = QStringLiteral("http://localhost:3000"); + const auto password = QStringLiteral("mypassword123"); + const auto roomId = QStringLiteral("123"); // Replace with a real room ID + + // Step 1: Login as two user + const auto loginManager0 = new LoginManager(app); + const auto loginManager1 = new LoginManager(app); + QString user1Id, user2Id, user1Auth, user2Auth; + auto user1KeyPair = EncryptionUtils::RSAKeyPair(); + auto user2KeyPair = EncryptionUtils::RSAKeyPair(); + + // Step 2: Generate session key (AES-128) + const auto sessionKey = EncryptionUtils::generateSessionKey(); + auto testPassed = false; + + // Step 2.5 Helper: proceed when both users are ready + int readyCount = 0; + auto proceed = [&]() { + if (++readyCount < 2) + return; + + // Step 3: Encrypt session key with each user's public key + QVector suggestedKeys; + qDebug() << "user1KeyPair.publicKey size:" << user1KeyPair.publicKey.size(); + const auto encryptedSessionKeyForUser1 = EncryptionUtils::encryptSessionKey(sessionKey, EncryptionUtils::publicKeyFromPEM(user1KeyPair.publicKey)); + const auto encryptedSessionKeyForUser2 = EncryptionUtils::encryptSessionKey(sessionKey, EncryptionUtils::publicKeyFromPEM(user2KeyPair.publicKey)); + suggestedKeys.append({user1Id, QString::fromLatin1(encryptedSessionKeyForUser1.toBase64())}); + suggestedKeys.append({user2Id, QString::fromLatin1(encryptedSessionKeyForUser2.toBase64())}); + + // Step 4: Distribute encrypted keys using API + const auto provideMethod = new RocketChatRestApi::RestApiMethod; + provideMethod->setServerUrl(url); + const auto provideJob = new RocketChatRestApi::ProvideUsersWithSuggestedGroupKeysJob(app); + provideJob->setNetworkAccessManager(networkManager); + provideJob->setRestApiMethod(provideMethod); + provideJob->setRoomId(roomId); + provideJob->setKeys(suggestedKeys); + QObject::connect( + provideJob, + &RocketChatRestApi::ProvideUsersWithSuggestedGroupKeysJob::provideUsersWithSuggestedGroupKeysDone, + app, + [&](const QJsonObject &) { + // Simulate user1 receiving and accepting the key + const auto encKey1 = QByteArray::fromBase64(suggestedKeys[0].encryptedKey.toUtf8()); + const auto decKey1 = EncryptionUtils::decryptSessionKey(encKey1, EncryptionUtils::privateKeyFromPEM(user1KeyPair.privateKey)); + QCOMPARE(decKey1, sessionKey); + + const auto acceptMethod = new RocketChatRestApi::RestApiMethod; + acceptMethod->setServerUrl(url); + const auto acceptJob1 = new RocketChatRestApi::AcceptSuggestedGroupKeyJob(app); + acceptJob1->setRestApiMethod(acceptMethod); + acceptJob1->setNetworkAccessManager(networkManager); + acceptJob1->setRoomId(roomId); + QObject::connect(acceptJob1, &RocketChatRestApi::AcceptSuggestedGroupKeyJob::acceptSuggestedGroupKeyDone, app, [&](const QJsonObject &) { + // Simulate user2 receiving and rejecting the key + const auto encKey2 = QByteArray::fromBase64(suggestedKeys[1].encryptedKey.toUtf8()); + const auto decKey2 = EncryptionUtils::decryptSessionKey(encKey2, EncryptionUtils::privateKeyFromPEM(user2KeyPair.privateKey)); + QCOMPARE(decKey2, sessionKey); + + const auto rejectJob2 = new RocketChatRestApi::RejectSuggestedGroupKeyJob(app); + const auto rejectMethod = new RocketChatRestApi::RestApiMethod; + rejectMethod->setServerUrl(url); + rejectJob2->setRestApiMethod(rejectMethod); + rejectJob2->setNetworkAccessManager(networkManager); + rejectJob2->setRoomId(roomId); + QObject::connect(rejectJob2, &RocketChatRestApi::RejectSuggestedGroupKeyJob::rejectSuggestedGroupKeyDone, app, [&](const QJsonObject &) { + testPassed = true; + app->quit(); + }); + rejectJob2->start(); + }); + acceptJob1->start(); + }); + provideJob->start(); + }; + + // Step 1a: Login and upload keys for user1 + QObject::connect(loginManager0, &LoginManager::loginSucceeded, this, [&](const QString &authToken, const QString &userId) { + user1Id = userId; + user1Auth = authToken; + uploadKeys(authToken, url, userId, password, networkManager, [&](const QString &, const EncryptionUtils::RSAKeyPair &keypair) { + user1KeyPair = keypair; + proceed(); + }); + }); + loginManager0->login(url, networkManager, 0); + + // Step 1b: Login and upload keys for user2 + QObject::connect(loginManager1, &LoginManager::loginSucceeded, this, [&](const QString &authToken, const QString &userId) { + user2Id = userId; + user2Auth = authToken; + uploadKeys(authToken, url, userId, password, networkManager, [&](const QString &, const EncryptionUtils::RSAKeyPair &keypair) { + user2KeyPair = keypair; + proceed(); + }); + }); + loginManager1->login(url, networkManager, 1); + + // Handle login failures + QObject::connect(loginManager0, &LoginManager::loginFailed, this, [=](const QString &err) { + QFAIL(qPrintable(QStringLiteral("User1 login failed: %1").arg(err))); + app->quit(); + }); + QObject::connect(loginManager1, &LoginManager::loginFailed, this, [=](const QString &err) { + QFAIL(qPrintable(QStringLiteral("User2 login failed: %1").arg(err))); + app->quit(); + }); + + app->exec(); + QVERIFY(testPassed); +} */ + +void SessionKeyDistributionTest::testJsonPayload() +{ + RocketChatRestApi::ProvideUsersWithSuggestedGroupKeysJob job; + job.setRoomId(QStringLiteral("123")); + const QVector suggestedGroupKeys = {{QStringLiteral("userA"), QStringLiteral("base64keyA")}, + {QStringLiteral("userB"), QStringLiteral("base64keyB")}}; + job.setKeys(suggestedGroupKeys); + + const QJsonDocument doc = job.json(); + const QJsonObject obj = doc.object(); + QCOMPARE(obj[QStringLiteral("rid")].toString(), QStringLiteral("123")); + QJsonArray arr = obj[QStringLiteral("keys")].toArray(); + QCOMPARE(arr.size(), 2); + QCOMPARE(arr[0].toObject()[QStringLiteral("userId")].toString(), QStringLiteral("userA")); + QCOMPARE(arr[0].toObject()[QStringLiteral("key")].toString(), QStringLiteral("base64keyA")); +} + +void SessionKeyDistributionTest::testCanStartValidation() +{ + RocketChatRestApi::ProvideUsersWithSuggestedGroupKeysJob job; + const auto networkManager = new QNetworkAccessManager(this); + job.setNetworkAccessManager(networkManager); + job.setAuthToken(QStringLiteral("dummyToken")); + job.setUserId(QStringLiteral("dummyUserId")); + const auto restApiMethod = new RocketChatRestApi::RestApiMethod; + restApiMethod->setServerUrl(QStringLiteral("http://localhost:3000")); + job.setRestApiMethod(restApiMethod); + QVERIFY(!job.canStart()); + + job.setRoomId(QStringLiteral("room123")); + QVERIFY(!job.canStart()); + + QVector keys = {{QStringLiteral("userA"), QStringLiteral("base64keyA")}}; + job.setKeys(keys); + QVERIFY(job.canStart()); +} + +#include "sessionkeydistributiontest.moc" \ No newline at end of file diff --git a/src/core/autotests/sessionkeydistributiontest.h b/src/core/autotests/sessionkeydistributiontest.h new file mode 100644 index 0000000000..19a10f96c0 --- /dev/null +++ b/src/core/autotests/sessionkeydistributiontest.h @@ -0,0 +1,49 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#pragma once +#include + +/** + * @class SessionKeyDistributionTest + * @brief Autotest for Rocket.Chat E2EE session key distribution and acceptance/rejection flows. + * + * This test simulates two users in an end-to-end encrypted room: + * + * - User1: + * - Receives the suggested group (session) key. + * - Decrypts it with their private key. + * - Accepts it using AcceptSuggestedGroupKeyJob (acceptJob1). + * + * - User2: + * - Receives the suggested group (session) key. + * - Decrypts it with their private key. + * - Rejects it using RejectSuggestedGroupKeyJob (rejectJob2). + * + * The test verifies: + * - Correct encryption and decryption of the session key for both users. + * - Proper API communication for distributing, accepting, and rejecting session keys. + * - That only users with the correct private key can decrypt the session key. + * - That the session key is correctly assigned or rejected in the users’ room subscriptions. + * + * + * * Prerequisites: + * + * - The .env file must contain credentials for at least two users (USERNAME1, PASSWORD1, USERNAME2, PASSWORD2). + * + * - The test room must exist and be accessible by both users. + */ +class SessionKeyDistributionTest : public QObject +{ + Q_OBJECT +public: + explicit SessionKeyDistributionTest(QObject *parent = nullptr); + ~SessionKeyDistributionTest() override = default; +private Q_SLOTS: + // void testSessionKeyDistribution(); + void testJsonPayload(); + void testCanStartValidation(); +}; \ No newline at end of file diff --git a/src/core/autotests/sessionkeytest.cpp b/src/core/autotests/sessionkeytest.cpp new file mode 100644 index 0000000000..77c6615448 --- /dev/null +++ b/src/core/autotests/sessionkeytest.cpp @@ -0,0 +1,46 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "sessionkeytest.h" +#include "encryption/encryptionutils.h" +#include + +QTEST_GUILESS_MAIN(SessionKeyTest) +SessionKeyTest::SessionKeyTest(QObject *parent) + : QObject(parent) +{ +} + +void SessionKeyTest::sessionKeyGenerationTest() +{ + QVERIFY(!(EncryptionUtils::generateSessionKey().isEmpty())); +} + +/** + * @brief Tests the encryption and decryption of the session key. + * + * This test verifies that a randomly generated 128-bit (16 bytes) AES session key, + * when encrypted with an RSA public key and then decrypted with the corresponding + * RSA private key, results in the original session key. + */ +void SessionKeyTest::sessionKeyEncryptionDecryptionTest() +{ + QByteArray sessionKey; + QByteArray encryptedSessionKey; + QByteArray decryptedSessionKey; + auto rsaKeyPair = EncryptionUtils::generateRSAKey(); + auto privateKey = rsaKeyPair.privateKey; + auto publicKey = rsaKeyPair.publicKey; + + for (int i = 0; i <= 10; i++) { + sessionKey = EncryptionUtils::generateSessionKey(); + encryptedSessionKey = EncryptionUtils::encryptSessionKey(sessionKey, EncryptionUtils::publicKeyFromPEM(publicKey)); + decryptedSessionKey = EncryptionUtils::decryptSessionKey(encryptedSessionKey, EncryptionUtils::privateKeyFromPEM(privateKey)); + QVERIFY(sessionKey == decryptedSessionKey); + } +} + +#include "moc_sessionkeytest.cpp" \ No newline at end of file diff --git a/src/core/autotests/sessionkeytest.h b/src/core/autotests/sessionkeytest.h new file mode 100644 index 0000000000..fc04d9ec2f --- /dev/null +++ b/src/core/autotests/sessionkeytest.h @@ -0,0 +1,21 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#pragma once + +#include + +class SessionKeyTest : public QObject +{ + Q_OBJECT +public: + explicit SessionKeyTest(QObject *parent = nullptr); + ~SessionKeyTest() override = default; + +private Q_SLOTS: + void sessionKeyGenerationTest(); + void sessionKeyEncryptionDecryptionTest(); +}; diff --git a/src/core/autotests/uploaddownloadrsakeypairtest.cpp b/src/core/autotests/uploaddownloadrsakeypairtest.cpp new file mode 100644 index 0000000000..5d2090981e --- /dev/null +++ b/src/core/autotests/uploaddownloadrsakeypairtest.cpp @@ -0,0 +1,48 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "uploaddownloadrsakeypair.h" +#include "loginmanager.h" +#include "uploaddownloadrsakeypairtest.h" +#include +#include +#include +#include + +QTEST_GUILESS_MAIN(UploadDownloadRsaKeyPairTest) + +void UploadDownloadRsaKeyPairTest::uploadDownloadCompare() +{ + const auto app = QCoreApplication::instance(); + const auto loginManager = new LoginManager(app); + const auto networkManager = new QNetworkAccessManager(app); + const auto url = QStringLiteral("http://localhost:3000"); + const auto password = QStringLiteral("mypassword123"); + bool testPassed = false; + + QObject::connect(loginManager, &LoginManager::loginSucceeded, this, [=, &testPassed](const QString &authToken, const QString &userId) { + qDebug() << "Login succeeded! authToken:" << authToken << "userId:" << userId; + uploadKeys(authToken, url, userId, password, networkManager, [=, &testPassed](const QString &message, const EncryptionUtils::RSAKeyPair &keypair) { + downloadKeys(authToken, url, userId, password, networkManager, [=, &testPassed](const QString &publicKey, const QString &decryptedPrivateKey) { + QCOMPARE(publicKey, QString::fromUtf8(keypair.publicKey)); + QCOMPARE(decryptedPrivateKey, QString::fromUtf8(keypair.privateKey)); + testPassed = true; + app->quit(); + }); + }); + }); + + QObject::connect(loginManager, &LoginManager::loginFailed, this, [&](const QString &err) { + QFAIL(qPrintable(QStringLiteral("Login failed: %1").arg(err))); + app->quit(); + }); + + loginManager->login(url, networkManager, 0); + app->exec(); + + QVERIFY(testPassed); +} + +#include "uploaddownloadrsakeypairtest.moc" diff --git a/src/core/autotests/uploaddownloadrsakeypairtest.h b/src/core/autotests/uploaddownloadrsakeypairtest.h new file mode 100644 index 0000000000..e6368a67a9 --- /dev/null +++ b/src/core/autotests/uploaddownloadrsakeypairtest.h @@ -0,0 +1,15 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#pragma once + +#include + +class UploadDownloadRsaKeyPairTest : public QObject +{ + Q_OBJECT +private Q_SLOTS: + void uploadDownloadCompare(); +}; \ No newline at end of file diff --git a/src/core/encryption/encryptionutils.cpp b/src/core/encryption/encryptionutils.cpp index d518ce6c22..728332bcc4 100644 --- a/src/core/encryption/encryptionutils.cpp +++ b/src/core/encryption/encryptionutils.cpp @@ -1,6 +1,7 @@ /* SPDX-FileCopyrightText: 2024-2025 Laurent Montel SPDX-FileCopyrightText: 2025 Andro Ranogajec + SPDX-License-Identifier: GPL-2.0-or-later */ @@ -10,31 +11,56 @@ // https://docs.rocket.chat/customer-center/security-center/end-to-end-encryption-specifications #include +#include #include #include #include using namespace Qt::Literals::StringLiterals; -QByteArray EncryptionUtils::exportJWKKey(RSA *rsaKey) -{ -#if 0 - code javascript - const key = await crypto.subtle.generateKey( - { name: 'AES-CBC', length: 256 }, - true, - ['encrypt', 'decrypt'] - ); - - const jwkKey = await exportJWKKey(key); - console.log(jwkKey); - -#endif +/** + * @brief Exports an RSA public key in JWK (JSON Web Key) format. + * + * This function extracts the modulus and public exponent from the given OpenSSL RSA key, + * encodes them using base64url (without padding), and constructs a JWK-compliant JSON object. + * The resulting JSON contains all fields required for interoperability with the Web Crypto API, + * and is returned as a compact UTF-8 encoded QByteArray. + * + * @param rsaKey Pointer to the OpenSSL RSA key. + * @return A QByteArray containing the JWK JSON representation of the public key, + * or an empty QByteArray on error. + * + * Example output: + * + * { + * "kty": "RSA", + * + * "n": "", + * + * "e": "", + * + * "alg": "RSA-OAEP-256", + * + * "key_ops": ["encrypt"], + * + * "ext": true + * } + * + * General steps of encoding/decoding for E2EE of the RSA public key part: + * + * use generateRsaKey() => QByteArray(PEM) + * + * use publicKeyFromPEM() => RSA(QByteArray(PEM)) + * + * use exportJWKPublicKey() => JWK(RSA) + */ +QByteArray EncryptionUtils::exportJWKPublicKey(RSA *rsaKey) +{ const BIGNUM *n, *e, *d; RSA_get0_key(rsaKey, &n, &e, &d); if (!n || !e) { - qCWarning(RUQOLA_ENCRYPTION_LOG) << " Impossible to get RSA"; + qCWarning(RUQOLA_ENCRYPTION_LOG) << "Impossible to get RSA"; return {}; } @@ -48,22 +74,46 @@ QByteArray EncryptionUtils::exportJWKKey(RSA *rsaKey) const QString eBase64Url = QString::fromLatin1(eBytes.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); QJsonObject jwkObj; - jwkObj["kty"_L1] = "RSA"_L1; - jwkObj["n"_L1] = nBase64Url; - jwkObj["e"_L1] = eBase64Url; + jwkObj[QStringLiteral("kty")] = QStringLiteral("RSA"); + jwkObj[QStringLiteral("n")] = nBase64Url; + jwkObj[QStringLiteral("e")] = eBase64Url; + jwkObj[QStringLiteral("alg")] = QStringLiteral("RSA-OAEP-256"); + jwkObj[QStringLiteral("key_ops")] = QJsonArray() << QStringLiteral("encrypt"); + jwkObj[QStringLiteral("ext")] = true; + + QJsonDocument doc(jwkObj); + return doc.toJson(QJsonDocument::Compact); +} + +QByteArray EncryptionUtils::exportJWKEncryptedPrivateKey(const QByteArray &encryptedPrivateKey) +{ + QJsonObject jwkObj; + jwkObj[QStringLiteral("kty")] = QStringLiteral("RSA"); + jwkObj[QStringLiteral("alg")] = QStringLiteral("RSA-OAEP-256"); + jwkObj[QStringLiteral("key_ops")] = QJsonArray() << QStringLiteral("decrypt"); + jwkObj[QStringLiteral("ext")] = true; + + // Store the encrypted private key as base64url + const QString ePrivKeyBase64Url = QString::fromLatin1(encryptedPrivateKey.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); + jwkObj[QStringLiteral("RSA-EPrivKey")] = ePrivKeyBase64Url; QJsonDocument doc(jwkObj); return doc.toJson(QJsonDocument::Compact); } -void EncryptionUtils::generateRSAKey() +EncryptionUtils::RSAKeyPair EncryptionUtils::generateRSAKey() { + RSAKeyPair keyPair; + int ret = 0; RSA *rsa = nullptr; BIGNUM *bne = nullptr; BIO *bp_public = nullptr; BIO *bp_private = nullptr; + BIO *pubBio = BIO_new(BIO_s_mem()); + BIO *privBio = BIO_new(BIO_s_mem()); + int bits = 2048; unsigned long e = RSA_F4; // équivalent à 0x10001 @@ -71,17 +121,17 @@ void EncryptionUtils::generateRSAKey() ret = BN_set_word(bne, e); if (ret != 1) { qCWarning(RUQOLA_ENCRYPTION_LOG) << "Error when generating exponent"; - return; + return {}; } rsa = RSA_new(); ret = RSA_generate_key_ex(rsa, bits, bne, nullptr); if (ret != 1) { qCWarning(RUQOLA_ENCRYPTION_LOG) << "Error during generate key"; - return; + return {}; } - bp_public = BIO_new_file("public_key.pem", "w+"); + /* bp_public = BIO_new_file("public_key.pem", "w+"); ret = PEM_write_bio_RSAPublicKey(bp_public, rsa); if (ret != 1) { qCWarning(RUQOLA_ENCRYPTION_LOG) << "Error when saving public key"; @@ -93,40 +143,115 @@ void EncryptionUtils::generateRSAKey() if (ret != 1) { qCWarning(RUQOLA_ENCRYPTION_LOG) << "Error when saving private key"; return; - } + } */ + + PEM_write_bio_RSA_PUBKEY(pubBio, rsa); + PEM_write_bio_RSAPrivateKey(privBio, rsa, nullptr, nullptr, 0, nullptr, nullptr); + + BUF_MEM *pubBuf = nullptr; + BUF_MEM *privBuf = nullptr; + + BIO_get_mem_ptr(pubBio, &pubBuf); + BIO_get_mem_ptr(privBio, &privBuf); + + keyPair.publicKey = QByteArray(pubBuf->data, pubBuf->length); + keyPair.privateKey = QByteArray(privBuf->data, privBuf->length); // Libérer la mémoire - BIO_free_all(bp_public); - BIO_free_all(bp_private); + // BIO_free_all(bp_public); + // BIO_free_all(bp_private); + BIO_free_all(pubBio); + BIO_free_all(privBio); RSA_free(rsa); BN_free(bne); + + return keyPair; } -QString EncryptionUtils::encodePrivateKey(const QString &privateKey, const QString &password, const QString &userId) +QByteArray EncryptionUtils::encryptPrivateKey(const QByteArray &privateKey, const QByteArray &masterKey) { - const QByteArray masterKey = getMasterKey(password, userId); - return {}; + if (privateKey.isEmpty()) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "Private key is empty"; + return {}; + } + + if (masterKey.isEmpty()) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "Master key is empty"; + return {}; + } + + const QByteArray iv = generateRandomIV(16); + const QByteArray ciphertext = encryptAES_CBC_256(privateKey, masterKey, iv); + + if (ciphertext.isEmpty()) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "Encryption of the private key failed, cipherText is empty"; + return {}; + } + + QByteArray encrypted; + encrypted.append(iv); + encrypted.append(ciphertext); + + return encrypted; } -QByteArray EncryptionUtils::getMasterKey(const QString &password, const QString &userId) +QByteArray EncryptionUtils::decryptPrivateKey(const QByteArray &encryptedPrivateKey, const QByteArray &masterKey) { - if (password.isEmpty()) { - qCWarning(RUQOLA_ENCRYPTION_LOG) << "Password can't be null. It's a bug"; + if (encryptedPrivateKey.isEmpty()) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "Encrypted private key is empty"; + return {}; + } + + if (masterKey.isEmpty()) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "Master key is empty"; + return {}; + } + + const QByteArray iv = encryptedPrivateKey.left(16); + const QByteArray cipherText = encryptedPrivateKey.mid(16); + + if (iv.isEmpty()) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "Decryption of the private key failed, 'iv' is empty"; + return {}; + } + if (cipherText.isEmpty()) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "Decryption of the private key failed, 'cipherText' is empty"; return {}; } - if (userId.isEmpty()) { - qCWarning(RUQOLA_ENCRYPTION_LOG) << "UserId can't be null. It's a bug"; + const QByteArray plainText = decryptAES_CBC_256(cipherText, masterKey, iv); + + if (plainText.isEmpty()) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "Decryption of the cipherText failed, plainText is empty"; return {}; } + return plainText; +} - const QByteArray baseKey = importRawKey(password.toUtf8(), userId.toUtf8(), 1000); - if (baseKey.isEmpty()) { - qCWarning(RUQOLA_ENCRYPTION_LOG) << "Failed to derive base key from password!"; +/** + * @brief Derives the master key from the user's password and user ID. + * + * This function uses a password-based key derivation function (PBKDF2) to generate + * a 256-bit (32-byte) AES master key from the provided password and user ID. + * The master key is used to encrypt and decrypt the user's private RSA key. + * + * @param password The user's E2EE password. + * @param salt user's unique identifier, sometimes called pepper if its constant. + * @return A 32-byte (256-bit) master key as a QByteArray, or an empty QByteArray on failure. + */ +QByteArray EncryptionUtils::getMasterKey(const QString &password, const QString &salt) +{ + if (password.isEmpty()) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "Password can't be null. It's a bug"; return {}; } - const QByteArray masterKey = deriveKey(userId.toUtf8(), baseKey, 1000, 32); + if (salt.isEmpty()) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "Salt(userId) can't be null. It's a bug"; + return {}; + } + + const QByteArray masterKey = deriveKey(salt.toUtf8(), password.toUtf8(), 1000, 32); if (masterKey.isEmpty()) { qCWarning(RUQOLA_ENCRYPTION_LOG) << "Master key derivation failed!"; return {}; @@ -162,12 +287,212 @@ QByteArray EncryptionUtils::getMasterKey(const QString &password, const QString #endif } -QByteArray EncryptionUtils::encryptAES_CBC(const QByteArray &data, const QByteArray &key, const QByteArray &iv) +/** + * @brief Generates a random 16-byte (128-bit) session key for AES encryption. + * + * @return A QByteArray containing 16 random bytes suitable for use as an AES-128 session key. + */ +QByteArray EncryptionUtils::generateSessionKey() +{ + return generateRandomIV(16); +} + +/** + * @brief Converts public key from QByteArray to RSA. + * @param QByteArray &pem + * + */ +RSA *EncryptionUtils::publicKeyFromPEM(const QByteArray &pem) +{ + BIO *bio = BIO_new_mem_buf(pem.constData(), pem.size()); + if (!bio) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "BIO_new_mem_buf failed!"; + return nullptr; + } + + RSA *rsa = PEM_read_bio_RSA_PUBKEY(bio, nullptr, nullptr, nullptr); + if (!rsa) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "PEM_read_bio_RSA_PUBKEY failed!"; + return nullptr; + } + + BIO_free(bio); + return rsa; +} + +/** + * @brief Converts private key from QByteArray to RSA. + * @param QByteArray &pem + * + */ +RSA *EncryptionUtils::privateKeyFromPEM(const QByteArray &pem) +{ + BIO *bio = BIO_new_mem_buf(pem.constData(), pem.size()); + if (!bio) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "BIO_new_mem_buf failed!"; + return nullptr; + } + + RSA *rsa = PEM_read_bio_RSAPrivateKey(bio, nullptr, nullptr, nullptr); + if (!rsa) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "PEM_read_bio_RSAPrivateKey failed!"; + BIO_free(bio); + return nullptr; + } + + BIO_free(bio); + return rsa; +} + +QByteArray EncryptionUtils::encryptSessionKey(const QByteArray &sessionKey, RSA *publicKey) +{ + QByteArray encryptedSessionKey(RSA_size(publicKey), 0); + int bytes = RSA_public_encrypt(sessionKey.size(), + reinterpret_cast(sessionKey.constData()), + reinterpret_cast(encryptedSessionKey.data()), + publicKey, + RSA_PKCS1_OAEP_PADDING); + if (bytes == -1) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "Session key encryption failed!"; + return {}; + } + encryptedSessionKey.resize(bytes); + return encryptedSessionKey; +} + +QByteArray EncryptionUtils::decryptSessionKey(const QByteArray &encryptedSessionKey, RSA *privateKey) +{ + QByteArray decryptedSessionKey(RSA_size(privateKey), 0); + int bytes = RSA_private_decrypt(encryptedSessionKey.size(), + reinterpret_cast(encryptedSessionKey.constData()), + reinterpret_cast(decryptedSessionKey.data()), + privateKey, + RSA_PKCS1_OAEP_PADDING); + if (bytes == -1) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "Session key decryption failed!"; + return {}; + } + decryptedSessionKey.resize(bytes); + return decryptedSessionKey; +} + +/** + * @brief Encrypts a message using AES-128-CBC. + * @param plainText The message to encrypt. + * @param sessionKey The 16-byte session key. + * @return The IV prepended to the ciphertext. + */ +QByteArray EncryptionUtils::encryptMessage(const QByteArray &plainText, const QByteArray &sessionKey) +{ + if (plainText.isEmpty()) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "QByteArray EncryptionUtils::encryptMessage, plaintext is empty!"; + return {}; + } + if (sessionKey.isEmpty()) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "QByteArray EncryptionUtils::encryptMessage, session key is empty!"; + return {}; + } + + QByteArray iv = generateRandomIV(16); + QByteArray cipherText = encryptAES_CBC_128(plainText, sessionKey, iv); + + if (cipherText.isEmpty()) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "QByteArray EncryptionUtils::encryptMessage, message encryption failed, cipher text is empty!"; + return {}; + } + + QByteArray result; + result.append(iv); + result.append(cipherText); + return result; +} + +/** + * @brief Decrypts a message using AES-128-CBC. + * @param encrypted The message to decrypt. + * @param sessionKey The 16-byte session key. + * @return The decrypted message. + */ +QByteArray EncryptionUtils::decryptMessage(const QByteArray &encrypted, const QByteArray &sessionKey) +{ + if (encrypted.isEmpty()) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "QByteArray EncryptionUtils::decryptMessage, encrypted message is empty!"; + return {}; + } + if (sessionKey.isEmpty()) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "QByteArray EncryptionUtils::decryptMessage, session key is empty!"; + return {}; + } + + const QByteArray iv = encrypted.left(16); + const QByteArray cipherText = encrypted.mid(16); + + qDebug() << cipherText << "QByteArray cipherText = encrypted.mid(16)"; + + const QByteArray plainText = decryptAES_CBC_128(cipherText, sessionKey, iv); + + qDebug() << plainText << "QByteArray plainText = decryptAES_CBC_128(cipherText, sessionKey, iv);"; + + if (plainText.isEmpty()) { + qCWarning(RUQOLA_ENCRYPTION_LOG) << "QByteArray EncryptionUtils::decryptMessage, message decryption failed, plain text is empty"; + return {}; + } + + return plainText; +} + +QByteArray EncryptionUtils::decryptAES_CBC_256(const QByteArray &data, const QByteArray &key, const QByteArray &iv) +{ + EVP_CIPHER_CTX *ctx; + int len; + int plaintext_len; + + QByteArray plaintext(data.size(), 0); + + ctx = EVP_CIPHER_CTX_new(); + if (!ctx) + return {}; + + if (1 + != EVP_DecryptInit_ex(ctx, + EVP_aes_256_cbc(), + nullptr, + reinterpret_cast(key.data()), + reinterpret_cast(iv.data()))) { + EVP_CIPHER_CTX_free(ctx); + return {}; + } + + if (1 + != EVP_DecryptUpdate(ctx, + reinterpret_cast(plaintext.data()), + &len, + reinterpret_cast(data.data()), + data.size())) { + EVP_CIPHER_CTX_free(ctx); + return {}; + } + plaintext_len = len; + + if (1 != EVP_DecryptFinal_ex(ctx, reinterpret_cast(plaintext.data()) + len, &len)) { + EVP_CIPHER_CTX_free(ctx); + return {}; + } + plaintext_len += len; + plaintext.resize(plaintext_len); + + EVP_CIPHER_CTX_free(ctx); + return plaintext; +} + +QByteArray EncryptionUtils::encryptAES_CBC_256(const QByteArray &data, const QByteArray &key, const QByteArray &iv) { EVP_CIPHER_CTX *ctx; int len; int ciphertext_len; - unsigned char ciphertext[128]; + + int max_out_len = data.size() + EVP_CIPHER_block_size(EVP_aes_256_cbc()); + QByteArray cipherText(max_out_len, 0); if (!(ctx = EVP_CIPHER_CTX_new())) return {}; @@ -177,20 +502,118 @@ QByteArray EncryptionUtils::encryptAES_CBC(const QByteArray &data, const QByteAr EVP_aes_256_cbc(), NULL, reinterpret_cast(key.data()), - reinterpret_cast(iv.data()))) + reinterpret_cast(iv.data()))) { + EVP_CIPHER_CTX_free(ctx); + return {}; + } + + if (1 + != EVP_EncryptUpdate(ctx, + reinterpret_cast(cipherText.data()), + &len, + reinterpret_cast(data.data()), + data.size())) { + EVP_CIPHER_CTX_free(ctx); + return {}; + } + ciphertext_len = len; + + if (1 != EVP_EncryptFinal_ex(ctx, reinterpret_cast(cipherText.data()) + len, &len)) { + EVP_CIPHER_CTX_free(ctx); + return {}; + } + ciphertext_len += len; + cipherText.resize(ciphertext_len); + EVP_CIPHER_CTX_free(ctx); + + return cipherText; +} + +QByteArray EncryptionUtils::encryptAES_CBC_128(const QByteArray &data, const QByteArray &key, const QByteArray &iv) +{ + EVP_CIPHER_CTX *ctx; + int len; + int ciphertext_len; + + int max_out_len = data.size() + EVP_CIPHER_block_size(EVP_aes_128_cbc()); + QByteArray cipherText(max_out_len, 0); + + if (!(ctx = EVP_CIPHER_CTX_new())) + return {}; + + if (1 + != EVP_EncryptInit_ex(ctx, + EVP_aes_128_cbc(), + NULL, + reinterpret_cast(key.data()), + reinterpret_cast(iv.data()))) { + EVP_CIPHER_CTX_free(ctx); return {}; + } - if (1 != EVP_EncryptUpdate(ctx, ciphertext, &len, reinterpret_cast(data.data()), data.size())) + if (1 + != EVP_EncryptUpdate(ctx, + reinterpret_cast(cipherText.data()), + &len, + reinterpret_cast(data.data()), + data.size())) { + EVP_CIPHER_CTX_free(ctx); return {}; + } ciphertext_len = len; - if (1 != EVP_EncryptFinal_ex(ctx, ciphertext + len, &len)) + if (1 != EVP_EncryptFinal_ex(ctx, reinterpret_cast(cipherText.data()) + len, &len)) { + EVP_CIPHER_CTX_free(ctx); return {}; + } ciphertext_len += len; + cipherText.resize(ciphertext_len); + EVP_CIPHER_CTX_free(ctx); + return cipherText; +} + +QByteArray EncryptionUtils::decryptAES_CBC_128(const QByteArray &cipherText, const QByteArray &key, const QByteArray &iv) +{ + EVP_CIPHER_CTX *ctx; + int len; + int plainTextLen; + + QByteArray plainText(cipherText.size(), 0); + + if (!(ctx = EVP_CIPHER_CTX_new())) + return {}; + + if (1 + != EVP_DecryptInit_ex(ctx, + EVP_aes_128_cbc(), + NULL, + reinterpret_cast(key.data()), + reinterpret_cast(iv.data()))) { + EVP_CIPHER_CTX_free(ctx); + return {}; + } + + if (1 + != EVP_DecryptUpdate(ctx, + reinterpret_cast(plainText.data()), + &len, + reinterpret_cast(cipherText.data()), + cipherText.size())) { + EVP_CIPHER_CTX_free(ctx); + return {}; + } + plainTextLen = len; + + if (1 != EVP_DecryptFinal_ex(ctx, reinterpret_cast(plainText.data()) + len, &len)) { + EVP_CIPHER_CTX_free(ctx); + return {}; + } + plainTextLen += len; + plainText.resize(plainTextLen); EVP_CIPHER_CTX_free(ctx); - return QByteArray(reinterpret_cast(ciphertext), ciphertext_len); + return plainText; } QByteArray EncryptionUtils::generateRandomIV(int size) @@ -219,6 +642,18 @@ QString EncryptionUtils::generateRandomText(int length) return randomText; } +/** + * @brief Derives a cryptographic key using PBKDF2 (Password-Based Key Derivation Function 2). + * + * This function uses OpenSSL's PKCS5_PBKDF2_HMAC to generate a key from a password and a salt. + * It is typically used to derive an AES key from a user's password and unique identifier (salt). + * + * @param pepper The constant salt value (user's id). + * @param baseKey The base key ( user's password). + * @param iterations Number of PBKDF2 iterations (higher is more secure but slower). + * @param keyLength Desired length of the derived key in bytes (e.g., 32 for AES-256). + * @return The derived key as a QByteArray, or an empty QByteArray on failure. + */ QByteArray EncryptionUtils::deriveKey(const QByteArray &salt, const QByteArray &baseKey, int iterations, int keyLength) { QByteArray derivedKey(keyLength, 0); // Allocate memory for the derived key @@ -242,6 +677,45 @@ QByteArray EncryptionUtils::deriveKey(const QByteArray &salt, const QByteArray & return derivedKey; } +/* QJsonObject EncryptionUtils::exportPublicKeyJWK(const RSA *rsaKey) +{ + const BIGNUM *n, *e; + RSA_get0_key(rsaKey, &n, &e, nullptr); + + auto b64url = [](const BIGNUM *bn) { + QByteArray bytes(BN_num_bytes(bn), 0); + BN_bn2bin(bn, reinterpret_cast(bytes.data())); + return QString::fromLatin1(bytes.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); + }; + + QJsonObject jwk; + jwk["kty"] = "RSA"; + jwk["n"] = b64url(n); + jwk["e"] = b64url(e); + jwk["alg"] = "RSA-OAEP-256"; + jwk["key_ops"] = QJsonArray{"encrypt"}; + jwk["ext"] = true; + return jwk; +} */ + +/* QJsonObject EncryptionUtils::exportEncryptedPrivateKeyJWK(const QByteArray &encryptedPrivateKey) +{ + QJsonObject jwk; + jwk["kty"] = "oct"; // "oct" for a symmetric (opaque) blob + jwk["alg"] = "A256CBC"; // or whatever encryption you used + jwk["ciphertext"] = QString::fromLatin1(encryptedPrivateKey.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); + jwk["ext"] = true; + return jwk; +} + +QJsonObject EncryptionUtils::exportKeyPairJWK(RSA *rsaKey, const QByteArray &encryptedPrivateKey) +{ + QJsonObject bundle; + bundle["public_key"] = exportPublicKeyJWK(rsaKey); + bundle["encrypted_private_key"] = exportEncryptedPrivateKeyJWK(encryptedPrivateKey); + return bundle; +} */ + #if 0 QByteArray aesEncrypt(const QByteArray& plaintext, const QByteArray& key, const QByteArray& iv) { EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new(); diff --git a/src/core/encryption/encryptionutils.h b/src/core/encryption/encryptionutils.h index 9a17a58521..a9005b700c 100644 --- a/src/core/encryption/encryptionutils.h +++ b/src/core/encryption/encryptionutils.h @@ -1,10 +1,11 @@ /* SPDX-FileCopyrightText: 2024-2025 Laurent Montel + SPDX-FileCopyrightText: 2025 Andro Ranogajec SPDX-License-Identifier: GPL-2.0-or-later */ #pragma once -#include "libruqola_private_export.h" +#include "libruqolacore_export.h" #include extern "C" { #include @@ -12,29 +13,46 @@ extern "C" { #include #include } + namespace EncryptionUtils { -struct LIBRUQOLACORE_TESTS_EXPORT EncryptionInfo { +struct LIBRUQOLACORE_EXPORT EncryptionInfo { QByteArray vector; QByteArray encryptedData; [[nodiscard]] bool isValid() const; [[nodiscard]] bool operator==(const EncryptionInfo &other) const; }; +struct RSAKeyPair { + QByteArray publicKey; + QByteArray privateKey; +}; -[[nodiscard]] LIBRUQOLACORE_TESTS_EXPORT QByteArray exportJWKKey(RSA *rsaKey); -LIBRUQOLACORE_TESTS_EXPORT void generateRSAKey(); -[[nodiscard]] LIBRUQOLACORE_TESTS_EXPORT QString encodePrivateKey(const QString &privateKey, const QString &password, const QString &userId); -[[nodiscard]] LIBRUQOLACORE_TESTS_EXPORT QByteArray getMasterKey(const QString &password, const QString &userId); -[[nodiscard]] LIBRUQOLACORE_TESTS_EXPORT QByteArray encryptAES_CBC(const QByteArray &data, const QByteArray &key, const QByteArray &iv); -[[nodiscard]] LIBRUQOLACORE_TESTS_EXPORT QByteArray deriveKey(const QByteArray &salt, const QByteArray &baseKey, int iterations = 1000, int keyLength = 32); -[[nodiscard]] LIBRUQOLACORE_TESTS_EXPORT QByteArray generateRandomIV(int size); -[[nodiscard]] LIBRUQOLACORE_TESTS_EXPORT QString generateRandomText(int size); -[[nodiscard]] LIBRUQOLACORE_TESTS_EXPORT EncryptionUtils::EncryptionInfo splitVectorAndEcryptedData(const QByteArray &cipherText); -[[nodiscard]] LIBRUQOLACORE_TESTS_EXPORT QByteArray joinVectorAndEcryptedData(const EncryptionUtils::EncryptionInfo &info); -[[nodiscard]] LIBRUQOLACORE_TESTS_EXPORT QVector toArrayBuffer(const QByteArray &ba); -[[nodiscard]] LIBRUQOLACORE_TESTS_EXPORT QByteArray importRawKey(const QByteArray &keyData, const QByteArray &salt, int iterations); -LIBRUQOLACORE_TESTS_EXPORT void importRSAKey(); -LIBRUQOLACORE_TESTS_EXPORT void importAESKey(); -[[nodiscard]] LIBRUQOLACORE_TESTS_EXPORT QString generateRandomPassword(); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray exportJWKPublicKey(RSA *rsaKey); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray exportJWKEncryptedPrivateKey(const QByteArray &encryptedPrivateKey); +[[nodiscard]] LIBRUQOLACORE_EXPORT RSAKeyPair generateRSAKey(); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray encryptPrivateKey(const QByteArray &privateKey, const QByteArray &masterKey); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray decryptPrivateKey(const QByteArray &encryptedPrivateKey, const QByteArray &masterKey); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray getMasterKey(const QString &password, const QString &userId); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray encryptAES_CBC_256(const QByteArray &data, const QByteArray &key, const QByteArray &iv); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray decryptAES_CBC_256(const QByteArray &data, const QByteArray &key, const QByteArray &iv); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray encryptAES_CBC_128(const QByteArray &data, const QByteArray &key, const QByteArray &iv); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray decryptAES_CBC_128(const QByteArray &data, const QByteArray &key, const QByteArray &iv); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray encryptMessage(const QByteArray &plainText, const QByteArray &sessionKey); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray decryptMessage(const QByteArray &plainText, const QByteArray &sessionKey); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray deriveKey(const QByteArray &salt, const QByteArray &baseKey, int iterations = 1000, int keyLength = 32); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray generateRandomIV(int size); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray generateSessionKey(); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray encryptSessionKey(const QByteArray &sessionKey, RSA *publicKey); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray decryptSessionKey(const QByteArray &encryptedSessionKey, RSA *privateKey); +[[nodiscard]] LIBRUQOLACORE_EXPORT RSA *publicKeyFromPEM(const QByteArray &pem); +[[nodiscard]] LIBRUQOLACORE_EXPORT RSA *privateKeyFromPEM(const QByteArray &pem); +[[nodiscard]] LIBRUQOLACORE_EXPORT QString generateRandomText(int size); +[[nodiscard]] LIBRUQOLACORE_EXPORT EncryptionUtils::EncryptionInfo splitVectorAndEcryptedData(const QByteArray &cipherText); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray joinVectorAndEcryptedData(const EncryptionUtils::EncryptionInfo &info); +[[nodiscard]] LIBRUQOLACORE_EXPORT QVector toArrayBuffer(const QByteArray &ba); +[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray importRawKey(const QByteArray &keyData, const QByteArray &salt, int iterations); +LIBRUQOLACORE_EXPORT void importRSAKey(); +LIBRUQOLACORE_EXPORT void importAESKey(); +[[nodiscard]] LIBRUQOLACORE_EXPORT QString generateRandomPassword(); }; Q_DECLARE_TYPEINFO(EncryptionUtils::EncryptionInfo, Q_RELOCATABLE_TYPE); diff --git a/src/core/localdatabase/autotests/CMakeLists.txt b/src/core/localdatabase/autotests/CMakeLists.txt index f9b853d8f4..667ca09391 100644 --- a/src/core/localdatabase/autotests/CMakeLists.txt +++ b/src/core/localdatabase/autotests/CMakeLists.txt @@ -21,3 +21,7 @@ add_ruqola_localdatabase_test(globaldatabasetest.cpp) add_ruqola_localdatabase_test(localaccountdatabasetest.cpp) add_ruqola_localdatabase_test(localroomsdatabasetest.cpp) add_ruqola_localdatabase_test(localdatabasebasetest.cpp) + +if(USE_E2E_SUPPORT) + add_ruqola_localdatabase_test(e2ekeystoretest.cpp) +endif() \ No newline at end of file diff --git a/src/core/localdatabase/autotests/e2ekeystoretest.cpp b/src/core/localdatabase/autotests/e2ekeystoretest.cpp new file mode 100644 index 0000000000..a3a54e6d71 --- /dev/null +++ b/src/core/localdatabase/autotests/e2ekeystoretest.cpp @@ -0,0 +1,76 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "e2ekeystoretest.h" +#include "encryption/encryptionutils.h" +#include "localdatabase/e2ekeystore.h" +#include + +QTEST_GUILESS_MAIN(E2EKeyStoreTest) + +const auto testUser = QStringLiteral("testuser"); +const auto otherUser = QStringLiteral("otheruser"); + +void E2EKeyStoreTest::initTestCase() +{ + E2EKeyStore store; + store.deleteKey(testUser); + store.deleteKey(otherUser); +} + +void E2EKeyStoreTest::testSaveLoadDelete() +{ + E2EKeyStore store; + const auto userId = testUser; + const auto rsaKeyPair = EncryptionUtils::generateRSAKey(); + const auto priv = rsaKeyPair.publicKey; + const auto pub = rsaKeyPair.privateKey; + + QVERIFY(store.saveKey(userId, priv, pub)); + QVERIFY(store.hasKey(userId)); + + QByteArray loadedPriv, loadedPub; + QVERIFY(store.loadKey(userId, loadedPriv, loadedPub)); + QCOMPARE(loadedPriv, priv); + QCOMPARE(loadedPub, pub); + + QVERIFY(store.deleteKey(userId)); + QVERIFY(!store.hasKey(userId)); +} + +void E2EKeyStoreTest::testOverwrite() +{ + E2EKeyStore store; + const auto userId = testUser; + const auto rsaKeyPair1 = EncryptionUtils::generateRSAKey(); + const auto rsaKeyPair2 = EncryptionUtils::generateRSAKey(); + + const auto priv1 = rsaKeyPair1.privateKey; + const auto pub1 = rsaKeyPair1.publicKey; + const auto priv2 = rsaKeyPair2.privateKey; + const auto pub2 = rsaKeyPair2.publicKey; + + QVERIFY(store.saveKey(userId, priv1, pub1)); + QVERIFY(store.saveKey(userId, priv2, pub2)); + + QByteArray loadedPriv, loadedPub; + QVERIFY(store.loadKey(userId, loadedPriv, loadedPub)); + QCOMPARE(loadedPriv, priv2); + QCOMPARE(loadedPub, pub2); + + store.deleteKey(userId); +} + +void E2EKeyStoreTest::testNonexistentKey() +{ + E2EKeyStore store; + const auto userId = otherUser; + QByteArray priv, pub; + QVERIFY(!store.hasKey(userId)); + QVERIFY(!store.loadKey(userId, priv, pub)); + QVERIFY(store.deleteKey(userId)); +} + +#include "e2ekeystoretest.moc" \ No newline at end of file diff --git a/src/core/localdatabase/autotests/e2ekeystoretest.h b/src/core/localdatabase/autotests/e2ekeystoretest.h new file mode 100644 index 0000000000..75605a19a5 --- /dev/null +++ b/src/core/localdatabase/autotests/e2ekeystoretest.h @@ -0,0 +1,18 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#pragma once + +#include + +class E2EKeyStoreTest : public QObject +{ + Q_OBJECT +private Q_SLOTS: + void initTestCase(); + void testSaveLoadDelete(); + void testOverwrite(); + void testNonexistentKey(); +}; \ No newline at end of file diff --git a/src/core/localdatabase/e2ekeystore.cpp b/src/core/localdatabase/e2ekeystore.cpp new file mode 100644 index 0000000000..ef6a75932a --- /dev/null +++ b/src/core/localdatabase/e2ekeystore.cpp @@ -0,0 +1,87 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "e2ekeystore.h" +#include "localdatabaseutils.h" +#include "ruqola_database_debug.h" +#include +#include + +const char E2EKeyStore::s_schemaE2EKeyStore[] = "CREATE TABLE E2EKEYS (userId TEXT PRIMARY KEY NOT NULL, encryptedPrivateKey BLOB, publicKey BLOB)"; + +E2EKeyStore::E2EKeyStore() + : LocalDatabaseBase(LocalDatabaseUtils::localDatabasePath() + QStringLiteral("e2e/"), LocalDatabaseBase::DatabaseType::E2E) +{ +} + +E2EKeyStore::~E2EKeyStore() = default; + +QString E2EKeyStore::schemaDataBase() const +{ + return QString::fromLatin1(s_schemaE2EKeyStore); +} + +bool E2EKeyStore::saveKey(const QString &userId, const QByteArray &encryptedPrivateKey, const QByteArray &publicKey) +{ + QSqlDatabase db; + if (!initializeDataBase(userId, db)) { + return false; + } + QSqlQuery query(db); + query.prepare(QStringLiteral("INSERT OR REPLACE INTO E2EKEYS (userId, encryptedPrivateKey, publicKey) VALUES (?, ?, ?)")); + query.addBindValue(userId); + query.addBindValue(encryptedPrivateKey); + query.addBindValue(publicKey); + if (!query.exec()) { + qCWarning(RUQOLA_DATABASE_LOG) << "Couldn't insert-or-replace in E2EKEYS table" << db.databaseName() << query.lastError(); + return false; + } + return true; +} + +bool E2EKeyStore::loadKey(const QString &userId, QByteArray &encryptedPrivateKey, QByteArray &publicKey) +{ + QSqlDatabase db; + if (!initializeDataBase(userId, db)) { + return false; + } + QSqlQuery query(db); + query.prepare(QStringLiteral("SELECT encryptedPrivateKey, publicKey FROM E2EKEYS WHERE userId = ?")); + query.addBindValue(userId); + if (query.exec() && query.first()) { + encryptedPrivateKey = query.value(0).toByteArray(); + publicKey = query.value(1).toByteArray(); + return true; + } + return false; +} + +bool E2EKeyStore::deleteKey(const QString &userId) +{ + QSqlDatabase db; + if (!initializeDataBase(userId, db)) { + return false; + } + QSqlQuery query(db); + query.prepare(QStringLiteral("DELETE FROM E2EKEYS WHERE userId = ?")); + query.addBindValue(userId); + if (!query.exec()) { + qCWarning(RUQOLA_DATABASE_LOG) << "Couldn't delete from E2EKEYS table" << db.databaseName() << query.lastError(); + return false; + } + return true; +} + +bool E2EKeyStore::hasKey(const QString &userId) +{ + QSqlDatabase db; + if (!initializeDataBase(userId, db)) { + return false; + } + QSqlQuery query(db); + query.prepare(QStringLiteral("SELECT 1 FROM E2EKEYS WHERE userId = ?")); + query.addBindValue(userId); + return query.exec() && query.first(); +} \ No newline at end of file diff --git a/src/core/localdatabase/e2ekeystore.h b/src/core/localdatabase/e2ekeystore.h new file mode 100644 index 0000000000..9081b20040 --- /dev/null +++ b/src/core/localdatabase/e2ekeystore.h @@ -0,0 +1,26 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#pragma once + +#include "libruqolacore_export.h" +#include "localdatabasebase.h" + +class LIBRUQOLACORE_EXPORT E2EKeyStore : public LocalDatabaseBase +{ +public: + E2EKeyStore(); + ~E2EKeyStore() override; + + [[nodiscard]] bool saveKey(const QString &userId, const QByteArray &encryptedPrivateKey, const QByteArray &publicKey); + [[nodiscard]] bool loadKey(const QString &userId, QByteArray &encryptedPrivateKey, QByteArray &publicKey); + [[nodiscard]] bool deleteKey(const QString &userId); + [[nodiscard]] bool hasKey(const QString &userId); + + [[nodiscard]] QString schemaDataBase() const override; + +private: + static const char s_schemaE2EKeyStore[]; +}; \ No newline at end of file diff --git a/src/core/localdatabase/localdatabasebase.h b/src/core/localdatabase/localdatabasebase.h index 5beb47feb0..426e1d4efa 100644 --- a/src/core/localdatabase/localdatabasebase.h +++ b/src/core/localdatabase/localdatabasebase.h @@ -19,6 +19,7 @@ class LIBRUQOLACORE_EXPORT LocalDatabaseBase Message, Logger, Global, + E2E }; explicit LocalDatabaseBase(const QString &basePath, DatabaseType type); virtual ~LocalDatabaseBase(); diff --git a/src/rocketchatrestapi-qt/CMakeLists.txt b/src/rocketchatrestapi-qt/CMakeLists.txt index 41e18fa729..a656095d71 100644 --- a/src/rocketchatrestapi-qt/CMakeLists.txt +++ b/src/rocketchatrestapi-qt/CMakeLists.txt @@ -182,6 +182,8 @@ target_sources(librocketchatrestapi-qt PRIVATE e2e/acceptsuggestedgroupkeyjob.cpp e2e/acceptsuggestedgroupkeyjob.h + e2e/provideuserswithsuggestedgroupkeysjob.cpp + e2e/provideuserswithsuggestedgroupkeysjob.h e2e/rejectsuggestedgroupkeyjob.cpp e2e/rejectsuggestedgroupkeyjob.h e2e/resetroomkeyjob.cpp diff --git a/src/rocketchatrestapi-qt/e2e/provideuserswithsuggestedgroupkeysjob.cpp b/src/rocketchatrestapi-qt/e2e/provideuserswithsuggestedgroupkeysjob.cpp new file mode 100644 index 0000000000..00b80d15de --- /dev/null +++ b/src/rocketchatrestapi-qt/e2e/provideuserswithsuggestedgroupkeysjob.cpp @@ -0,0 +1,106 @@ +/* + SPDX-FileCopyrightText: 2025 Andor Ranogajec + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "provideuserswithsuggestedgroupkeysjob.h" +#include "restapimethod.h" +#include "rocketchatqtrestapi_debug.h" +#include +#include +#include +using namespace RocketChatRestApi; +using namespace Qt::Literals::StringLiterals; + +ProvideUsersWithSuggestedGroupKeysJob::ProvideUsersWithSuggestedGroupKeysJob(QObject *parent) + : RestApiAbstractJob(parent) +{ +} + +ProvideUsersWithSuggestedGroupKeysJob::~ProvideUsersWithSuggestedGroupKeysJob() = default; + +void ProvideUsersWithSuggestedGroupKeysJob::setRoomId(const QString &roomId) +{ + mRoomId = roomId; +} + +void ProvideUsersWithSuggestedGroupKeysJob::setKeys(const QVector &keys) +{ + mSuggestedGroupKeys = keys; +} + +QString ProvideUsersWithSuggestedGroupKeysJob::roomId() const +{ + return mRoomId; +} + +QVector ProvideUsersWithSuggestedGroupKeysJob::keys() const +{ + return mSuggestedGroupKeys; +} + +bool ProvideUsersWithSuggestedGroupKeysJob::requireHttpAuthentication() const +{ + return true; +} + +bool ProvideUsersWithSuggestedGroupKeysJob::start() +{ + if (!canStart()) { + deleteLater(); + return false; + } + addStartRestApiInfo("ProvideUsersWithSuggestedGroupKeysJob::start"); + submitPostRequest(json()); + return true; +} + +void ProvideUsersWithSuggestedGroupKeysJob::onPostRequestResponse(const QString &replyErrorString, const QJsonDocument &replyJson) +{ + const QJsonObject replyObject = replyJson.object(); + + if (replyObject["success"_L1].toBool()) { + addLoggerInfo("ProvideUsersWithSuggestedGroupKeysJob: success: "_ba + replyJson.toJson(QJsonDocument::Indented)); + Q_EMIT provideUsersWithSuggestedGroupKeysDone(replyObject); + } else { + emitFailedMessage(replyErrorString, replyObject); + addLoggerWarning("ProvideUsersWithSuggestedGroupKeysJob: Problem: "_ba + replyJson.toJson(QJsonDocument::Indented)); + } +} + +QNetworkRequest ProvideUsersWithSuggestedGroupKeysJob::request() const +{ + const QUrl url = mRestApiMethod->generateUrl(RestApiUtil::RestApiUrlType::E2EProvideUsersWithSuggestedGroupKeys); + QNetworkRequest req(url); + addAuthRawHeader(req); + addRequestAttribute(req); + return req; +} + +QJsonDocument ProvideUsersWithSuggestedGroupKeysJob::json() const +{ + QJsonObject obj; + obj["rid"_L1] = mRoomId; + QJsonArray keysArr; + for (const auto &k : mSuggestedGroupKeys) { + QJsonObject keyObj; + keyObj["userId"_L1] = k.userId; + keyObj["key"_L1] = k.encryptedKey; + keysArr.append(keyObj); + } + obj["keys"_L1] = keysArr; + return QJsonDocument(obj); +} + +bool ProvideUsersWithSuggestedGroupKeysJob::canStart() const +{ + if (!RestApiAbstractJob::canStart()) { + return false; + } + if (mRoomId.isEmpty() || mSuggestedGroupKeys.isEmpty()) { + qCWarning(ROCKETCHATQTRESTAPI_LOG) << "ProvideUsersWithSuggestedGroupKeysJob: roomId or keys is empty"; + return false; + } + return true; +} \ No newline at end of file diff --git a/src/rocketchatrestapi-qt/e2e/provideuserswithsuggestedgroupkeysjob.h b/src/rocketchatrestapi-qt/e2e/provideuserswithsuggestedgroupkeysjob.h new file mode 100644 index 0000000000..d3a098f30a --- /dev/null +++ b/src/rocketchatrestapi-qt/e2e/provideuserswithsuggestedgroupkeysjob.h @@ -0,0 +1,56 @@ +/* + SPDX-FileCopyrightText: 2025 Andor Ranogajec + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#pragma once + +#include "librocketchatrestapi-qt_export.h" +#include "restapiabstractjob.h" +#include +#include + +namespace RocketChatRestApi +{ + +/** + * QString userId; + * + *QString encryptedKey; + */ +struct SuggestedGroupKey { + QString userId; + QString encryptedKey; // base64 ???? +}; + +class LIBROCKETCHATRESTAPI_QT_EXPORT ProvideUsersWithSuggestedGroupKeysJob : public RestApiAbstractJob +{ + Q_OBJECT +public: + explicit ProvideUsersWithSuggestedGroupKeysJob(QObject *parent = nullptr); + ~ProvideUsersWithSuggestedGroupKeysJob() override; + + void setRoomId(const QString &roomId); + void setKeys(const QVector &keys); + + [[nodiscard]] QString roomId() const; + [[nodiscard]] QVector keys() const; + [[nodiscard]] QNetworkRequest request() const override; + [[nodiscard]] QJsonDocument json() const; + + [[nodiscard]] bool start() override; + [[nodiscard]] bool canStart() const override; + [[nodiscard]] bool requireHttpAuthentication() const override; + +protected: + void onPostRequestResponse(const QString &replyErrorString, const QJsonDocument &replyJson) override; + +Q_SIGNALS: + void provideUsersWithSuggestedGroupKeysDone(const QJsonObject &result); + +private: + QString mRoomId; + QVector mSuggestedGroupKeys; +}; +} \ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9875d661cd..08d41c9828 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,7 +13,7 @@ if(TEXT_CONVERTER_CMARK_SUPPORT) add_subdirectory(cmarktestgui) endif() if(OPTION_USE_E2E_SUPPORT) - add_subdirectory(encryptiontestgui) + add_subdirectory(encryptiontest) endif() add_subdirectory(passwordvalidategui) diff --git a/tests/encryptiontest/CMakeLists.txt b/tests/encryptiontest/CMakeLists.txt new file mode 100644 index 0000000000..1a8d1a9a63 --- /dev/null +++ b/tests/encryptiontest/CMakeLists.txt @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2024-2025 Laurent Montel , 2025 Andro Ranogajec +# SPDX-License-Identifier: BSD-3-Clause +find_package(OpenSSL REQUIRED) + +add_executable(encryptiontestgui) +target_sources(encryptiontestgui PRIVATE encryptiontestgui.h encryptiontestgui.cpp) +target_link_libraries(encryptiontestgui + Qt::Widgets + Qt::Gui + libruqolacore +) +set_target_properties(encryptiontestgui PROPERTIES DISABLE_PRECOMPILE_HEADERS ON) + +add_executable(encryptiontestcli + ${CMAKE_CURRENT_SOURCE_DIR}/encryptiontestcli.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/uploaddownloadrsakeypair.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/loginmanager.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/envutils.cpp +) +target_include_directories(encryptiontestcli PRIVATE + ${CMAKE_SOURCE_DIR}/src/core/encryption + ${CMAKE_BINARY_DIR}/src/core + ${CMAKE_SOURCE_DIR}/src/rocketchatrestapi-qt + ${CMAKE_CURRENT_SOURCE_DIR} +) +target_link_libraries(encryptiontestcli + librocketchatrestapi-qt + libruqolacore + Qt::Network + OpenSSL::Crypto +) diff --git a/tests/encryptiontest/PLEASEREADME.md b/tests/encryptiontest/PLEASEREADME.md new file mode 100644 index 0000000000..546716d956 --- /dev/null +++ b/tests/encryptiontest/PLEASEREADME.md @@ -0,0 +1,58 @@ + +# E2EE TEST GUI + +## Table of Contents + +- [Overview](#overview) +- [Features](#features) +- [Installation](#installation) +- [Usage](#usage) + +--- + +## Overview + +This test-gui provides a simple interface to experiment with: + +- Master key derivation using a password and userId as salt. +- RSA key pair generation. +- Encryption and decryption of private keys using the master key. +- Session key generation and encryption with RSA keys. +- Message encryption/decryption using a session key. +- Export/Import of public and encrypted private keys in **JWK** format. + +The GUI uses **QTextEdit** for input/output, **QPushButton** for triggering actions, +and leverages the `EncryptionUtils` library for all cryptographic operations. + +--- + +## Features + +- **Master Key Derivation**: Derive a symmetric master key from a password and user ID (used as salt). +- **RSA Key Pair**: Generate a new RSA public/private key pair. +- **Private Key Encryption**: Encrypt the private key using the derived master key. +- **Session Key**: Generate a random session key for encrypting messages. +- **Message Encryption/Decryption**: Encrypt or decrypt arbitrary messages using the session key. +- **Export Keys**: Export the public key (JWK format) and the encrypted private key (JWK format). +- **Reset**: Clear all keys, messages, and intermediate data. + +--- + +## Usage + +1. Launch the application. (if you new ./build/bin/encryptiontestgui, if on Windows don't forget XLaunch). +2. Derive a master key using your password and user ID (salt). +3. Generate an RSA key pair. +4. Encrypt the private key using the master key. +5. Generate a session key and encrypt it with the RSA public key. +6. Encrypt a message with the session key. +7. Decrypt the session key with the RSA private key. +8. Decrypt the message with the session key. +9. Export public and encrypted private keys in **JWK** format if needed. + +All outputs and results appear in the **Output** field. + + diff --git a/tests/encryptiontest/encryptiontestcli.cpp b/tests/encryptiontest/encryptiontestcli.cpp new file mode 100644 index 0000000000..9e395653bc --- /dev/null +++ b/tests/encryptiontest/encryptiontestcli.cpp @@ -0,0 +1,66 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +/* + +test my existance...of course within server, otherwise I still exist :) + +curl -X POST http://localhost:3000/api/v1/login \ + -H "Content-Type: application/json" \ + -d '{"user": "", "password": ""}' +*/ + +#include "e2e/fetchmykeysjob.h" +#include "e2e/setuserpublicandprivatekeysjob.h" +#include "encryptionutils.h" +#include "loginmanager.h" +#include "restapimethod.h" +#include "uploaddownloadrsakeypair.h" +#include +#include + +using namespace EncryptionUtils; + +const auto url = QStringLiteral("http://localhost:3000"); + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + auto networkManager = new QNetworkAccessManager(&app); + auto loginManager = new LoginManager(&app); + loginManager->login(url, networkManager, 1); + + QObject::connect(loginManager, &LoginManager::loginSucceeded, &app, [=](const QString &authToken, const QString &userId) { + qDebug() << "Login successful! Auth token:" << authToken << "UserId:" << userId << "\n"; + + uploadKeys(authToken, + url, + userId, + QStringLiteral("mypassword123"), + networkManager, + [authToken, userId, networkManager](const QString &message, const RSAKeyPair &keypair) { + qDebug() << message; + qDebug() << keypair.publicKey << keypair.privateKey; + + downloadKeys(authToken, + url, + userId, + QStringLiteral("mypassword123"), + networkManager, + [](const QString &publicKey, const QString &decryptedPrivateKey) { + qDebug() << "Downloaded Public Key:" << publicKey; + qDebug() << "Decrypted Private Key:" << decryptedPrivateKey; + QCoreApplication::quit(); + }); + }); + }); + + QObject::connect(loginManager, &LoginManager::loginFailed, &app, [](const QString &err) { + qCritical() << "Login failed:" << err; + QCoreApplication::quit(); + }); + + return app.exec(); +} \ No newline at end of file diff --git a/tests/encryptiontest/encryptiontestgui.cpp b/tests/encryptiontest/encryptiontestgui.cpp new file mode 100644 index 0000000000..a8eb12c363 --- /dev/null +++ b/tests/encryptiontest/encryptiontestgui.cpp @@ -0,0 +1,233 @@ +/* + SPDX-FileCopyrightText: 2023-2025 Laurent Montel + SPDX-FileCopyrightText: 2025 Andro Ranogajec + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "encryptiontestgui.h" +#include "encryption/encryptionutils.h" +#include +#include +#include +#include +#include +#include +#include +#include + +EncryptionTestGui::EncryptionTestGui(QWidget *parent) + : QWidget{parent} + , mTextEdit(new QTextEdit(this)) + , mTextEditResult(new QTextEdit(this)) +{ + auto mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins({}); + auto labelInput = new QLabel(QStringLiteral("Input"), this); + mainLayout->addWidget(labelInput); + mainLayout->addWidget(mTextEdit); + + auto pushButtonDeriveMasterKey = new QPushButton(QStringLiteral("Derive Master Key"), this); + mainLayout->addWidget(pushButtonDeriveMasterKey); + + connect(pushButtonDeriveMasterKey, &QPushButton::clicked, this, [this]() { + auto dialog = new QDialog(this); + dialog->setWindowTitle(QStringLiteral("Credentials")); + + auto saltEdit = new QLineEdit(dialog); + auto passwordEdit = new QLineEdit(dialog); + passwordEdit->setEchoMode(QLineEdit::Password); + + auto layout = new QGridLayout(dialog); + layout->addWidget(new QLabel(QStringLiteral("UserId as salt: "), dialog), 0, 0); + layout->addWidget(saltEdit, 0, 1); + layout->addWidget(new QLabel(QStringLiteral("Password: "), dialog), 1, 0); + layout->addWidget(passwordEdit, 1, 1); + + auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, dialog); + layout->addWidget(buttonBox, 2, 0, 1, 2); + + connect(buttonBox, &QDialogButtonBox::accepted, dialog, &QDialog::accept); + connect(buttonBox, &QDialogButtonBox::rejected, dialog, &QDialog::reject); + + if (dialog->exec()) { + mSalt = saltEdit->text(); + mPassword = passwordEdit->text(); + mMasterKey = EncryptionUtils::getMasterKey(mPassword, mSalt); + + if (mMasterKey.isEmpty()) { + mTextEditResult->setPlainText(QStringLiteral("Master key is empty, generation failed!")); + } else { + qDebug() << "Derived Master Key:" << mMasterKey.toBase64(); + mTextEditResult->setPlainText((QStringLiteral("Master Key derivation succeeded!\n") + QString::fromUtf8(mMasterKey.toBase64()))); + } + } + delete dialog; + }); + + auto pushButtonGenerateRSAKey = new QPushButton(QStringLiteral("Generate RSA Pair"), this); + mainLayout->addWidget(pushButtonGenerateRSAKey); + connect(pushButtonGenerateRSAKey, &QPushButton::clicked, this, [this]() { + mRsaKeyPair = EncryptionUtils::generateRSAKey(); + qDebug() << "Public Key:\n " << mRsaKeyPair.publicKey << "Private Key:\n " << mRsaKeyPair.privateKey; + mTextEditResult->setPlainText(QStringLiteral("RSA pair generation succeded!\n") + QString::fromUtf8(mRsaKeyPair.publicKey) + + QString::fromUtf8(mRsaKeyPair.privateKey)); + }); + + auto pushButtonEncryptPrivateKey = new QPushButton(QStringLiteral("Encrypt Private Key"), this); + mainLayout->addWidget(pushButtonEncryptPrivateKey); + connect(pushButtonEncryptPrivateKey, &QPushButton::clicked, this, [this]() { + if (mMasterKey.isEmpty()) { + mTextEditResult->setPlainText(QStringLiteral("Master key is empty, encryption of the private key failed!")); + } else if (mRsaKeyPair.privateKey.isEmpty()) { + mTextEditResult->setPlainText(QStringLiteral("Private key is empty, encryption of the private key failed!")); + } else { + mEncryptedPrivateKey = EncryptionUtils::encryptPrivateKey(mRsaKeyPair.privateKey, mMasterKey); + qDebug() << mEncryptedPrivateKey.toBase64() << "encrypted and encoded to 'base64()' private key "; + mTextEditResult->setPlainText(QStringLiteral("Private key encryption succeded!\n") + QString::fromUtf8(mEncryptedPrivateKey.toBase64())); + } + }); + + auto pushButtonDecryptPrivateKey = new QPushButton(QStringLiteral("Decrypt Private Key"), this); + mainLayout->addWidget(pushButtonDecryptPrivateKey); + connect(pushButtonDecryptPrivateKey, &QPushButton::clicked, this, [this]() { + if (mMasterKey.isEmpty()) { + mTextEditResult->setPlainText(QStringLiteral("Master key is empty, encryption of the private key failed!")); + } else if (mEncryptedPrivateKey.isEmpty()) { + mTextEditResult->setPlainText(QStringLiteral("Encrypted private key is empty, decryption of the private key failed!")); + } else { + mDecryptedPrivateKey = EncryptionUtils::decryptPrivateKey(mEncryptedPrivateKey, mMasterKey); + mTextEditResult->setPlainText(QStringLiteral("Private key decryption succeded!\n") + QString::fromUtf8(mDecryptedPrivateKey)); + qDebug() << mDecryptedPrivateKey << "decrypted private key '\n' "; + qDebug() << mRsaKeyPair.privateKey << "init private key '\n' "; + } + }); + + auto pushButtonExportPublicKey = new QPushButton(QStringLiteral("Export Public Key"), this); + mainLayout->addWidget(pushButtonExportPublicKey); + connect(pushButtonExportPublicKey, &QPushButton::clicked, this, [this]() { + if (mRsaKeyPair.publicKey.isEmpty()) { + mTextEditResult->setPlainText(QStringLiteral("Public key is empty, exporting failed!\n")); + } else { + const auto expPublicKey = EncryptionUtils::exportJWKPublicKey(EncryptionUtils::publicKeyFromPEM(mRsaKeyPair.publicKey)); + qDebug() << "Public Key:\n " << mRsaKeyPair.publicKey << "Exported Public Key:\n " << expPublicKey; + mTextEditResult->setPlainText(QStringLiteral("Public key export succeded!\n") + QString::fromUtf8(expPublicKey)); + } + }); + + auto pushButtonExportEncryptedPrivateKey = new QPushButton(QStringLiteral("Export Encrypted Private Key"), this); + mainLayout->addWidget(pushButtonExportEncryptedPrivateKey); + connect(pushButtonExportEncryptedPrivateKey, &QPushButton::clicked, this, [this]() { + if (mEncryptedPrivateKey.isEmpty()) { + mTextEditResult->setPlainText(QStringLiteral("Encrypted private key is empty, exporting failed!\n")); + } else { + const auto expPrivKey = EncryptionUtils::exportJWKEncryptedPrivateKey(mEncryptedPrivateKey); + qDebug() << "Private Key:\n " << mRsaKeyPair.privateKey << "Exported Encrypted Private Key:\n " << expPrivKey; + mTextEditResult->setPlainText(QStringLiteral("Encrypted private key export succeded!\n") + QString::fromUtf8(expPrivKey)); + } + }); + + auto pushButtonGenerateSessionKey = new QPushButton(QStringLiteral("Generate Session Key"), this); + mainLayout->addWidget(pushButtonGenerateSessionKey); + connect(pushButtonGenerateSessionKey, &QPushButton::clicked, this, [this]() { + mSessionKey = EncryptionUtils::generateSessionKey(); + qDebug() << "Derived Session Key:" << mSessionKey.toBase64(); + mTextEditResult->setPlainText(QStringLiteral("Session key generation succeeded!\n") + QString::fromUtf8(mSessionKey.toBase64())); + }); + + auto pushButtonEncryptSessionKey = new QPushButton(QStringLiteral("Encrypt Session Key"), this); + mainLayout->addWidget(pushButtonEncryptSessionKey); + connect(pushButtonEncryptSessionKey, &QPushButton::clicked, this, [this]() { + const auto publicKey = mRsaKeyPair.publicKey; + if (publicKey.isEmpty()) { + mTextEditResult->setPlainText(QStringLiteral("Public key is empty, session key encryption failed!\n")); + } else { + RSA *publicKeyfromPem = EncryptionUtils::publicKeyFromPEM(publicKey); + mEncryptedSessionKey = EncryptionUtils::encryptSessionKey(mSessionKey, publicKeyfromPem); + qDebug() << "Public Key from PEM:" << publicKeyfromPem; + qDebug() << "Encrypted Session Key:" << mEncryptedSessionKey.toBase64(); + mTextEditResult->setPlainText(QStringLiteral("Session key encryption succeeded!\n") + QString::fromUtf8(mEncryptedSessionKey.toBase64())); + } + }); + + auto pushButtonDecryptSessionKey = new QPushButton(QStringLiteral("Decrypt Session Key"), this); + mainLayout->addWidget(pushButtonDecryptSessionKey); + connect(pushButtonDecryptSessionKey, &QPushButton::clicked, this, [this]() { + auto privateKey = mRsaKeyPair.privateKey; + if (privateKey.isEmpty()) { + mTextEditResult->setPlainText(QStringLiteral("Private key is empty, session key decryption failed!\n")); + } else { + RSA *privateKeyfromPem = EncryptionUtils::privateKeyFromPEM(privateKey); + mDecryptedSessionKey = EncryptionUtils::decryptSessionKey(mEncryptedSessionKey, privateKeyfromPem); + qDebug() << "Private Key from PEM:" << privateKeyfromPem; + qDebug() << "Decrypted Session Key:" << mDecryptedSessionKey.toBase64(); + mTextEditResult->setPlainText(QStringLiteral("Session key decryption succeeded!\n") + QString::fromUtf8(mDecryptedSessionKey.toBase64())); + } + }); + auto pushButtonEncryptMessage = new QPushButton(QStringLiteral("Encrypt message"), this); + mainLayout->addWidget(pushButtonEncryptMessage); + connect(pushButtonEncryptMessage, &QPushButton::clicked, this, [this]() { + const auto text = mTextEdit->toPlainText(); + if (text.isEmpty()) { + mTextEditResult->setPlainText(QStringLiteral("Text cannot be null, message encryption failed!\n")); + } else if (!text.isEmpty() && mSessionKey.isEmpty()) { + mTextEditResult->setPlainText(QStringLiteral("Session key is empty, message encryption failed!\n")); + } else { + mEncryptedMessage = EncryptionUtils::encryptMessage(text.toUtf8(), mSessionKey); + qDebug() << "Encrypted message:" << mEncryptedMessage.toBase64(); + mTextEditResult->setPlainText(QStringLiteral("Message encryption succeeded!\n") + QString::fromUtf8(mEncryptedMessage.toBase64())); + mTextEdit->clear(); + } + }); + auto pushButtonDecryptMessage = new QPushButton(QStringLiteral("Decrypt message"), this); + mainLayout->addWidget(pushButtonDecryptMessage); + connect(pushButtonDecryptMessage, &QPushButton::clicked, this, [this]() { + qDebug() << "Session key:" << mSessionKey; + if (QString::fromUtf8(mEncryptedMessage).isEmpty()) { + mTextEditResult->setPlainText(QStringLiteral("Encrypted message is null, message decryption failed!\n")); + return; + } + mDecryptedMessage = EncryptionUtils::decryptMessage(mEncryptedMessage, mSessionKey); + qDebug() << "Decrypted message:" << mDecryptedMessage; + mTextEditResult->setPlainText(QStringLiteral("Message decryption succeeded!\n") + QString::fromUtf8(mDecryptedMessage)); + }); + + auto pushButtonReset = new QPushButton(QStringLiteral("Reset"), this); + mainLayout->addWidget(pushButtonReset); + connect(pushButtonReset, &QPushButton::clicked, this, [this]() { + mTextEdit->clear(); + mTextEditResult->clear(); + mMasterKey.clear(); + mPassword = QString(); + mSalt = QString(); + mRsaKeyPair.privateKey.clear(); + mRsaKeyPair.publicKey.clear(); + mSessionKey.clear(); + mEncryptedSessionKey.clear(); + mDecryptedSessionKey.clear(); + mEncryptedMessage.clear(); + mDecryptedMessage.clear(); + qDebug() << "Master Key: " << mMasterKey << "\nsalt: " << mSalt << "\npassword: " << mPassword << "\nprivatekey: " << mRsaKeyPair.privateKey + << "\npublickey: " << mRsaKeyPair.publicKey << "\nencrypted session key: " << mEncryptedSessionKey + << "\ndecrypted session key: " << mDecryptedSessionKey << "\nencrypted message: " << mEncryptedMessage + << "\ndecrypted message: " << mDecryptedMessage; + mTextEditResult->setPlainText(QStringLiteral("Reset succeded!\n")); + }); + + mTextEditResult->setReadOnly(true); + auto labelOutput = new QLabel(QStringLiteral("Output"), this); + mainLayout->addWidget(labelOutput); + mainLayout->addWidget(mTextEditResult); +} + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + + EncryptionTestGui w; + w.resize(800, 600); + w.show(); + return app.exec(); +} + +#include "moc_encryptiontestgui.cpp" diff --git a/tests/encryptiontestgui/encryptiontestgui.h b/tests/encryptiontest/encryptiontestgui.h similarity index 60% rename from tests/encryptiontestgui/encryptiontestgui.h rename to tests/encryptiontest/encryptiontestgui.h index 5ba977755b..1fc669421d 100644 --- a/tests/encryptiontestgui/encryptiontestgui.h +++ b/tests/encryptiontest/encryptiontestgui.h @@ -7,7 +7,9 @@ #pragma once +#include "encryption/encryptionutils.h" #include + class QTextEdit; class EncryptionTestGui : public QWidget { @@ -21,5 +23,13 @@ class EncryptionTestGui : public QWidget QTextEdit *const mTextEditResult; QByteArray mMasterKey; QString mPassword; - QString mUserId; + QString mSalt; + QByteArray mEncryptedPrivateKey; + QByteArray mDecryptedPrivateKey; + EncryptionUtils::RSAKeyPair mRsaKeyPair; + QByteArray mSessionKey; + QByteArray mEncryptedSessionKey; + QByteArray mDecryptedSessionKey; + QByteArray mEncryptedMessage; + QByteArray mDecryptedMessage; }; diff --git a/tests/encryptiontest/envutils.cpp b/tests/encryptiontest/envutils.cpp new file mode 100644 index 0000000000..f6672ce2c6 --- /dev/null +++ b/tests/encryptiontest/envutils.cpp @@ -0,0 +1,28 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "envutils.h" +#include + +QHash loadEnvFile(const QString &filePath) +{ + QHash env; + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return env; + } + while (!file.atEnd()) { + const QString line = QString::fromUtf8(file.readLine()).trimmed(); + if (line.startsWith(QLatin1Char('#')) || line.isEmpty()) + continue; + const int equalSignIndex = line.indexOf(QLatin1Char('=')); + if (equalSignIndex == -1) + continue; + const QString key = line.left(equalSignIndex).trimmed(); + const QString value = line.mid(equalSignIndex + 1).trimmed(); + env[key] = value; + } + return env; +} \ No newline at end of file diff --git a/tests/encryptiontest/envutils.h b/tests/encryptiontest/envutils.h new file mode 100644 index 0000000000..1b1e0b5317 --- /dev/null +++ b/tests/encryptiontest/envutils.h @@ -0,0 +1,10 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#pragma once +#include +#include + +[[nodiscard]] QHash loadEnvFile(const QString &filePath); \ No newline at end of file diff --git a/tests/encryptiontest/loginmanager.cpp b/tests/encryptiontest/loginmanager.cpp new file mode 100644 index 0000000000..2923923ee2 --- /dev/null +++ b/tests/encryptiontest/loginmanager.cpp @@ -0,0 +1,44 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "loginmanager.h" + +LoginManager::LoginManager(QObject *parent) + : QObject(parent) +{ +} + +void LoginManager::login(const QString &serverUrl, QNetworkAccessManager *networkManager, int userIndex) +{ + const auto envPath = QDir(QCoreApplication::applicationDirPath()).absoluteFilePath(QStringLiteral("../../.env")); + const auto creds = loadEnvFile(envPath); + + const auto usernameKey = userIndex == 0 ? QStringLiteral("USERNAME") : QStringLiteral("USERNAME%1").arg(userIndex); + const auto passwordKey = userIndex == 0 ? QStringLiteral("PASSWORD") : QStringLiteral("PASSWORD%1").arg(userIndex); + + loginJob = new RocketChatRestApi::LoginJob(this); + restApiMethod = new RocketChatRestApi::RestApiMethod(); + restApiMethod->setServerUrl(serverUrl); + + loginJob->setRestApiMethod(restApiMethod); + loginJob->setNetworkAccessManager(networkManager); + + if (creds.value(usernameKey).isEmpty() || creds.value(passwordKey).isEmpty()) { + qDebug() << "Username or password are empty!"; + } + + loginJob->setUserName(creds.value(usernameKey)); + loginJob->setPassword(creds.value(passwordKey)); + + QObject::connect(loginJob, &RocketChatRestApi::LoginJob::loginDone, this, [this](const QString &authToken, const QString &userId) { + Q_EMIT loginSucceeded(authToken, userId); + }); + + QObject::connect(loginJob, &RocketChatRestApi::RestApiAbstractJob::failed, this, [this](const QString &err) { + Q_EMIT loginFailed(err); + }); + + loginJob->start(); +} \ No newline at end of file diff --git a/tests/encryptiontest/loginmanager.h b/tests/encryptiontest/loginmanager.h new file mode 100644 index 0000000000..ef9c543b9d --- /dev/null +++ b/tests/encryptiontest/loginmanager.h @@ -0,0 +1,62 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#pragma once + +#include "authentication/loginjob.h" +#include "envutils.h" +#include "restapimethod.h" +#include +#include +#include +#include + +/** + * @class LoginManager + * @brief Utility class for logging into Rocket.Chat as a test user. + * + * LoginManager simplifies the process of authenticating test users in integration and autotests. + * It reads user credentials from a `.env` file located at the project root, supporting multiple users + * by using environment variable suffixes (e.g., USERNAME1, PASSWORD1, USERNAME2, PASSWORD2, etc.). + * + * Usage: + * + * LoginManager lm; + * lm.login(serverUrl, networkManager, index); + * + * + * LoginManager lm2; + * lm2.login(serverUrl, networkManager, 2); // Uses USERNAME2, PASSWORD2 + * + * .env file: + * + * The .env file should be placed at the project root and contain lines like: + * USERNAME=alice + * PASSWORD=alicepass + * + * USERNAME1=bob + * PASSWORD1=bobpass + * + * USERNAME2=carol + * PASSWORD2=carolpass + * + * This allows you to run tests with multiple users by specifying their credentials. + * + **/ +class LoginManager : public QObject +{ + Q_OBJECT +public: + explicit LoginManager(QObject *parent = nullptr); + void login(const QString &serverUrl, QNetworkAccessManager *networkManager, int userIndex); + +Q_SIGNALS: + void loginSucceeded(const QString &authToken, const QString &userId); + void loginFailed(const QString &error); + +private: + RocketChatRestApi::LoginJob *loginJob = nullptr; + RocketChatRestApi::RestApiMethod *restApiMethod = nullptr; +}; \ No newline at end of file diff --git a/tests/encryptiontest/uploaddownloadrsakeypair.cpp b/tests/encryptiontest/uploaddownloadrsakeypair.cpp new file mode 100644 index 0000000000..1f8939b9f7 --- /dev/null +++ b/tests/encryptiontest/uploaddownloadrsakeypair.cpp @@ -0,0 +1,89 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "uploaddownloadrsakeypair.h" +#include +#include +#include + +using namespace EncryptionUtils; +using namespace RocketChatRestApi; + +void uploadKeys(const QString &authToken, + const QString &url, + const QString &userId, + const QString &password, + QNetworkAccessManager *networkManager, + std::function onSuccess) +{ + const auto keyPair = generateRSAKey(); + const auto masterKey = getMasterKey(password, QStringLiteral("salt")); + const auto encryptedPrivateKey = encryptPrivateKey(keyPair.privateKey, masterKey); + + qDebug() << "uploadKeys called with authToken:" << authToken; + const auto uploadJob = new SetUserPublicAndPrivateKeysJob(); + const auto restApiMethod = new RestApiMethod(); + restApiMethod->setServerUrl(url); + + uploadJob->setRestApiMethod(restApiMethod); + uploadJob->setNetworkAccessManager(networkManager); + uploadJob->setAuthToken(authToken); + uploadJob->setUserId(userId); + + SetUserPublicAndPrivateKeysJob::SetUserPublicAndPrivateKeysInfo info; + info.rsaPublicKey = QString::fromUtf8(keyPair.publicKey); + info.rsaPrivateKey = QString::fromUtf8(encryptedPrivateKey.toBase64()); + info.force = true; + uploadJob->setSetUserPublicAndPrivateKeysInfo(info); + + QObject::connect(uploadJob, &SetUserPublicAndPrivateKeysJob::setUserPublicAndPrivateKeysDone, uploadJob, [onSuccess, keyPair]() { + if (onSuccess) { + onSuccess(QStringLiteral("Key upload successful!"), keyPair); + } + }); + + QObject::connect(uploadJob, &SetUserPublicAndPrivateKeysJob::failed, uploadJob, [](const QString &err) { + qCritical() << "Key upload failed!: " << err; + QCoreApplication::quit(); + }); + + uploadJob->start(); +} + +void downloadKeys(const QString &authToken, + const QString &url, + const QString &userId, + const QString &password, + QNetworkAccessManager *networkManager, + std::function onSuccess) +{ + const auto fetchJob = new FetchMyKeysJob(); + const auto restApiMethod = new RestApiMethod(); + restApiMethod->setServerUrl(url); + + fetchJob->setRestApiMethod(restApiMethod); + fetchJob->setNetworkAccessManager(networkManager); + fetchJob->setAuthToken(authToken); + fetchJob->setUserId(userId); + + QObject::connect(fetchJob, &FetchMyKeysJob::fetchMyKeysDone, fetchJob, [password, onSuccess](const QJsonObject &jsonObj) { + const auto publicKey = jsonObj["public_key"_L1].toString(); + const auto encryptedPrivateKeyB64 = jsonObj["private_key"_L1].toString(); + const auto encryptedPrivateKey = QByteArray::fromBase64(encryptedPrivateKeyB64.toUtf8()); + const auto masterKey = getMasterKey(password, QStringLiteral("salt")); + const auto decryptedPrivateKey = QString::fromUtf8(decryptPrivateKey(encryptedPrivateKey, masterKey)); + + if (onSuccess) { + onSuccess(publicKey, decryptedPrivateKey); + } + }); + + QObject::connect(fetchJob, &RestApiAbstractJob::failed, fetchJob, [=](const QString &err) { + qCritical() << "Key fetch failed:" << err; + QCoreApplication::quit(); + }); + + fetchJob->start(); +} \ No newline at end of file diff --git a/tests/encryptiontest/uploaddownloadrsakeypair.h b/tests/encryptiontest/uploaddownloadrsakeypair.h new file mode 100644 index 0000000000..f7f48e7821 --- /dev/null +++ b/tests/encryptiontest/uploaddownloadrsakeypair.h @@ -0,0 +1,28 @@ +/* + SPDX-FileCopyrightText: 2025 Andro Ranogajec + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#pragma once + +#include "e2e/fetchmykeysjob.h" +#include "e2e/setuserpublicandprivatekeysjob.h" +#include "encryptionutils.h" +#include "restapimethod.h" +#include +#include +#include + +void uploadKeys(const QString &authToken, + const QString &url, + const QString &userId, + const QString &password, + QNetworkAccessManager *networkManager, + std::function onSuccess); + +void downloadKeys(const QString &authToken, + const QString &url, + const QString &userId, + const QString &password, + QNetworkAccessManager *networkManager, + std::function onSuccess); \ No newline at end of file diff --git a/tests/encryptiontestgui/CMakeLists.txt b/tests/encryptiontestgui/CMakeLists.txt deleted file mode 100644 index 0b67046cc0..0000000000 --- a/tests/encryptiontestgui/CMakeLists.txt +++ /dev/null @@ -1,14 +0,0 @@ -# SPDX-FileCopyrightText: 2024-2025 Laurent Montel -# SPDX-License-Identifier: BSD-3-Clause - -add_executable(encryptiontestgui) -target_sources(encryptiontestgui PRIVATE encryptiontestgui.h encryptiontestgui.cpp) - -target_link_libraries(encryptiontestgui - Qt::Widgets - Qt::Gui - libruqolacore -) -set_target_properties(encryptiontestgui PROPERTIES DISABLE_PRECOMPILE_HEADERS ON) - - diff --git a/tests/encryptiontestgui/encryptiontestgui.cpp b/tests/encryptiontestgui/encryptiontestgui.cpp deleted file mode 100644 index 722c01ebc6..0000000000 --- a/tests/encryptiontestgui/encryptiontestgui.cpp +++ /dev/null @@ -1,108 +0,0 @@ -/* - SPDX-FileCopyrightText: 2023-2025 Laurent Montel - SPDX-FileCopyrightText: 2025 Andro Ranogajec - - SPDX-License-Identifier: LGPL-2.0-or-later -*/ - -#include "encryptiontestgui.h" -#include "encryption/encryptionutils.h" -#include -#include -#include -#include -#include -#include -#include -#include - -EncryptionTestGui::EncryptionTestGui(QWidget *parent) - : QWidget{parent} - , mTextEdit(new QTextEdit(this)) - , mTextEditResult(new QTextEdit(this)) - -{ - auto mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins({}); - auto labelInput = new QLabel(QStringLiteral("Input"), this); - mainLayout->addWidget(labelInput); - mainLayout->addWidget(mTextEdit); - - auto pushButtonDeriveMasterKey = new QPushButton(QStringLiteral("Derive Master Key"), this); - mainLayout->addWidget(pushButtonDeriveMasterKey); - - connect(pushButtonDeriveMasterKey, &QPushButton::clicked, this, [this]() { - auto *dialog = new QDialog(this); - dialog->setWindowTitle(QStringLiteral("Credentials")); - - auto *userIdEdit = new QLineEdit(dialog); - auto *passwordEdit = new QLineEdit(dialog); - passwordEdit->setEchoMode(QLineEdit::Password); - - auto *layout = new QGridLayout(dialog); - layout->addWidget(new QLabel(QStringLiteral("UserId: "), dialog), 0, 0); - layout->addWidget(userIdEdit, 0, 1); - layout->addWidget(new QLabel(QStringLiteral("Password: "), dialog), 1, 0); - layout->addWidget(passwordEdit, 1, 1); - - auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, dialog); - layout->addWidget(buttonBox, 2, 0, 1, 2); - - connect(buttonBox, &QDialogButtonBox::accepted, dialog, &QDialog::accept); - connect(buttonBox, &QDialogButtonBox::rejected, dialog, &QDialog::reject); - - if (dialog->exec()) { - mUserId = userIdEdit->text(); - mPassword = passwordEdit->text(); - mMasterKey = EncryptionUtils::getMasterKey(mPassword, mUserId); - qDebug() << "Derived Master Key:" << mMasterKey.toBase64(); - mTextEditResult->setPlainText((QStringLiteral("Master Key derivation succeeded!\n") + QString::fromUtf8(mMasterKey.toBase64()))); - } - delete dialog; - }); - - auto pushButtonGenerateRSAKey = new QPushButton(QStringLiteral("Generate RSA Pair"), this); - mainLayout->addWidget(pushButtonGenerateRSAKey); - connect(pushButtonGenerateRSAKey, &QPushButton::clicked, this, []() { - // test - }); - auto pushButtonGenerateSessionKey = new QPushButton(QStringLiteral("Generate Session Key"), this); - mainLayout->addWidget(pushButtonGenerateSessionKey); - connect(pushButtonGenerateSessionKey, &QPushButton::clicked, this, []() { - // test - }); - - auto pushButtonEncode = new QPushButton(QStringLiteral("Encode"), this); - mainLayout->addWidget(pushButtonEncode); - connect(pushButtonEncode, &QPushButton::clicked, this, []() { - // test - }); - auto pushButtonDecode = new QPushButton(QStringLiteral("Decode"), this); - mainLayout->addWidget(pushButtonDecode); - connect(pushButtonDecode, &QPushButton::clicked, this, []() { - // test - }); - - auto pushButtonReset = new QPushButton(QStringLiteral("Reset"), this); - mainLayout->addWidget(pushButtonReset); - connect(pushButtonReset, &QPushButton::clicked, this, []() { - EncryptionUtils::generateRSAKey(); - }); - - mTextEditResult->setReadOnly(true); - auto labelOutput = new QLabel(QStringLiteral("Output"), this); - mainLayout->addWidget(labelOutput); - mainLayout->addWidget(mTextEditResult); -} - -int main(int argc, char *argv[]) -{ - QApplication app(argc, argv); - - EncryptionTestGui w; - w.resize(800, 600); - w.show(); - return app.exec(); -} - -#include "moc_encryptiontestgui.cpp"