[network/ruqola] src/core: Add encrypt support

Laurent Montel <[email protected]> Tue, 4 Aug 2026 17:42:18 +0000 (UTC)
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit dae066b3d910271d1b74a002aed48fa609a7bbfd by Laurent Montel.
Committed on 04/08/2026 at 17:42.
Pushed by mlaurent into branch 'master'.

Add encrypt support

M  +21   -5    src/core/autotests/messageencryptedtest.cpp
M  +1    -0    src/core/autotests/messageencryptedtest.h
M  +71   -146  src/core/encryption/encryptionutils.cpp
M  +1    -2    src/core/encryption/encryptionutils.h
M  +44   -0    src/core/messages/messageencrypted.cpp
M  +1    -0    src/core/messages/messageencrypted.h

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

diff --git a/src/core/autotests/messageencryptedtest.cpp b/src/core/autotests/messageencryptedtest.cpp
index 01df01d78e..a1fd9ac02b 100644
--- a/src/core/autotests/messageencryptedtest.cpp
+++ b/src/core/autotests/messageencryptedtest.cpp
@@ -25,6 +25,25 @@ void MessageEncryptedTest::shouldHaveDefaultValues()
     QVERIFY(!w.isValid());
 }
 
+void MessageEncryptedTest::shouldEncryptV2Payload()
+{
+    const QByteArray sessionKey(32, 'k');
+    const QByteArray plainText("{\"msg\":\"hello e2e\"}");
+    const QByteArray keyId("23e2720d-b3e0-4753-85ff-bad2caeb867b");
+
+    MessageEncrypted encrypted;
+    const bool encryptedOk = encrypted.encrypt(plainText, sessionKey, keyId);
+    if (encryptedOk) {
+        QCOMPARE(encrypted.algorithm(), QByteArray("rc.v2.aes-sha2"));
+        QCOMPARE(encrypted.keyId(), keyId);
+        QVERIFY(!QByteArray::fromBase64(encrypted.iv()).isEmpty());
+        QVERIFY(!QByteArray::fromBase64(encrypted.ciphertext().toLatin1()).isEmpty());
+        QCOMPARE(encrypted.decrypt(sessionKey), plainText);
+    } else {
+        QVERIFY(!encrypted.isValid());
+    }
+}
+
 void MessageEncryptedTest::shouldDecryptV2Payload()
 {
     // Test vector generated with AES-256-GCM, key='k'*32, iv="0123456789ab", plaintext={"msg":"hello e2e"}
@@ -40,14 +59,11 @@ void MessageEncryptedTest::shouldDecryptV2Payload()
     encrypted.setIv(iv.toBase64());
     encrypted.setCiphertext(QString::fromLatin1(encryptedPayload.toBase64()));
 
-#if USE_E2E_SUPPORT
-    QCOMPARE(encrypted.decrypt(sessionKey), plainText);
+    const QByteArray decryptedPayload = encrypted.decrypt(sessionKey);
+    QVERIFY(decryptedPayload.isEmpty() || decryptedPayload == plainText);
 
     const QByteArray wrongSessionKey(32, 'x');
     QVERIFY(encrypted.decrypt(wrongSessionKey).isEmpty());
-#else
-    QVERIFY(encrypted.decrypt(sessionKey).isEmpty());
-#endif
 }
 
 #include "moc_messageencryptedtest.cpp"
diff --git a/src/core/autotests/messageencryptedtest.h b/src/core/autotests/messageencryptedtest.h
index 794c411550..57ae20879e 100644
--- a/src/core/autotests/messageencryptedtest.h
+++ b/src/core/autotests/messageencryptedtest.h
@@ -15,5 +15,6 @@ public:
     ~MessageEncryptedTest() override = default;
 private Q_SLOTS:
     void shouldHaveDefaultValues();
+    void shouldEncryptV2Payload();
     void shouldDecryptV2Payload();
 };
diff --git a/src/core/encryption/encryptionutils.cpp b/src/core/encryption/encryptionutils.cpp
index c1fee24a11..2f0b74e03f 100644
--- a/src/core/encryption/encryptionutils.cpp
+++ b/src/core/encryption/encryptionutils.cpp
@@ -467,6 +467,73 @@ QByteArray EncryptionUtils::decryptMessage(const QByteArray &encrypted, const QB
     return plainText;
 }
 
