[network/ruqola] src: Try to implement generate encrypted room
Laurent Montel <[email protected]> Wed, 5 Aug 2026 12:01:26 +0000 (UTC)
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit e9d07cee0f2f52bf2cb8a71a2cbf6f5ee795e3b0 by Laurent Montel.
Committed on 05/08/2026 at 11:15.
Pushed by mlaurent into branch 'master'.
Try to implement generate encrypted room
M +111 -0 src/core/encryption/e2ekeymanager.cpp
M +2 -0 src/core/encryption/e2ekeymanager.h
M +65 -1 src/core/encryption/encryptionutils.cpp
M +1 -0 src/core/encryption/encryptionutils.h
M +2 -2 src/core/localdatabase/e2eroomsdatabase.cpp
M +5 -0 src/core/model/messagesmodel.cpp
M +23 -2 src/core/rocketchataccount.cpp
M +1 -1 src/rocketchatrestapi-qt/chat/postmessagejob.h
https://invent.kde.org/network/ruqola/-/commit/e9d07cee0f2f52bf2cb8a71a2cbf6f5ee795e3b0
diff --git a/src/core/encryption/e2ekeymanager.cpp b/src/core/encryption/e2ekeymanager.cpp
index f3d43d3dd5..078468ac7c 100644
--- a/src/core/encryption/e2ekeymanager.cpp
+++ b/src/core/encryption/e2ekeymanager.cpp
@@ -8,11 +8,14 @@
#include "config-ruqola.h"
#include "connection.h"
#include "e2e/fetchmykeysjob.h"
+#include "e2e/setroomkeyidjob.h"
#include "e2e/setuserpublicandprivatekeysjob.h"
+#include "e2e/updategroupkeyjob.h"
#if USE_E2E_SUPPORT
#include "encryptionutils.h"
#endif
#include "localdatabase/e2edatabase.h"
+#include "localdatabase/e2eroomsdatabase.h"
#include "localdatabase/localdatabasemanager.h"
#include "rocketchataccount.h"
#include "rocketchataccountsettings.h"
@@ -255,6 +258,114 @@ void E2eKeyManager::setStatus(Status newStatus)
mStatus = newStatus;
}
+bool E2eKeyManager::initializeRoomE2EKey(const QByteArray &roomId, const QString &existingKeyId)
+{
+#if USE_E2E_SUPPORT
+ if (!mAccount || roomId.isEmpty()) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: invalid arguments";
+ return false;
+ }
+ if (mStatus != Status::KeyDecrypted || mDecodedPrivateKey.isEmpty()) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: E2E key not yet decrypted";
+ return false;
+ }
+
+ // Generate a new 32-byte AES-256-GCM session key.
+ const QByteArray sessionKey = EncryptionUtils::generateSessionKey();
+
+ if (existingKeyId.isEmpty()) {
+ // Server has no keyId yet — register a newly generated one.
+ const QString keyId = EncryptionUtils::generateRoomKeyId();
+ auto setKeyIdJob = new RocketChatRestApi::SetRoomKeyIDJob(this);
+ mAccount->restApi()->initializeRestApiJob(setKeyIdJob);
+ const RocketChatRestApi::SetRoomKeyIDJob::RoomKeyIDInfo keyIdInfo{
+ .roomId = roomId,
+ .keyId = keyId.toLatin1(),
+ };
+ setKeyIdJob->setRoomKeyIDInfo(keyIdInfo);
+ connect(setKeyIdJob, &RocketChatRestApi::SetRoomKeyIDJob::setRoomKeyIdDone, this, [sessionKey, keyId, roomId, this]() {
+ distributeRoomSessionKey(roomId, sessionKey, keyId);
+ });
+ if (!setKeyIdJob->start()) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: failed to start SetRoomKeyIDJob";
+ return false;
+ }
+ } else {
+ // Server already assigned a keyId (e.g. auto-created by the server on room creation).
+ qCDebug(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: reusing server-assigned keyId" << existingKeyId;
+ distributeRoomSessionKey(roomId, sessionKey, existingKeyId);
+ }
+ return true;
+#else
+ Q_UNUSED(roomId)
+ Q_UNUSED(existingKeyId)
+ return false;
+#endif
+}
+void E2eKeyManager::distributeRoomSessionKey(const QByteArray &roomId, const QByteArray &sessionKey, const QString &keyId)
+{
+#if USE_E2E_SUPPORT
+ // Retrieve own RSA public key from local database.
+ const QString userId = QString::fromLatin1(mAccount->settings()->userId());
+ QByteArray encryptedOwnPrivateKey;
+ QByteArray ownPublicKeyPem;
+ if (!mAccount->localDatabaseManager()->e2EDatabase()->loadKey(mAccount->accountName(), userId, encryptedOwnPrivateKey, ownPublicKeyPem)) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: own public key not found in local database";
+ return;
+ }
+
+ // Encrypt the session key with own RSA-OAEP public key.
+ // The public key may be stored as JWK JSON (Rocket.Chat format) or PEM.
+ QByteArray resolvedPublicKeyPem;
+ if (ownPublicKeyPem.trimmed().startsWith('{')) {
+ resolvedPublicKeyPem = EncryptionUtils::publicKeyJWKToPEM(ownPublicKeyPem);
+ if (resolvedPublicKeyPem.isEmpty()) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: failed to convert JWK public key to PEM";
+ return;
+ }
+ } else {
+ resolvedPublicKeyPem = ownPublicKeyPem;
+ }
+ RSA *rsaPublicKey = EncryptionUtils::publicKeyFromPEM(resolvedPublicKeyPem);
+ if (!rsaPublicKey) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: failed to parse own public key";
+ return;
+ }
+ const QByteArray encryptedSessionKey = EncryptionUtils::encryptSessionKey(sessionKey, rsaPublicKey);
+ RSA_free(rsaPublicKey);
+ if (encryptedSessionKey.isEmpty()) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: session key encryption failed";
+ return;
+ }
+
+ // Store the encrypted session key in the user's subscription on the server.
+ auto updateKeyJob = new RocketChatRestApi::UpdateGroupKeyJob(this);
+ mAccount->restApi()->initializeRestApiJob(updateKeyJob);
+ RocketChatRestApi::UpdateGroupKeyJob::UpdateGroupKeyInfo updateInfo;
+ updateInfo.uid = userId;
+ updateInfo.roomId = QString::fromLatin1(roomId);
+ updateInfo.key = QString::fromLatin1(encryptedSessionKey.toBase64());
+ updateKeyJob->setUpdateGroupInfo(updateInfo);
+ if (!updateKeyJob->start()) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: failed to start UpdateGroupKeyJob";
+ return;
+ }
+
+ // Persist the encrypted session key locally so we can decrypt messages without a server round-trip.
+ (void)mAccount->localDatabaseManager()->e2ERoomsDataBase()->saveKey(mAccount->accountName(),
+ QString::fromLatin1(roomId),
+ keyId,
+ encryptedSessionKey,
+ ownPublicKeyPem);
+
+ qCDebug(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: E2E session key initialised for room" << roomId << "keyId" << keyId;
+#else
+ Q_UNUSED(roomId)
+ Q_UNUSED(sessionKey)
+ Q_UNUSED(keyId)
+#endif
+}
+
void E2eKeyManager::fetchMyKeys()
{
auto job = new RocketChatRestApi::FetchMyKeysJob(this);
diff --git a/src/core/encryption/e2ekeymanager.h b/src/core/encryption/e2ekeymanager.h
index e588878edf..b16559b6f3 100644
--- a/src/core/encryption/e2ekeymanager.h
+++ b/src/core/encryption/e2ekeymanager.h
@@ -35,6 +35,7 @@ public:
[[nodiscard]] bool hasPendingUploadFailure() const;
void fetchMyKeys();
+ [[nodiscard]] bool initializeRoomE2EKey(const QByteArray &roomId, const QString &existingKeyId = {});
[[nodiscard]] E2eKeyManager::Status needToDecodeEncryptionKey() const;
@@ -65,6 +66,7 @@ private:
LIBRUQOLACORE_NO_EXPORT void slotPasswordWritten(QKeychain::Job *baseJob);
[[nodiscard]] LIBRUQOLACORE_NO_EXPORT QString passwordKeyIdentifier() const;
LIBRUQOLACORE_NO_EXPORT void slotPasswordRead(QKeychain::Job *baseJob);
+ LIBRUQOLACORE_NO_EXPORT void distributeRoomSessionKey(const QByteArray &roomId, const QByteArray &sessionKey, const QString &keyId);
Status mStatus = Status::Unknown;
QString mGeneratedPassword;
QByteArray mDecodedPrivateKey;
diff --git a/src/core/encryption/encryptionutils.cpp b/src/core/encryption/encryptionutils.cpp
index 299e6751a7..ee9b87a354 100644
--- a/src/core/encryption/encryptionutils.cpp
+++ b/src/core/encryption/encryptionutils.cpp
@@ -304,7 +304,7 @@ QByteArray EncryptionUtils::getMasterKey(const QString &password, const QString
*/
QByteArray EncryptionUtils::generateSessionKey()
{
- return generateRandomIV(16);
+ return generateRandomIV(32);
}
/**
@@ -698,6 +698,70 @@ QByteArray EncryptionUtils::privateKeyJWKToPEM(const QByteArray &jwkJson)
return pem;
}
+/**
+ * @brief Converts a JWK RSA public key JSON to PEM format.
+ *
+ * Rocket.Chat stores public keys as JWK JSON (kty=RSA, with base64url-encoded
+ * n and e fields). This function reconstructs the OpenSSL RSA public key and
+ * serialises it as a SubjectPublicKeyInfo PEM so that publicKeyFromPEM() can
+ * consume it uniformly.
+ *
+ * @param jwkJson UTF-8 encoded JSON containing at minimum: kty, n, e.
+ * @return PEM-encoded public key, or empty on error.
+ */
+QByteArray EncryptionUtils::publicKeyJWKToPEM(const QByteArray &jwkJson)
+{
+ const QJsonDocument doc = QJsonDocument::fromJson(jwkJson);
+ if (doc.isNull() || !doc.isObject()) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "publicKeyJWKToPEM: invalid JSON";
+ return {};
+ }
+ const QJsonObject obj = doc.object();
+ if (obj.value(QStringLiteral("kty")).toString() != QLatin1String("RSA")) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "publicKeyJWKToPEM: not an RSA key";
+ return {};
+ }
+
+ const auto b64urlToBN = [](const QString &b64url) -> BIGNUM * {
+ QString b64 = b64url;
+ b64.replace(QLatin1Char('-'), QLatin1Char('+')).replace(QLatin1Char('_'), QLatin1Char('/'));
+ while (b64.size() % 4 != 0) {
+ b64.append(QLatin1Char('='));
+ }
+ const QByteArray bytes = QByteArray::fromBase64(b64.toLatin1());
+ if (bytes.isEmpty()) {
+ return nullptr;
+ }
+ return BN_bin2bn(reinterpret_cast<const unsigned char *>(bytes.constData()), bytes.size(), nullptr);
+ };
+
+ BIGNUM *n = b64urlToBN(obj.value(QStringLiteral("n")).toString());
+ BIGNUM *e = b64urlToBN(obj.value(QStringLiteral("e")).toString());
+ if (!n || !e) {
+ BN_free(n);
+ BN_free(e);
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "publicKeyJWKToPEM: missing n or e components";
+ return {};
+ }
+
+ RSA *rsa = RSA_new();
+ RSA_set0_key(rsa, n, e, nullptr); // transfers ownership
+
+ // Wrap in EVP_PKEY and write as SubjectPublicKeyInfo PEM (BEGIN PUBLIC KEY)
+ EVP_PKEY *pkey = EVP_PKEY_new();
+ EVP_PKEY_assign_RSA(pkey, rsa); // pkey owns rsa from here
+ BIO *bio = BIO_new(BIO_s_mem());
+ PEM_write_bio_PUBKEY(bio, pkey);
+
+ BUF_MEM *buf = nullptr;
+ BIO_get_mem_ptr(bio, &buf);
+ const QByteArray pem(buf->data, static_cast<qsizetype>(buf->length));
+
+ BIO_free(bio);
+ EVP_PKEY_free(pkey);
+ return pem;
+}
+
QByteArray EncryptionUtils::decryptAES_CBC_256(const QByteArray &data, const QByteArray &key, const QByteArray &iv)
{
EVP_CIPHER_CTX *ctx;
diff --git a/src/core/encryption/encryptionutils.h b/src/core/encryption/encryptionutils.h
index d9330be4c1..a18f4fef03 100644
--- a/src/core/encryption/encryptionutils.h
+++ b/src/core/encryption/encryptionutils.h
@@ -40,6 +40,7 @@ struct RSAKeyPair {
[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray decryptAES_GCM_256(const QByteArray &ciphertext, const QByteArray &key, const QByteArray &iv);
[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray encryptAES_GCM_256(const QByteArray &plainText, const QByteArray &key, const QByteArray &iv);
[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray privateKeyJWKToPEM(const QByteArray &jwkJson);
+[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray publicKeyJWKToPEM(const QByteArray &jwkJson);
[[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);
diff --git a/src/core/localdatabase/e2eroomsdatabase.cpp b/src/core/localdatabase/e2eroomsdatabase.cpp
index 290c8b445c..f669b090a7 100644
--- a/src/core/localdatabase/e2eroomsdatabase.cpp
+++ b/src/core/localdatabase/e2eroomsdatabase.cpp
@@ -21,7 +21,7 @@ enum class E2ERoomsFields {
}; // in the same order as the table
E2ERoomsDataBase::E2ERoomsDataBase()
- : LocalDatabaseBase(LocalDatabaseUtils::localE2EDatabasePath(), LocalDatabaseBase::DatabaseType::E2ERooms)
+ : LocalDatabaseBase(LocalDatabaseUtils::localE2ERoomsDatabasePath(), LocalDatabaseBase::DatabaseType::E2ERooms)
{
}
@@ -115,7 +115,7 @@ std::unique_ptr<QSqlTableModel> E2ERoomsDataBase::createE2eRoomsModel(const QStr
if (!db.isValid()) {
// Open the DB if it exists (don't create a new one)
const QString fileName = dbFileName(accountName);
- // qDebug() << " fileName " << fileName;
+ qDebug() << " fileName " << fileName;
if (!QFileInfo::exists(fileName)) {
return {};
}
diff --git a/src/core/model/messagesmodel.cpp b/src/core/model/messagesmodel.cpp
index b88ac40a38..2c469137c2 100644
--- a/src/core/model/messagesmodel.cpp
+++ b/src/core/model/messagesmodel.cpp
@@ -436,6 +436,11 @@ QString MessagesModel::convertedText(const Message &message, const QString &sear
return message.systemMessageText();
} else if (message.messageType() == Message::EncryptedText) {
// TODO allow to decrypt message
+#if 0
+ qDebug() << "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD" << *message.messageEncrypted();
+ qDebug() << " xxxxxxxxxx " << message.messageEncrypted()->decrypt("a2e332e5-0b27-4d8f-82e8-66c833ae7860");
+#endif
+ // message.messageEncrypted()->decrypt()
return message.systemMessageText();
} else {
QStringList highlightWords;
diff --git a/src/core/rocketchataccount.cpp b/src/core/rocketchataccount.cpp
index 9f2c43f7d1..ba31c13e66 100644
--- a/src/core/rocketchataccount.cpp
+++ b/src/core/rocketchataccount.cpp
@@ -22,6 +22,7 @@
#include "notifications/notificationpreferences.h"
#include "rocketchataccountsettings.h"
#include "ruqola_database_debug.h"
+#include "ruqola_encryption_debug.h"
#include "ruqola_subscription_parsing_debug.h"
#include "ruqolautils.h"
#include "subscriptions/subscriptiongetonejob.h"
@@ -2758,12 +2759,32 @@ void RocketChatAccount::extractIdentifier(const QJsonObject &replyObject, const
void RocketChatAccount::slotCreateGroupDone(const QJsonObject &replyObject)
{
- extractIdentifier(replyObject, "group"_L1, "_id"_L1);
+ const QJsonObject group = replyObject["group"_L1].toObject();
+ const QString roomId = group["_id"_L1].toString();
+ if (!roomId.isEmpty()) {
+ Q_EMIT selectRoomByRoomIdRequested(roomId.toLatin1());
+ if (group["encrypted"_L1].toBool()) {
+ const QString existingKeyId = group["e2eKeyId"_L1].toString();
+ if (!mE2eKeyManager->initializeRoomE2EKey(roomId.toLatin1(), existingKeyId)) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "Impossible to initialize e2e for room " << roomId;
+ }
+ }
+ }
}
void RocketChatAccount::slotCreateChannelDone(const QJsonObject &replyObject)
{
- extractIdentifier(replyObject, "channel"_L1, "_id"_L1);
+ const QJsonObject channel = replyObject["channel"_L1].toObject();
+ const QString roomId = channel["_id"_L1].toString();
+ if (!roomId.isEmpty()) {
+ Q_EMIT selectRoomByRoomIdRequested(roomId.toLatin1());
+ if (channel["encrypted"_L1].toBool()) {
+ const QString existingKeyId = channel["e2eKeyId"_L1].toString();
+ if (!mE2eKeyManager->initializeRoomE2EKey(roomId.toLatin1(), existingKeyId)) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "Impossible to initialize e2e for room " << roomId;
+ }
+ }
+ }
}
void RocketChatAccount::slotPostMessageDone(const QJsonObject &replyObject)
diff --git a/src/rocketchatrestapi-qt/chat/postmessagejob.h b/src/rocketchatrestapi-qt/chat/postmessagejob.h
index d6f0eb3ef1..64385033b0 100644
--- a/src/rocketchatrestapi-qt/chat/postmessagejob.h
+++ b/src/rocketchatrestapi-qt/chat/postmessagejob.h
@@ -36,9 +36,9 @@ Q_SIGNALS:
protected:
[[nodiscard]] QString generateErrorMessage(const QString &errorStr) const override;
[[nodiscard]] QString errorMessage(const QString &str, const QJsonObject &details) override;
+ void onPostRequestResponse(const QString &replyErrorString, const QJsonDocument &replyJson) override;
private:
- LIBROCKETCHATRESTAPI_QT_NO_EXPORT void onPostRequestResponse(const QString &replyErrorString, const QJsonDocument &replyJson) override;
QList<QByteArray> mRoomIds;
QString mText;
};