[network/ruqola] src: Use GetUsersOfRoomWithoutKeyJob when we initialize room

Laurent Montel <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit a8b12e1396e5a6500dff7a3fac12e0778125e471 by Laurent Montel.
Committed on 06/08/2026 at 17:37.
Pushed by mlaurent into branch 'master'.

Use GetUsersOfRoomWithoutKeyJob when we initialize room

M  +4    -0    src/core/connection.cpp
M  +2    -0    src/core/connection.h
M  +151  -0    src/core/encryption/e2ekeymanager.cpp
M  +1    -0    src/core/encryption/e2ekeymanager.h
M  +4    -1    src/core/job/adduserinchanneljob.cpp
M  +1    -0    src/core/job/adduserinchanneljob.h
M  +7    -0    src/core/model/roommodel.cpp
M  +12   -0    src/core/rocketchataccount.cpp
M  +5    -1    src/rocketchatrestapi-qt/e2e/setroomkeyidjob.cpp
M  +1    -0    src/rocketchatrestapi-qt/e2e/setroomkeyidjob.h

https://invent.kde.org/network/ruqola/-/commit/a8b12e1396e5a6500dff7a3fac12e0778125e471

diff --git a/src/core/connection.cpp b/src/core/connection.cpp
index 57ea1fc674..71f1e2cfa0 100644
--- a/src/core/connection.cpp
+++ b/src/core/connection.cpp
@@ -515,6 +515,7 @@ void Connection::addUserInChannel(const QByteArray &roomId, const QByteArray &us
     };
     job->setInfo(info);
     connect(job, &AddUserInChannelJob::userNeedUnbanned, this, &Connection::userNeedUnbanned);
+    connect(job, &AddUserInChannelJob::addUserInChannelDone, this, &Connection::addUserInChannelDone);
     job->start();
 }
 