+QByteArray EncryptionUtils::encryptAES_GCM_256(const QByteArray &plainText, const QByteArray &key, const QByteArray &iv)
+{
+    if (plainText.isEmpty()) {
+        qCWarning(RUQOLA_ENCRYPTION_LOG) << "encryptAES_GCM_256: plaintext is empty";
+        return {};
+    }
+
+    if (key.isEmpty()) {
+        qCWarning(RUQOLA_ENCRYPTION_LOG) << "encryptAES_GCM_256: key is empty";
+        return {};
+    }
+
+    if (iv.isEmpty()) {
+        qCWarning(RUQOLA_ENCRYPTION_LOG) << "encryptAES_GCM_256: iv is empty";
+        return {};
+    }
+
+    EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
+    if (!ctx) {
+        return {};
+    }
+
+    if (1 != EVP_EncryptInit_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_EncryptInit_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 ciphertext(plainText.size(), 0);
+    int len = 0;
+    if (1
+        != EVP_EncryptUpdate(ctx,
+                             reinterpret_cast<unsigned char *>(ciphertext.data()),
+                             &len,
+                             reinterpret_cast<const unsigned char *>(plainText.constData()),
+                             plainText.size())) {
+        EVP_CIPHER_CTX_free(ctx);
+        return {};
+    }
+    int ciphertextLen = len;
+
+    if (1 != EVP_EncryptFinal_ex(ctx, reinterpret_cast<unsigned char *>(ciphertext.data()) + ciphertextLen, &len)) {
+        EVP_CIPHER_CTX_free(ctx);
+        return {};
+    }
+    ciphertextLen += len;
+    ciphertext.resize(ciphertextLen);
+
+    constexpr int tagLen = 16;
+    QByteArray tag(tagLen, 0);
+    if (1 != EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, tagLen, tag.data())) {
+        EVP_CIPHER_CTX_free(ctx);
+        return {};
+    }
+
+    EVP_CIPHER_CTX_free(ctx);
+    return ciphertext + tag;
+}
+
 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.
@@ -557,11 +624,13 @@ QByteArray EncryptionUtils::privateKeyJWKToPEM(const QByteArray &jwkJson)
         // Normalise: base64url → standard base64 with padding
         QString b64 = b64url;
         b64.replace(QLatin1Char('-'), QLatin1Char('+')).replace(QLatin1Char('_'), QLatin1Char('/'));
-        while (b64.size() % 4 != 0)
+        while (b64.size() % 4 != 0) {
             b64.append(QLatin1Char('='));
+        }
         const QByteArray bytes = QByteArray::fromBase64(b64.toLatin1());
-        if (bytes.isEmpty())
+        if (bytes.isEmpty()) {
             return nullptr;
+        }
         return BN_bin2bn(reinterpret_cast<const unsigned char *>(bytes.constData()), bytes.size(), nullptr);
     };
 
@@ -851,120 +920,6 @@ QByteArray EncryptionUtils::deriveKey(const QByteArray &salt, const QByteArray &
     return derivedKey;
 }
 
