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