@@ -528,6 +529,9 @@ void Connection::addUserInGroup(const QByteArray &roomId, const QByteArray &user
     job->setChannelGroupInfo(info);
 
     job->setInviteUserId(QString::fromLatin1(userId));
+    connect(job, &GroupsInviteJob::inviteGroupsDone, this, [this, roomId, userId]() {
+        Q_EMIT addUserInGroupDone(roomId, userId);
+    });
     if (!job->start()) {
         qCWarning(RUQOLA_LOG) << "Impossible to start addUserInGroup job";
     }
diff --git a/src/core/connection.h b/src/core/connection.h
index a5d546aa3a..637d755fa4 100644
--- a/src/core/connection.h
+++ b/src/core/connection.h
@@ -176,6 +176,8 @@ Q_SIGNALS:
     void createChannelDone(const QJsonObject &replyObject);
     void createGroupDone(const QJsonObject &replyObject);
     void userNeedUnbanned(const AddUserInChannelJob::UserInChannelNeedUnBanJobInfo &info);
+    void addUserInChannelDone(const QByteArray &roomId, const QByteArray &userId);
+    void addUserInGroupDone(const QByteArray &roomId, const QByteArray &userId);
 
 private:
     LIBRUQOLACORE_NO_EXPORT void initializeCookies();
diff --git a/src/core/encryption/e2ekeymanager.cpp b/src/core/encryption/e2ekeymanager.cpp
index e36e6b7fc3..2d6be74ee6 100644
--- a/src/core/encryption/e2ekeymanager.cpp
+++ b/src/core/encryption/e2ekeymanager.cpp
@@ -8,6 +8,8 @@
 #include "config-ruqola.h"
 #include "connection.h"
 #include "e2e/fetchmykeysjob.h"
+#include "e2e/getusersofroomwithoutkeyjob.h"
+#include "e2e/provideuserswithsuggestedgroupkeysjob.h"
 #include "e2e/setroomkeyidjob.h"
 #include "e2e/setuserpublicandprivatekeysjob.h"
 #include "e2e/updategroupkeyjob.h"
@@ -26,9 +28,11 @@
 #include <qt6keychain/keychain.h>
 
 #include <QByteArray>
+#include <QJsonArray>
 #include <QJsonDocument>
 #include <QJsonObject>
 #include <QJsonValue>
+#include <QTimer>
 using namespace QKeychain;
 using namespace Qt::Literals::StringLiterals;
 
@@ -291,6 +295,30 @@ bool E2eKeyManager::initializeRoomE2EKey([[maybe_unused]] const QByteArray &room
         connect(setKeyIdJob, &RocketChatRestApi::SetRoomKeyIDJob::setRoomKeyIdDone, this, [sessionKey, keyId, roomId, this]() {
             distributeRoomSessionKey(roomId, sessionKey, keyId);
         });
+        connect(setKeyIdJob, &RocketChatRestApi::SetRoomKeyIDJob::roomKeyIdAlreadyExists, this, [sessionKey, roomId, this]() {
+            const auto tryDistributeUsingServerKeyId = [this, roomId, sessionKey](int delayMs) {
+                QTimer::singleShot(delayMs, this, [this, roomId, sessionKey]() {
+                    Room *const room = mAccount->room(roomId);
+                    if (!room) {
+                        qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: unable to recover from key-id race, room not found" << roomId;
+                        return;
+                    }
+
+                    const QString serverKeyId = room->e2eKeyId();
+                    if (serverKeyId.isEmpty()) {
+                        qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: waiting for server key-id update after race" << roomId;
+                        return;
+                    }
+
+                    qCDebug(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: recovered room key-id after race" << serverKeyId << "for" << roomId;
+                    distributeRoomSessionKey(roomId, sessionKey, serverKeyId);
+                });
+            };
+
+            // Room updates are asynchronous; try shortly after the conflict to reuse server key id.
+            tryDistributeUsingServerKeyId(250);
+            tryDistributeUsingServerKeyId(1000);
+        });
         if (!setKeyIdJob->start()) {
             qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: failed to start SetRoomKeyIDJob";
             return false;
@@ -305,6 +333,45 @@ bool E2eKeyManager::initializeRoomE2EKey([[maybe_unused]] const QByteArray &room
     return false;
 #endif
 }
+
+bool E2eKeyManager::distributeExistingRoomE2EKey(const QByteArray &roomId)
+{
+#if USE_E2E_SUPPORT
+    if (!mAccount || roomId.isEmpty()) {
+        qCWarning(RUQOLA_ENCRYPTION_LOG) << "distributeExistingRoomE2EKey: invalid arguments";
+        return false;
+    }
+
+    if (mStatus != Status::KeyDecrypted || mDecodedPrivateKey.isEmpty()) {
+        qCWarning(RUQOLA_ENCRYPTION_LOG) << "distributeExistingRoomE2EKey: E2E key not yet decrypted";
+        return false;
+    }
+
+    Room *const room = mAccount->room(roomId);
+    if (!room || !room->encrypted()) {
+        return false;
+    }
+
+    const QByteArray sessionKey = room->sessionKey();
+    if (sessionKey.isEmpty()) {
+        qCWarning(RUQOLA_ENCRYPTION_LOG) << "distributeExistingRoomE2EKey: missing room session key for" << roomId;
+        return false;
+    }
+
+    const QString keyId = room->e2eKeyId();
+    if (keyId.isEmpty()) {
+        qCWarning(RUQOLA_ENCRYPTION_LOG) << "distributeExistingRoomE2EKey: missing room key id for" << roomId;
+        return false;
+    }
+
+    distributeRoomSessionKey(roomId, sessionKey, keyId);
+    return true;
+#else
+    Q_UNUSED(roomId)
+    return false;
+#endif
+}
+
 void E2eKeyManager::distributeRoomSessionKey([[maybe_unused]] const QByteArray &roomId,
                                              [[maybe_unused]] const QByteArray &sessionKey,
                                              [[maybe_unused]] const QString &keyId)
@@ -343,6 +410,16 @@ void E2eKeyManager::distributeRoomSessionKey([[maybe_unused]] const QByteArray &
         return;
     }
 
+    // Prime the local room state immediately so the sender can encrypt outgoing
+    // messages without waiting for asynchronous server subscription updates.
+    if (Room *const room = mAccount->room(roomId)) {
+        room->setE2eKeyId(keyId);
+        room->setE2EKey(keyId + QString::fromLatin1(encryptedSessionKey.toBase64()));
+        if (!decryptRoomSessionKeys(room)) {
+            qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: unable to decrypt local room session key immediately for" << roomId;
+        }
+    }
+
     // Store the encrypted session key in the user's subscription on the server.
     auto updateKeyJob = new RocketChatRestApi::UpdateGroupKeyJob(this);
     mAccount->restApi()->initializeRestApiJob(updateKeyJob);
@@ -356,6 +433,80 @@ void E2eKeyManager::distributeRoomSessionKey([[maybe_unused]] const QByteArray &
         return;
     }
 
+    // Share this room key with users that do not have a key yet.
+    auto usersWithoutKeyJob = new RocketChatRestApi::GetUsersOfRoomWithoutKeyJob(this);
+    mAccount->restApi()->initializeRestApiJob(usersWithoutKeyJob);
+    usersWithoutKeyJob->setRoomId(roomId);
+    connect(usersWithoutKeyJob,
+            &RocketChatRestApi::GetUsersOfRoomWithoutKeyJob::getUsersOfRoomWithoutKeyDone,
+            this,
+            [this, roomId, sessionKey, keyId](const QJsonObject &obj) {
+                const QJsonArray users = obj.value("users"_L1).toArray();
+                if (users.isEmpty()) {
+                    return;
+                }
+
+                const QString ownUserId = QString::fromLatin1(mAccount->settings()->userId());
+                QVector<RocketChatRestApi::SuggestedGroupKey> suggestedKeys;
+                suggestedKeys.reserve(users.size());
+
+                for (const QJsonValue &userValue : users) {
+                    const QJsonObject userObj = userValue.toObject();
+                    const QString targetUserId = userObj.value("_id"_L1).toString();
+                    if (targetUserId.isEmpty() || targetUserId == ownUserId) {
+                        continue;
+                    }
+
+                    const QString publicKey = userObj.value("e2e"_L1).toObject().value("public_key"_L1).toString();
+                    if (publicKey.isEmpty()) {
+                        continue;
+                    }
+
+                    QByteArray publicKeyPem = publicKey.toUtf8();
+                    if (publicKeyPem.trimmed().startsWith('{')) {
+                        publicKeyPem = EncryptionUtils::publicKeyJWKToPEM(publicKeyPem);
+                    }
+                    if (publicKeyPem.isEmpty()) {
+                        qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: unable to resolve recipient public key for" << targetUserId;
+                        continue;
+                    }
+
+                    RSA *targetRsaPublicKey = EncryptionUtils::publicKeyFromPEM(publicKeyPem);
+                    if (!targetRsaPublicKey) {
+                        qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: unable to parse recipient public key for" << targetUserId;
+                        continue;
+                    }
+
+                    const QByteArray encryptedRecipientSessionKey = EncryptionUtils::encryptSessionKey(sessionKey, targetRsaPublicKey);
+                    RSA_free(targetRsaPublicKey);
+                    if (encryptedRecipientSessionKey.isEmpty()) {
+                        qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: unable to encrypt room key for" << targetUserId;
+                        continue;
+                    }
+
+                    suggestedKeys.append({
+                        targetUserId,
+                        keyId + QString::fromLatin1(encryptedRecipientSessionKey.toBase64()),
+                    });
+                }
+
+                if (suggestedKeys.isEmpty()) {
+                    return;
+                }
+
+                auto provideJob = new RocketChatRestApi::ProvideUsersWithSuggestedGroupKeysJob(this);
+                mAccount->restApi()->initializeRestApiJob(provideJob);
+                provideJob->setRoomId(QString::fromLatin1(roomId));
+                provideJob->setKeys(suggestedKeys);
+                if (!provideJob->start()) {
+                    qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: failed to start ProvideUsersWithSuggestedGroupKeysJob for room" << roomId;
+                }
+            });
+
+    if (!usersWithoutKeyJob->start()) {
+        qCWarning(RUQOLA_ENCRYPTION_LOG) << "initializeRoomE2EKey: failed to start GetUsersOfRoomWithoutKeyJob for room" << roomId;
+    }
+
     // 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),
diff --git a/src/core/encryption/e2ekeymanager.h b/src/core/encryption/e2ekeymanager.h
index 3209b5f4d0..f939770952 100644
--- a/src/core/encryption/e2ekeymanager.h
+++ b/src/core/encryption/e2ekeymanager.h
@@ -38,6 +38,7 @@ public:
 
     void fetchMyKeys();
     [[nodiscard]] bool initializeRoomE2EKey(const QByteArray &roomId, const QString &existingKeyId = {});
+    [[nodiscard]] bool distributeExistingRoomE2EKey(const QByteArray &roomId);
 
     [[nodiscard]] E2eKeyManager::Status needToDecodeEncryptionKey() const;
 
diff --git a/src/core/job/adduserinchanneljob.cpp b/src/core/job/adduserinchanneljob.cpp
index 6c50252145..c040fa310e 100644
--- a/src/core/job/adduserinchanneljob.cpp
+++ b/src/core/job/adduserinchanneljob.cpp
@@ -41,7 +41,10 @@ void AddUserInChannelJob::start()
     };
     job->setChannelInviteInfo(inviteInfo);
     connect(job, &ChannelInviteJob::needUnbanned, this, &AddUserInChannelJob::slotNeedUnbanned);
-    connect(job, &ChannelInviteJob::inviteDone, this, &AddUserInChannelJob::deleteLater);
+    connect(job, &ChannelInviteJob::inviteDone, this, [this]() {
+        Q_EMIT addUserInChannelDone(mInfo.roomId, mInfo.userId);
+        deleteLater();
+    });
     if (!job->start()) {
         qCWarning(RUQOLA_LOG) << "Impossible to start addUserInChannel job";
         deleteLater();
diff --git a/src/core/job/adduserinchanneljob.h b/src/core/job/adduserinchanneljob.h
index f78fd8653d..ca20c6093c 100644
--- a/src/core/job/adduserinchanneljob.h
+++ b/src/core/job/adduserinchanneljob.h
@@ -38,6 +38,7 @@ public:
 
 Q_SIGNALS:
     void userNeedUnbanned(const AddUserInChannelJob::UserInChannelNeedUnBanJobInfo &info);
+    void addUserInChannelDone(const QByteArray &roomId, const QByteArray &userId);
 
 private:
     LIBRUQOLACORE_NO_EXPORT void slotNeedUnbanned(const RocketChatRestApi::ChannelInviteJob::ChannelInviteInfo &info);
diff --git a/src/core/model/roommodel.cpp b/src/core/model/roommodel.cpp
index 360498e5ff..cc60db62fc 100644
--- a/src/core/model/roommodel.cpp
+++ b/src/core/model/roommodel.cpp
@@ -8,6 +8,7 @@
 
 #include "roommodel.h"
 #include "accountroomsettings.h"
+#include "encryption/e2ekeymanager.h"
 #include "localdatabase/localdatabasemanager.h"
 #include "rocketchataccount.h"
 #include "ruqola_rooms_debug.h"
@@ -283,6 +284,9 @@ QByteArray RoomModel::updateSubscriptionRoom(const QJsonObject &roomData)
             if (room->roomId() == rId) {
                 qCDebug(RUQOLA_ROOMS_LOG) << " void RoomModel::updateSubscriptionRoom(const QJsonArray &array) room found:" << room->roomId();
                 room->updateSubscriptionRoom(roomData);
+                if (mRocketChatAccount) {
+                    (void)mRocketChatAccount->e2eKeyManager()->decryptRoomSessionKeys(room);
+                }
                 Q_EMIT dataChanged(createIndex(i, 0), createIndex(i, 0));
 
                 break;
@@ -487,6 +491,9 @@ QByteArray RoomModel::updateRoom(const QJsonObject &roomData)
             if (room->roomId() == rId) {
                 qCDebug(RUQOLA_ROOMS_LOG) << " void RoomModel::updateRoom(const QJsonArray &array) room found:" << rId;
                 room->parseUpdateRoom(roomData);
+                if (mRocketChatAccount) {
+                    (void)mRocketChatAccount->e2eKeyManager()->decryptRoomSessionKeys(room);
+                }
                 Q_EMIT dataChanged(createIndex(i, 0), createIndex(i, 0));
                 roomFound = true;
                 break;
diff --git a/src/core/rocketchataccount.cpp b/src/core/rocketchataccount.cpp
index 52e8e6d52a..fd9c0f0eb8 100644
--- a/src/core/rocketchataccount.cpp
+++ b/src/core/rocketchataccount.cpp
@@ -639,6 +639,18 @@ Connection *RocketChatAccount::restApi()
         connect(mRestApi.get(), &Connection::channelGetCountersDone, this, &RocketChatAccount::slotChannelGetCountersDone);
         connect(mRestApi.get(), &Connection::permissionListAllDone, this, &RocketChatAccount::slotPermissionListAllDone);
         connect(mRestApi.get(), &Connection::usersSetPreferencesDone, this, &RocketChatAccount::slotUsersSetPreferencesDone);
+        const auto redistributeRoomKeyIfEncrypted = [this](const QByteArray &roomId, const QByteArray &userId) {
+            Q_UNUSED(userId)
+            Room *const r = room(roomId);
+            if (!r || !r->encrypted()) {
+                return;
+            }
+            if (!mE2eKeyManager->distributeExistingRoomE2EKey(roomId)) {
+                qCWarning(RUQOLA_ENCRYPTION_LOG) << debugCategoryAccountName() << "Failed to redistribute E2E key for room" << roomId;
+            }
+        };
+        connect(mRestApi.get(), &Connection::addUserInChannelDone, this, redistributeRoomKeyIfEncrypted);
+        connect(mRestApi.get(), &Connection::addUserInGroupDone, this, redistributeRoomKeyIfEncrypted);
         connect(mRestApi.get(), &Connection::networkError, this, [this]() {
             // Transient error, try again, with an increasing delay
             qCDebug(RUQOLA_RECONNECT_LOG) << debugCategoryAccountName() << "networkError" << accountName();
diff --git a/src/rocketchatrestapi-qt/e2e/setroomkeyidjob.cpp b/src/rocketchatrestapi-qt/e2e/setroomkeyidjob.cpp
index 8d0116d4ef..6a86df8a01 100644
--- a/src/rocketchatrestapi-qt/e2e/setroomkeyidjob.cpp
+++ b/src/rocketchatrestapi-qt/e2e/setroomkeyidjob.cpp
@@ -37,10 +37,14 @@ bool SetRoomKeyIDJob::start()
 void SetRoomKeyIDJob::onPostRequestResponse(const QString &replyErrorString, const QJsonDocument &replyJson)
 {
     const QJsonObject replyObject = replyJson.object();
-    qDebug() << " replyObject " << replyObject;
+
     if (replyObject["success"_L1].toBool()) {
         addLoggerInfo("SetRoomKeyIDJob: success: "_ba + replyJson.toJson(QJsonDocument::Indented));
         Q_EMIT setRoomKeyIdDone();
+    } else if (replyObject["errorType"_L1].toString() == "error-room-e2e-key-already-exists"_L1) {
+        // Another client won the room key race. Let callers recover using the server key id.
+        addLoggerInfo("SetRoomKeyIDJob: room key already exists, recovery path will be used"_ba);
+        Q_EMIT roomKeyIdAlreadyExists();
     } else {
         emitFailedMessage(replyErrorString, replyObject);
         addLoggerWarning("SetRoomKeyIDJob: Problem: "_ba + replyJson.toJson(QJsonDocument::Indented));
diff --git a/src/rocketchatrestapi-qt/e2e/setroomkeyidjob.h b/src/rocketchatrestapi-qt/e2e/setroomkeyidjob.h
index b440fd4a4a..9a3c57ff7e 100644
--- a/src/rocketchatrestapi-qt/e2e/setroomkeyidjob.h
+++ b/src/rocketchatrestapi-qt/e2e/setroomkeyidjob.h
@@ -36,6 +36,7 @@ public:
 
 Q_SIGNALS:
     void setRoomKeyIdDone();
+    void roomKeyIdAlreadyExists();
 
 protected:
     void onPostRequestResponse(const QString &replyErrorString, const QJsonDocument &replyJson) override;
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.