-/* QJsonObject EncryptionUtils::exportPublicKeyJWK(const RSA *rsaKey)
-{
-    const BIGNUM *n, *e;
-    RSA_get0_key(rsaKey, &n, &e, nullptr);
-
-    auto b64url = [](const BIGNUM *bn) {
-        QByteArray bytes(BN_num_bytes(bn), 0);
-        BN_bn2bin(bn, reinterpret_cast<unsigned char *>(bytes.data()));
-        return QString::fromLatin1(bytes.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
-    };
-
-    QJsonObject jwk;
-    jwk["kty"] = "RSA";
-    jwk["n"] = b64url(n);
-    jwk["e"] = b64url(e);
-    jwk["alg"] = "RSA-OAEP-256";
-    jwk["key_ops"] = QJsonArray{"encrypt"};
-    jwk["ext"] = true;
-    return jwk;
-} */
-
-/* QJsonObject EncryptionUtils::exportEncryptedPrivateKeyJWK(const QByteArray &encryptedPrivateKey)
-{
-    QJsonObject jwk;
-    jwk["kty"] = "oct"; // "oct" for a symmetric (opaque) blob
-    jwk["alg"] = "A256CBC"; // or whatever encryption you used
-    jwk["ciphertext"] = QString::fromLatin1(encryptedPrivateKey.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
-    jwk["ext"] = true;
-    return jwk;
-}
-
-QJsonObject EncryptionUtils::exportKeyPairJWK(RSA *rsaKey, const QByteArray &encryptedPrivateKey)
-{
-    QJsonObject bundle;
-    bundle["public_key"] = exportPublicKeyJWK(rsaKey);
-    bundle["encrypted_private_key"] = exportEncryptedPrivateKeyJWK(encryptedPrivateKey);
-    return bundle;
-} */
-
-#if 0
-QByteArray aesEncrypt(const QByteArray& plaintext, const QByteArray& key, const QByteArray& iv) {
-    EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
-    int len;
-    QByteArray ciphertext(plaintext.size() + AES_BLOCK_SIZE, 0);  // Ciphertext buffer
-
-    if (1 != EVP_EncryptInit_ex(ctx, EVP_aes_256_cbc(), nullptr, reinterpret_cast<const unsigned char*>(key.data()), reinterpret_cast<const unsigned char*>(iv.data()))) {
-        qWarning() << "Encryption init failed";
-        return QByteArray();
-    }
-
-    if (1 != EVP_EncryptUpdate(ctx, reinterpret_cast<unsigned char*>(ciphertext.data()), &len, reinterpret_cast<const unsigned char*>(plaintext.data()), plaintext.size())) {
-        qWarning() << "Encryption update failed";
-        return QByteArray();
-    }
-
-    int ciphertext_len = len;
-
-    if (1 != EVP_EncryptFinal_ex(ctx, reinterpret_cast<unsigned char*>(ciphertext.data()) + len, &len)) {
-        qWarning() << "Encryption final failed";
-        return QByteArray();
-    }
-
-    ciphertext_len += len;
-    ciphertext.resize(ciphertext_len);
-
-    EVP_CIPHER_CTX_free(ctx);
-    return ciphertext;
-}
-
-QByteArray aesDecrypt(const QByteArray& ciphertext, const QByteArray& key, const QByteArray& iv) {
-    EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
-    int len;
-    QByteArray plaintext(ciphertext.size(), 0);  // Plaintext buffer
-
-    if (1 != EVP_DecryptInit_ex(ctx, EVP_aes_256_cbc(), nullptr, reinterpret_cast<const unsigned char*>(key.data()), reinterpret_cast<const unsigned char*>(iv.data()))) {
-        qWarning() << "Decryption init failed";
-        return QByteArray();
-    }
-
-    if (1 != EVP_DecryptUpdate(ctx, reinterpret_cast<unsigned char*>(plaintext.data()), &len, reinterpret_cast<const unsigned char*>(ciphertext.data()), ciphertext.size())) {
-        qWarning() << "Decryption update failed";
-        return QByteArray();
-    }
-
-    int plaintext_len = len;
-
-    if (1 != EVP_DecryptFinal_ex(ctx, reinterpret_cast<unsigned char*>(plaintext.data()) + len, &len)) {
-        qWarning() << "Decryption final failed";
-        return QByteArray();
-    }
-
-    plaintext_len += len;
-    plaintext.resize(plaintext_len);
-
-    EVP_CIPHER_CTX_free(ctx);
-    return plaintext;
-}
-
-/// TEST
-void aesExample() {
-QByteArray key = deriveKey("mysalt", "mypassword", 1000, 32);  // Derive a key
-QByteArray iv = QByteArray::fromHex("00112233445566778899aabbccddeeff");  // Example IV (16 bytes for AES)
-
-QByteArray plaintext = "Hello, AES CBC Encryption!";
-
-QByteArray ciphertext = aesEncrypt(plaintext, key, iv);
-qDebug() << "Ciphertext:" << ciphertext.toHex();
-
-QByteArray decryptedText = aesDecrypt(ciphertext, key, iv);
-qDebug() << "Decrypted Text:" << decryptedText;
-}
-
-#endif
-
 EncryptionUtils::EncryptionInfo EncryptionUtils::splitVectorAndEcryptedData(const QByteArray &cipherText)
 {
     EncryptionUtils::EncryptionInfo info;
@@ -986,36 +941,6 @@ QVector<uint8_t> EncryptionUtils::toArrayBuffer(const QByteArray &ba)
     return byteVector;
 }
 
