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