[network/ruqola] src/core/encryption: Fix decode encryption key
Laurent Montel <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit 6474b39c69a0ea166a7ae641951060a44688539e by Laurent Montel.
Committed on 03/08/2026 at 06:53.
Pushed by mlaurent into branch 'master'.
Fix decode encryption key
M +110 -11 src/core/encryption/e2ekeymanager.cpp
M +147 -0 src/core/encryption/encryptionutils.cpp
M +2 -0 src/core/encryption/encryptionutils.h
https://invent.kde.org/network/ruqola/-/commit/6474b39c69a0ea166a7ae641951060a44688539e
diff --git a/src/core/encryption/e2ekeymanager.cpp b/src/core/encryption/e2ekeymanager.cpp
index f40138257f..791015aab6 100644
--- a/src/core/encryption/e2ekeymanager.cpp
+++ b/src/core/encryption/e2ekeymanager.cpp
@@ -20,6 +20,8 @@
#include "ruqolaserverconfig.h"
#include <QByteArray>
+#include <QJsonDocument>
+#include <QJsonObject>
#include <QJsonValue>
using namespace Qt::Literals::StringLiterals;
@@ -65,15 +67,76 @@ bool E2eKeyManager::decodeEncryptionKey(const QString &password)
return false;
}
- const QByteArray masterKey = EncryptionUtils::getMasterKey(password, userId);
- if (masterKey.isEmpty()) {
+ // Decrypt the stored private key. Two storage layouts are possible:
+ //
+ // V2 JSON – starts with '{'; contains its own PBKDF2 salt/iterations
+ // and was encrypted with AES-GCM.
+ // Binary – raw bytes: iv[16] + AES-CBC-256 ciphertext; PBKDF2 uses
+ // the userId as salt with 1 000 iterations.
+ //
+ // After decryption, the plaintext may be:
+ // • JWK JSON (starts with '{') – produced by Rocket.Chat web/mobile
+ // • PEM – produced by Ruqola itself
+ QByteArray decryptedPrivateKey;
+ if (encryptedPrivateKey.startsWith('{')) {
+ // ── V2 format (AES-GCM) ─────────────────────────────────────────────
+ const QJsonDocument doc = QJsonDocument::fromJson(encryptedPrivateKey);
+ if (doc.isNull() || !doc.isObject()) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "Unable to parse V2 encrypted private key JSON";
+ setStatus(Status::NeedToDecryptKey);
+ Q_EMIT failedDecodeEncryptionKey();
+ return false;
+ }
+ const QJsonObject v2 = doc.object();
+ const QString v2Salt = v2.value(QStringLiteral("salt")).toString();
+ const int v2Iterations = v2.value(QStringLiteral("iterations")).toInt();
+ const QByteArray v2Iv = QByteArray::fromBase64(v2.value(QStringLiteral("iv")).toString().toUtf8());
+ const QByteArray v2Ciphertext = QByteArray::fromBase64(v2.value(QStringLiteral("ciphertext")).toString().toUtf8());
+
+ if (v2Salt.isEmpty() || v2Iterations <= 0 || v2Iv.isEmpty() || v2Ciphertext.isEmpty()) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "V2 encrypted private key has missing fields";
+ setStatus(Status::NeedToDecryptKey);
+ Q_EMIT failedDecodeEncryptionKey();
+ return false;
+ }
+
+ const QByteArray v2MasterKey = EncryptionUtils::deriveKey(v2Salt.toUtf8(), password.toUtf8(), v2Iterations, 32);
+ if (v2MasterKey.isEmpty()) {
+ setStatus(Status::NeedToDecryptKey);
+ Q_EMIT failedDecodeEncryptionKey();
+ return false;
+ }
+
+ decryptedPrivateKey = EncryptionUtils::decryptAES_GCM_256(v2Ciphertext, v2MasterKey, v2Iv);
+ } else {
+ // ── V1 / oldest format (AES-CBC) ────────────────────────────────────
+ const QByteArray masterKey = EncryptionUtils::getMasterKey(password, userId);
+ if (masterKey.isEmpty()) {
+ setStatus(Status::NeedToDecryptKey);
+ Q_EMIT failedDecodeEncryptionKey();
+ return false;
+ }
+ decryptedPrivateKey = EncryptionUtils::decryptPrivateKey(encryptedPrivateKey, masterKey);
+ }
+
+ if (decryptedPrivateKey.isEmpty()) {
setStatus(Status::NeedToDecryptKey);
Q_EMIT failedDecodeEncryptionKey();
return false;
}
- const QByteArray privateKeyPem = EncryptionUtils::decryptPrivateKey(encryptedPrivateKey, masterKey);
+ // Convert decrypted bytes to PEM.
+ // Rocket.Chat web/mobile encrypts the private key serialised as JWK JSON;
+ // Ruqola-generated keys are already PEM.
+ QByteArray privateKeyPem;
+ if (decryptedPrivateKey.startsWith('{')) {
+ privateKeyPem = EncryptionUtils::privateKeyJWKToPEM(decryptedPrivateKey);
+ } else {
+ privateKeyPem = decryptedPrivateKey;
+ }
+
if (privateKeyPem.isEmpty()) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "Unable to obtain PEM from decrypted private key";
setStatus(Status::NeedToDecryptKey);
Q_EMIT failedDecodeEncryptionKey();
return false;
@@ -160,19 +223,55 @@ void E2eKeyManager::fetchMyKeys()
void E2eKeyManager::verifyExistingKey(const QJsonObject &json)
{
+ // Decode the server's private_key field into the bytes we store locally.
+ //
+ // The field can arrive in several formats depending on the server version:
+ //
+ // Oldest : plain base64 string → binary (iv[16] + AES-CBC ciphertext)
+ // V1 : JSON object { "$binary": "<base64>" } → same binary layout
+ // V2 : JSON object { "iv":"…", "ciphertext":"…", "salt":"…",
+ // "iterations": N } → stored as compact JSON bytes
+ //
+ // We also handle the unusual case where the server serialises V1/V2 as a
+ // JSON *string* (i.e. the value is already JSON-stringified).
const auto decodeEncryptedPrivateKey = [](const QJsonValue &privateKeyValue) -> QByteArray {
- if (privateKeyValue.isString()) {
- const QByteArray privateKey = privateKeyValue.toString().toUtf8();
- const QByteArray decoded = QByteArray::fromBase64(privateKey);
- // Some server payloads can already be raw bytes serialized as UTF-8.
- return decoded.isEmpty() ? privateKey : decoded;
- }
- if (privateKeyValue.isObject()) {
- const QString binaryValue = privateKeyValue.toObject().value(QStringLiteral("$binary")).toString();
+ // Helper: process a QJsonObject for V1 ($binary) or V2 (iv/ciphertext)
+ const auto decodeObject = [](const QJsonObject &obj) -> QByteArray {
+ // V1: {"$binary": "<base64>"}
+ const QString binaryValue = obj.value(QStringLiteral("$binary")).toString();
if (!binaryValue.isEmpty()) {
return QByteArray::fromBase64(binaryValue.toUtf8());
}
+ // V2: {"iv":…, "ciphertext":…, "salt":…, "iterations":…}
+ if (obj.contains(QStringLiteral("iv")) && obj.contains(QStringLiteral("ciphertext")) && obj.contains(QStringLiteral("salt"))) {
+ return QJsonDocument(obj).toJson(QJsonDocument::Compact);
+ }
+ return {};
+ };
+
+ if (privateKeyValue.isObject()) {
+ return decodeObject(privateKeyValue.toObject());
}
+
+ if (privateKeyValue.isString()) {
+ const QString str = privateKeyValue.toString();
+ const QByteArray strBytes = str.toUtf8();
+
+ // Check whether the string is itself a JSON object (server stringified it)
+ if (str.startsWith(QLatin1Char('{'))) {
+ const QJsonDocument doc = QJsonDocument::fromJson(strBytes);
+ if (!doc.isNull() && doc.isObject()) {
+ const QByteArray result = decodeObject(doc.object());
+ if (!result.isEmpty())
+ return result;
+ }
+ }
+
+ // Oldest format: plain base64 string → binary (iv + ciphertext)
+ const QByteArray decoded = QByteArray::fromBase64(strBytes);
+ return decoded.isEmpty() ? strBytes : decoded;
+ }
+
return {};
};
diff --git a/src/core/encryption/encryptionutils.cpp b/src/core/encryption/encryptionutils.cpp
index 31085f4a49..c1fee24a11 100644
--- a/src/core/encryption/encryptionutils.cpp
+++ b/src/core/encryption/encryptionutils.cpp
@@ -13,6 +13,7 @@
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
+#include <QJsonParseError>
#include <QRandomGenerator>
using namespace Qt::Literals::StringLiterals;
@@ -203,6 +204,7 @@ QByteArray EncryptionUtils::encryptPrivateKey(const QByteArray &privateKey, cons
QByteArray EncryptionUtils::decryptPrivateKey(const QByteArray &encryptedPrivateKey, const QByteArray &masterKey)
{
+ qDebug() << " encryptedPrivateKey " << encryptedPrivateKey << " masterKey " << masterKey;
if (encryptedPrivateKey.isEmpty()) {
qCWarning(RUQOLA_ENCRYPTION_LOG) << "Encrypted private key is empty";
return {};
@@ -216,6 +218,7 @@ QByteArray EncryptionUtils::decryptPrivateKey(const QByteArray &encryptedPrivate
const QByteArray iv = encryptedPrivateKey.left(16);
const QByteArray cipherText = encryptedPrivateKey.mid(16);
+ qDebug() << " iv " << iv << " cipherText " << cipherText;
if (iv.isEmpty()) {
qCWarning(RUQOLA_ENCRYPTION_LOG) << "Decryption of the private key failed, 'iv' is empty";
return {};
@@ -464,6 +467,150 @@ QByteArray EncryptionUtils::decryptMessage(const QByteArray &encrypted, const QB
return plainText;
}
+QByteArray EncryptionUtils::decryptAES_GCM_256(const QByteArray &ciphertext, const QByteArray &key, const QByteArray &iv)
+{
+ // AES-GCM: Web Crypto appends the 16-byte authentication tag after the ciphertext.
+ constexpr int tagLen = 16;
+ if (ciphertext.size() <= tagLen) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "decryptAES_GCM_256: ciphertext too short";
+ return {};
+ }
+
+ const QByteArray data = ciphertext.left(ciphertext.size() - tagLen);
+ const QByteArray tag = ciphertext.right(tagLen);
+
+ EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
+ if (!ctx) {
+ return {};
+ }
+
+ if (1 != EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr)) {
+ EVP_CIPHER_CTX_free(ctx);
+ return {};
+ }
+ if (1 != EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, iv.size(), nullptr)) {
+ EVP_CIPHER_CTX_free(ctx);
+ return {};
+ }
+ if (1
+ != EVP_DecryptInit_ex(ctx, nullptr, nullptr, reinterpret_cast<const unsigned char *>(key.data()), reinterpret_cast<const unsigned char *>(iv.data()))) {
+ EVP_CIPHER_CTX_free(ctx);
+ return {};
+ }
+
+ QByteArray plaintext(data.size(), 0);
+ int len = 0;
+ if (1
+ != EVP_DecryptUpdate(ctx,
+ reinterpret_cast<unsigned char *>(plaintext.data()),
+ &len,
+ reinterpret_cast<const unsigned char *>(data.data()),
+ data.size())) {
+ EVP_CIPHER_CTX_free(ctx);
+ return {};
+ }
+ int plaintextLen = len;
+
+ if (1 != EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, tagLen, const_cast<char *>(tag.data()))) {
+ EVP_CIPHER_CTX_free(ctx);
+ return {};
+ }
+
+ if (EVP_DecryptFinal_ex(ctx, reinterpret_cast<unsigned char *>(plaintext.data()) + plaintextLen, &len) <= 0) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "decryptAES_GCM_256: authentication tag verification failed";
+ EVP_CIPHER_CTX_free(ctx);
+ return {};
+ }
+ plaintextLen += len;
+ plaintext.resize(plaintextLen);
+
+ EVP_CIPHER_CTX_free(ctx);
+ return plaintext;
+}
+
+/**
+ * @brief Converts a JWK RSA private key JSON to PEM format.
+ *
+ * Rocket.Chat encrypts the private key as JWK JSON (not PEM). This function
+ * reconstructs the OpenSSL RSA key from the JWK components and serialises it
+ * as a PKCS#1 PEM string so that the rest of the code can use it uniformly.
+ *
+ * @param jwkJson UTF-8 encoded JSON containing at minimum the keys:
+ * kty, n, e, d, p, q, dp, dq, qi (all base64url-encoded BIGNUMs).
+ * @return PEM-encoded private key, or empty on error.
+ */
+QByteArray EncryptionUtils::privateKeyJWKToPEM(const QByteArray &jwkJson)
+{
+ const QJsonDocument doc = QJsonDocument::fromJson(jwkJson);
+ if (doc.isNull() || !doc.isObject()) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "privateKeyJWKToPEM: invalid JSON";
+ return {};
+ }
+ const QJsonObject obj = doc.object();
+ if (obj.value(QStringLiteral("kty")).toString() != QLatin1String("RSA")) {
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "privateKeyJWKToPEM: not an RSA key";
+ return {};
+ }
+
+ // Helper: base64url → BIGNUM
+ const auto b64urlToBN = [](const QString &b64url) -> BIGNUM * {
+ // Normalise: base64url → standard base64 with padding
+ 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());
+ BIGNUM *d = b64urlToBN(obj.value(QStringLiteral("d")).toString());
+ BIGNUM *p = b64urlToBN(obj.value(QStringLiteral("p")).toString());
+ BIGNUM *q = b64urlToBN(obj.value(QStringLiteral("q")).toString());
+ BIGNUM *dp = b64urlToBN(obj.value(QStringLiteral("dp")).toString());
+ BIGNUM *dq = b64urlToBN(obj.value(QStringLiteral("dq")).toString());
+ BIGNUM *qi = b64urlToBN(obj.value(QStringLiteral("qi")).toString());
+
+ if (!n || !e || !d) {
+ BN_free(n);
+ BN_free(e);
+ BN_free(d);
+ BN_free(p);
+ BN_free(q);
+ BN_free(dp);
+ BN_free(dq);
+ BN_free(qi);
+ qCWarning(RUQOLA_ENCRYPTION_LOG) << "privateKeyJWKToPEM: missing required key components";
+ return {};
+ }
+
+ RSA *rsa = RSA_new();
+ // RSA_set0_* transfers ownership of the BIGNUMs to rsa
+ RSA_set0_key(rsa, n, e, d);
+ if (p && q)
+ RSA_set0_factors(rsa, p, q);
+ if (dp && dq && qi)
+ RSA_set0_crt_params(rsa, dp, dq, qi);
+
+ BIO *bio = BIO_new(BIO_s_mem());
+ if (!bio) {
+ RSA_free(rsa);
+ return {};
+ }
+ PEM_write_bio_RSAPrivateKey(bio, rsa, nullptr, nullptr, 0, nullptr, nullptr);
+
+ BUF_MEM *buf = nullptr;
+ BIO_get_mem_ptr(bio, &buf);
+ const QByteArray pem(buf->data, static_cast<qsizetype>(buf->length));
+
+ BIO_free(bio);
+ RSA_free(rsa);
+ 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 832716dc2b..76d17f18eb 100644
--- a/src/core/encryption/encryptionutils.h
+++ b/src/core/encryption/encryptionutils.h
@@ -37,6 +37,8 @@ struct RSAKeyPair {
[[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 decryptAES_GCM_256(const QByteArray &ciphertext, const QByteArray &key, const QByteArray &iv);
+[[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray privateKeyJWKToPEM(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);