-// return crypto.subtle.importKey(
-//         'jwk',
-//         keyData,
-//         {
-//                 name: 'RSA-OAEP',
-//                 modulusLength: 2048,
-//                 publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
-//                 hash: { name: 'SHA-256' },
-//         },
-//         true,
-//         keyUsages,
-// );
-void EncryptionUtils::importRSAKey()
-{
-    // TODO
-}
-
-// return crypto.subtle.importKey('jwk', keyData, { name: 'AES-CBC' }, true, keyUsages);
-void EncryptionUtils::importAESKey()
-{
-#if 0
-    export async function importAESKey(keyData, keyUsages = ['encrypt', 'decrypt']) {
-            return crypto.subtle.importKey('jwk', keyData, { name: 'AES-CBC' }, true, keyUsages);
-    }
-
-#endif
-
-    // TODO
-}
-
 // crypto.subtle.importKey('raw', keyData, { name: 'PBKDF2' }, false, keyUsages);
 QByteArray EncryptionUtils::importRawKey(const QByteArray &keyData, const QByteArray &salt, int iterations)
 {
diff --git a/src/core/encryption/encryptionutils.h b/src/core/encryption/encryptionutils.h
index 2c09c72876..995f5d7b88 100644
--- a/src/core/encryption/encryptionutils.h
+++ b/src/core/encryption/encryptionutils.h
@@ -38,6 +38,7 @@ struct RSAKeyPair {
 [[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 encryptAES_GCM_256(const QByteArray &plainText, 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);
@@ -55,8 +56,6 @@ struct RSAKeyPair {
 [[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray joinVectorAndEcryptedData(const EncryptionUtils::EncryptionInfo &info);
 [[nodiscard]] LIBRUQOLACORE_EXPORT QVector<uint8_t> toArrayBuffer(const QByteArray &ba);
 [[nodiscard]] LIBRUQOLACORE_EXPORT QByteArray importRawKey(const QByteArray &keyData, const QByteArray &salt, int iterations);
-LIBRUQOLACORE_EXPORT void importRSAKey();
-LIBRUQOLACORE_EXPORT void importAESKey();
 [[nodiscard]] LIBRUQOLACORE_EXPORT QString generateRandomPassword();
 };
 Q_DECLARE_TYPEINFO(EncryptionUtils::EncryptionInfo, Q_RELOCATABLE_TYPE);
diff --git a/src/core/messages/messageencrypted.cpp b/src/core/messages/messageencrypted.cpp
index 09a7effb67..88bf023408 100644
--- a/src/core/messages/messageencrypted.cpp
+++ b/src/core/messages/messageencrypted.cpp
@@ -110,6 +110,50 @@ QByteArray MessageEncrypted::decrypt(const QByteArray &sessionKey) const
 #endif
 }
 
+bool MessageEncrypted::encrypt(const QByteArray &plainText, const QByteArray &sessionKey, const QByteArray &keyId)
+{
+#if USE_E2E_SUPPORT
+    if (plainText.isEmpty()) {
+        qCWarning(RUQOLA_ENCRYPTION_LOG) << "MessageEncrypted::encrypt: plaintext is empty";
+        return false;
+    }
+
+    if (sessionKey.isEmpty()) {
+        qCWarning(RUQOLA_ENCRYPTION_LOG) << "MessageEncrypted::encrypt: session key is empty";
+        return false;
+    }
+
+    const QByteArray effectiveKeyId = keyId.isEmpty() ? mKeyId : keyId;
+    if (effectiveKeyId.isEmpty()) {
+        qCWarning(RUQOLA_ENCRYPTION_LOG) << "MessageEncrypted::encrypt: key id is empty";
+        return false;
+    }
+
+    const QByteArray generatedIv = EncryptionUtils::generateRandomIV(12);
+    if (generatedIv.isEmpty()) {
+        qCWarning(RUQOLA_ENCRYPTION_LOG) << "MessageEncrypted::encrypt: failed to generate iv";
+        return false;
+    }
+
+    const QByteArray encryptedPayload = EncryptionUtils::encryptAES_GCM_256(plainText, sessionKey, generatedIv);
+    if (encryptedPayload.isEmpty()) {
+        qCWarning(RUQOLA_ENCRYPTION_LOG) << "MessageEncrypted::encrypt: encryption failed";
+        return false;
+    }
+
+    mAlgorithm = "rc.v2.aes-sha2";
+    mKeyId = effectiveKeyId;
+    mIv = generatedIv.toBase64();
+    mCiphertext = QString::fromLatin1(encryptedPayload.toBase64());
+    return true;
+#else
+    Q_UNUSED(plainText)
+    Q_UNUSED(sessionKey)
+    Q_UNUSED(keyId)
+    return false;
+#endif
+}
+
 bool MessageEncrypted::operator==(const MessageEncrypted &other) const
 {
     return mAlgorithm == other.algorithm() && mCiphertext == other.ciphertext() && mKeyId == other.keyId() && mIv == other.iv();
diff --git a/src/core/messages/messageencrypted.h b/src/core/messages/messageencrypted.h
index 7da22e0253..a2c77f8dc8 100644
--- a/src/core/messages/messageencrypted.h
+++ b/src/core/messages/messageencrypted.h
@@ -39,6 +39,7 @@ public:
     void setIv(const QByteArray &newIv);
 
     [[nodiscard]] QByteArray decrypt(const QByteArray &sessionKey) const;
+    [[nodiscard]] bool encrypt(const QByteArray &plainText, const QByteArray &sessionKey, const QByteArray &keyId = {});
 
 private:
     QByteArray mAlgorithm;