[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;
 };