move webpush encryption code to seperate file

This commit is contained in:
tehcneko 2025-08-16 04:21:06 +01:00
commit 774446130f
5 changed files with 545 additions and 456 deletions

View file

@ -15,6 +15,7 @@ add_library(qbt_webui STATIC
api/torrentscontroller.h api/torrentscontroller.h
api/transfercontroller.h api/transfercontroller.h
api/serialize/serialize_torrent.h api/serialize/serialize_torrent.h
api/webpush/webpush_utils.h
webapplication.h webapplication.h
webui.h webui.h
@ -32,6 +33,7 @@ add_library(qbt_webui STATIC
api/torrentscontroller.cpp api/torrentscontroller.cpp
api/transfercontroller.cpp api/transfercontroller.cpp
api/serialize/serialize_torrent.cpp api/serialize/serialize_torrent.cpp
api/webpush/webpush_utils.cpp
webapplication.cpp webapplication.cpp
webui.cpp webui.cpp
) )

View file

@ -28,11 +28,7 @@
#include "pushcontroller.h" #include "pushcontroller.h"
#include <openssl/core_names.h> #include <QByteArray>
#include <openssl/kdf.h>
#include <openssl/pem.h>
#include <openssl/rand.h>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
@ -40,6 +36,8 @@
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkProxy> #include <QNetworkProxy>
#include <QNetworkReply> #include <QNetworkReply>
#include <QString>
#include <QUrl>
#include "base/addtorrentmanager.h" #include "base/addtorrentmanager.h"
#include "base/bittorrent/infohash.h" #include "base/bittorrent/infohash.h"
@ -53,6 +51,7 @@
#include "base/utils/string.h" #include "base/utils/string.h"
#include "base/version.h" #include "base/version.h"
#include "apierror.h" #include "apierror.h"
#include "webpush/webpush_utils.h"
const QString KEY_VAPID_PUBLIC_KEY = u"vapidPublicKey"_s; const QString KEY_VAPID_PUBLIC_KEY = u"vapidPublicKey"_s;
const QString KEY_VAPID_PRIVATE_KEY = u"vapidPrivateKey"_s; const QString KEY_VAPID_PRIVATE_KEY = u"vapidPrivateKey"_s;
@ -80,416 +79,6 @@ const QString EVENT_ADD_TORRENT_FAILED = u"add_torrent_failed"_s;
const QString PUSH_CONFIG_FILE_NAME = u"web_push.json"_s; const QString PUSH_CONFIG_FILE_NAME = u"web_push.json"_s;
namespace
{
QByteArray base64UrlDecode(const QByteArray& data)
{
return QByteArray::fromBase64(data, QByteArray::Base64UrlEncoding);
}
QByteArray base64UrlEncode(const QByteArray& data)
{
return data.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
}
QByteArray generateSalt()
{
QByteArray salt(16, 0);
RAND_bytes(reinterpret_cast<unsigned char*>(salt.data()), salt.size());
return salt;
}
QByteArray getECPublicOctets(EVP_PKEY *key)
{
size_t outLen = 0;
if (EVP_PKEY_get_octet_string_param(key, OSSL_PKEY_PARAM_PUB_KEY, nullptr, 0, &outLen) <= 0)
{
return {};
}
QByteArray out(outLen, 0);
if (EVP_PKEY_get_octet_string_param(key, OSSL_PKEY_PARAM_PUB_KEY,
reinterpret_cast<unsigned char*>(out.data()), outLen, &outLen) <= 0)
{
return {};
}
return out;
}
EVP_PKEY *createPublicKeyFromBytes(const QByteArray& publicKeyBytes)
{
EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new_from_name(NULL, "EC", NULL);
if (!ctx)
return nullptr;
if (EVP_PKEY_fromdata_init(ctx) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return nullptr;
}
EVP_PKEY *pkey = nullptr;
OSSL_PARAM params[3];
params[0] = OSSL_PARAM_construct_utf8_string(OSSL_PKEY_PARAM_GROUP_NAME, const_cast<char*>("prime256v1"), 0);
auto data = static_cast<const void*>(publicKeyBytes.data());
params[1] = OSSL_PARAM_construct_octet_string(OSSL_PKEY_PARAM_PUB_KEY,const_cast<void*>(data), publicKeyBytes.size());
params[2] = OSSL_PARAM_construct_end();
if (EVP_PKEY_fromdata(ctx, &pkey, EVP_PKEY_PUBLIC_KEY, params) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return nullptr;
}
EVP_PKEY_CTX_free(ctx);
return pkey;
}
QByteArray computeECDHSecret(EVP_PKEY *senderPrivateKey, EVP_PKEY *receiverPublicKey)
{
EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new(senderPrivateKey, nullptr);
if (!ctx)
return {};
if (EVP_PKEY_derive_init(ctx) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_derive_set_peer(ctx, receiverPublicKey) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
size_t outLen = EVP_MAX_MD_SIZE;
if (EVP_PKEY_derive(ctx, nullptr, &outLen) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
QByteArray out(outLen, 0);
if (EVP_PKEY_derive(ctx, reinterpret_cast<unsigned char*>(out.data()), &outLen) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
out.resize(outLen);
EVP_PKEY_CTX_free(ctx);
return out;
}
QByteArray hkdfExtract(const QByteArray& salt, const QByteArray& ikm)
{
EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_HKDF, nullptr);
if (!ctx)
return {};
if (EVP_PKEY_derive_init(ctx) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set_hkdf_mode(ctx, EVP_PKEY_HKDEF_MODE_EXTRACT_ONLY) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set_hkdf_md(ctx, EVP_sha256()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set1_hkdf_key(ctx, reinterpret_cast<const unsigned char*>(ikm.constData()), ikm.size()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set1_hkdf_salt(ctx, reinterpret_cast<const unsigned char*>(salt.constData()), salt.size()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
size_t outLen = EVP_MAX_MD_SIZE;
if (EVP_PKEY_derive(ctx, nullptr, &outLen) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
QByteArray out(outLen, 0);
if (EVP_PKEY_derive(ctx, reinterpret_cast<unsigned char*>(out.data()), &outLen) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
EVP_PKEY_CTX_free(ctx);
return out;
}
QByteArray hkdfExpand(const QByteArray& prk, const QByteArray& info, size_t length)
{
EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_HKDF, nullptr);
if (!ctx)
return {};
if (EVP_PKEY_derive_init(ctx) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set_hkdf_mode(ctx, EVP_PKEY_HKDEF_MODE_EXPAND_ONLY) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set_hkdf_md(ctx, EVP_sha256()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set1_hkdf_key(ctx, reinterpret_cast<const unsigned char*>(prk.constData()), prk.size()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_add1_hkdf_info(ctx, reinterpret_cast<const unsigned char*>(info.constData()), info.size()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
size_t outLen = EVP_MAX_MD_SIZE;
QByteArray out(outLen, 0);
if (EVP_PKEY_derive(ctx, reinterpret_cast<unsigned char*>(out.data()), &outLen) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
out.resize(length);
EVP_PKEY_CTX_free(ctx);
return out;
}
// Derive keys for WebPush. Returns CEK (16), NONCE (12)
// https://datatracker.ietf.org/doc/html/rfc8291#section-4
QPair<QByteArray, QByteArray> deriveWebPushKeys(const QByteArray& salt,
EVP_PKEY *senderPrivateKey, const QByteArray& senderPublicKeyOctets,
EVP_PKEY *receiverPublicKey, const QByteArray& authSecret
)
{
// ecdh_secret = ECDH(as_private, ua_public)
const auto ecdhSecret = computeECDHSecret(senderPrivateKey, receiverPublicKey);
// PRK_key = HKDF-Extract(salt=auth_secret, IKM=ecdh_secret)
const auto prkKey = hkdfExtract(authSecret, ecdhSecret);
const auto receiverPublicKeyOctets = getECPublicOctets(receiverPublicKey);
// IKM = HKDF-Expand(PRK_key, key_info, L_key=32)
auto keyInfo = QByteArray("WebPush: info");
keyInfo.append('\0');
keyInfo.append(receiverPublicKeyOctets);
keyInfo.append(senderPublicKeyOctets);
const auto ikm = hkdfExpand(prkKey, keyInfo, 32);
// PRK = HKDF-Extract(salt, IKM)
const auto prk = hkdfExtract(salt, ikm);
// CEK = HKDF-Expand(PRK, "Content-Encoding: aes128gcm" || 0x00, L=16)
const auto cekInfo = QByteArray("Content-Encoding: aes128gcm").append('\0');
const auto cek = hkdfExpand(prk, cekInfo, 16);
// NONCE = HKDF-Expand(PRK, "Content-Encoding: nonce" || 0x00, L=12)
const auto nonceInfo = QByteArray("Content-Encoding: nonce").append('\0');
const auto nonce = hkdfExpand(prk, nonceInfo, 12);
return {cek, nonce};
}
QByteArray aes128gcmEncrypt(const QByteArray& cek, const QByteArray& nonce, const QByteArray& plaintext)
{
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
if (!ctx)
return {};
int len;
QByteArray ciphertext(plaintext.size(), 0);
QByteArray tag(16, 0);
if (EVP_EncryptInit_ex(ctx, EVP_aes_128_gcm(), nullptr,
reinterpret_cast<const unsigned char*>(cek.constData()),
reinterpret_cast<const unsigned char*>(nonce.constData())) <= 0)
{
EVP_CIPHER_CTX_free(ctx);
return {};
}
if (EVP_EncryptUpdate(ctx,
reinterpret_cast<unsigned char*>(ciphertext.data()), &len,
reinterpret_cast<const unsigned char*>(plaintext.constData()), plaintext.size()) <= 0)
{
EVP_CIPHER_CTX_free(ctx);
return {};
}
if (EVP_EncryptFinal_ex(ctx, reinterpret_cast<unsigned char*>(ciphertext.data()) + len, &len) <= 0)
{
EVP_CIPHER_CTX_free(ctx);
return {};
}
if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, tag.data()) <= 0)
{
EVP_CIPHER_CTX_free(ctx);
return {};
}
EVP_CIPHER_CTX_free(ctx);
return ciphertext + tag;
}
EVP_PKEY *generateECDHKeypair()
{
return EVP_EC_gen(const_cast<char*>("prime256v1"));
}
EVP_PKEY *createPrivateKeyFromPemString(const QString& pemString)
{
const auto pemBytes = pemString.toLatin1();
BIO *bio = BIO_new_mem_buf(pemBytes.constData(), pemBytes.size());
if (!bio)
{
return nullptr;
}
EVP_PKEY *pkey = PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr);
BIO_free(bio);
if (!pkey)
{
return nullptr;
}
return pkey;
}
QString savePrivateKeyToPemString(EVP_PKEY *pkey)
{
BIO *bio = BIO_new(BIO_s_mem());
if (!bio)
return {};
if (!PEM_write_bio_PrivateKey(bio, pkey, nullptr, nullptr, 0, nullptr, nullptr))
{
BIO_free(bio);
return {};
}
BUF_MEM *bptr = nullptr;
BIO_get_mem_ptr(bio, &bptr);
if (!bptr || !bptr->data)
{
BIO_free(bio);
return {};
}
QString pemStr = QString::fromLatin1(bptr->data, bptr->length);
BIO_free(bio);
return pemStr;
}
QString getAudienceFromEndpoint(const QString& endpoint)
{
QUrl url(endpoint);
QString audience = url.scheme() + u"://"_s + url.host();
if (url.port() != -1)
{
audience += u":"_s + QString::number(url.port());
}
return audience;
}
QByteArray derSigToRaw(const QByteArray& derSig)
{
const unsigned char *ptr = reinterpret_cast<const unsigned char*>(derSig.constData());
ECDSA_SIG *sig = d2i_ECDSA_SIG(nullptr, &ptr, derSig.size());
if (!sig)
return {};
const BIGNUM *r;
const BIGNUM *s;
ECDSA_SIG_get0(sig, &r, &s);
QByteArray rawSig;
rawSig.resize(64);
BN_bn2binpad(r, reinterpret_cast<unsigned char*>(rawSig.data()), 32);
BN_bn2binpad(s, reinterpret_cast<unsigned char*>(rawSig.data() + 32), 32);
ECDSA_SIG_free(sig);
return rawSig;
}
QByteArray ecdsaSign(EVP_PKEY *pkey, const QByteArray& data)
{
QByteArray signature;
EVP_MD_CTX *ctx = EVP_MD_CTX_new();
if (!ctx)
return {};
if (EVP_DigestSignInit(ctx, nullptr, EVP_sha256(), nullptr, pkey) <= 0)
{
EVP_MD_CTX_free(ctx);
return {};
}
if (EVP_DigestSignUpdate(ctx, data.constData(), data.size()) <= 0)
{
EVP_MD_CTX_free(ctx);
return {};
}
size_t sigLen = 0;
if (EVP_DigestSignFinal(ctx, nullptr, &sigLen) <= 0)
{
EVP_MD_CTX_free(ctx);
return {};
}
signature.resize(sigLen);
if (EVP_DigestSignFinal(ctx, reinterpret_cast<unsigned char*>(signature.data()), &sigLen) <= 0)
{
EVP_MD_CTX_free(ctx);
return {};
}
EVP_MD_CTX_free(ctx);
signature.resize(sigLen);
return derSigToRaw(signature);
}
// https://datatracker.ietf.org/doc/html/rfc8292#section-2
QString createVapidJWT(EVP_PKEY *privateKey, const QString& audience)
{
const auto now = QDateTime::currentSecsSinceEpoch();
const QJsonObject header{{u"alg"_s, u"ES256"_s}, {u"typ"_s, u"JWT"_s}};
const QJsonObject payload
{
{u"aud"_s, audience},
// Limiting this to 24 hours balances the need for reuse
// against the potential cost and likelihood of theft of a valid token.
{u"exp"_s, now + 60 * 60 * 24}, // 24 hours
{u"sub"_s, u"https://qbittorrent.org"_s}
};
const auto headerJson = QJsonDocument(header).toJson(QJsonDocument::Compact);
const auto payloadJson = QJsonDocument(payload).toJson(QJsonDocument::Compact);
QByteArray signingInput = base64UrlEncode(headerJson) + "." + base64UrlEncode(payloadJson);
QByteArray signature = base64UrlEncode(ecdsaSign(privateKey, signingInput));
return QString::fromLatin1(signingInput + "." + signature);
}
}
PushController::PushController(IApplication *app, QObject *parent) PushController::PushController(IApplication *app, QObject *parent)
: APIController(app, parent) : APIController(app, parent)
, m_networkManager {new QNetworkAccessManager(this)} , m_networkManager {new QNetworkAccessManager(this)}
@ -524,7 +113,7 @@ PushController::PushController(IApplication *app, QObject *parent)
const auto jsonObject = jsonDoc.object(); const auto jsonObject = jsonDoc.object();
m_vapidPrivateKey = createPrivateKeyFromPemString(jsonObject.value(KEY_VAPID_PRIVATE_KEY).toString()); m_vapidPrivateKey = createPrivateKeyFromPemString(jsonObject.value(KEY_VAPID_PRIVATE_KEY).toString());
m_vapidPublicKeyOctets = getECPublicOctets(m_vapidPrivateKey); m_vapidPublicKeyString = getVapidPublicKeyString(m_vapidPrivateKey);
const auto subscriptionsArray = jsonObject.value(KEY_SUBSCRIPTIONS).toArray(); const auto subscriptionsArray = jsonObject.value(KEY_SUBSCRIPTIONS).toArray();
for (const auto& subscription : subscriptionsArray) for (const auto& subscription : subscriptionsArray)
@ -537,17 +126,19 @@ PushController::PushController(IApplication *app, QObject *parent)
{ {
// Generate new VAPID keypair and save if the file does not exist // Generate new VAPID keypair and save if the file does not exist
const auto privateKey = generateECDHKeypair(); const auto privateKey = generateECDHKeypair();
m_vapidPublicKeyOctets = getECPublicOctets(privateKey); m_vapidPublicKeyString = getVapidPublicKeyString(privateKey);
m_vapidPrivateKey = privateKey; m_vapidPrivateKey = privateKey;
saveSubscriptions(); saveSubscriptions();
} }
// Apply proxy settings
connect(Net::ProxyConfigurationManager::instance(), &Net::ProxyConfigurationManager::proxyConfigurationChanged connect(Net::ProxyConfigurationManager::instance(), &Net::ProxyConfigurationManager::proxyConfigurationChanged
, this, &PushController::applyProxySettings); , this, &PushController::applyProxySettings);
connect(Preferences::instance(), &Preferences::changed, this, &PushController::applyProxySettings); connect(Preferences::instance(), &Preferences::changed, this, &PushController::applyProxySettings);
applyProxySettings(); applyProxySettings();
// Connect to signals
const auto *btSession = BitTorrent::Session::instance(); const auto *btSession = BitTorrent::Session::instance();
connect(btSession, &BitTorrent::Session::fullDiskError, this connect(btSession, &BitTorrent::Session::fullDiskError, this
, [this](const BitTorrent::Torrent *torrent, const QString& msg) , [this](const BitTorrent::Torrent *torrent, const QString& msg)
@ -701,7 +292,7 @@ void PushController::unsubscribeAction()
void PushController::vapidPublicKeyAction() void PushController::vapidPublicKeyAction()
{ {
QJsonObject jsonObject; QJsonObject jsonObject;
jsonObject[KEY_VAPID_PUBLIC_KEY] = QString::fromLatin1(base64UrlEncode(m_vapidPublicKeyOctets)); jsonObject[KEY_VAPID_PUBLIC_KEY] = m_vapidPublicKeyString;
setResult(jsonObject); setResult(jsonObject);
} }
@ -752,47 +343,18 @@ void PushController::sendWebPushNotificationToSubscription(const PushController:
request.setRawHeader("TTL", "3600"); request.setRawHeader("TTL", "3600");
request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("qBittorrent/" QBT_VERSION_2)); request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("qBittorrent/" QBT_VERSION_2));
const auto audience = getAudienceFromEndpoint(subscription.endpoint); const auto vapidJWT = createVapidJWT(m_vapidPrivateKey, subscription.endpoint);
const auto vapidJWT = createVapidJWT(m_vapidPrivateKey, audience); request.setRawHeader("Authorization", "vapid t=" + vapidJWT.toLatin1() + ",k=" + m_vapidPublicKeyString.toLatin1());
request.setRawHeader("Authorization", "vapid t=" + vapidJWT.toLatin1() + ",k=" + base64UrlEncode(m_vapidPublicKeyOctets));
const auto salt = generateSalt(); const auto [encryptedPayload, p256ecdsa] = buildWebPushPayload(subscription.p256dh, subscription.auth, payload);
const auto receiverPublicKey = createPublicKeyFromBytes(base64UrlDecode(subscription.p256dh.toLatin1())); if (encryptedPayload.isEmpty())
const auto authSecret = base64UrlDecode(subscription.auth.toLatin1());
if (receiverPublicKey == nullptr)
{ {
LogMsg(tr("Failed to create public key from subscription p256dh for endpoint: ") LogMsg(tr("Failed to build web push payload for subscription: %1").arg(subscription.endpoint), Log::CRITICAL);
.arg(subscription.endpoint), Log::CRITICAL);
return; return;
} }
const auto senderPrivateKey = generateECDHKeypair(); request.setRawHeader("Crypto-Key", "p256ecdsa=" + p256ecdsa);
const auto senderPublicKeyOctets = getECPublicOctets(senderPrivateKey);
if (senderPrivateKey == nullptr)
{
LogMsg(tr("Failed to generate ECDH keys for web push notification for endpoint: ")
.arg(subscription.endpoint), Log::CRITICAL);
return;
}
const auto [cek, nonce] = deriveWebPushKeys(salt, senderPrivateKey, senderPublicKeyOctets, receiverPublicKey, authSecret);
if (cek.isEmpty() || nonce.isEmpty())
{
LogMsg(tr("Failed to derive keys for web push notification for endpoint: ")
.arg(subscription.endpoint), Log::CRITICAL);
return;
}
request.setRawHeader("Crypto-Key", "p256ecdsa=" + base64UrlEncode(senderPublicKeyOctets));
// Build header (salt || record size || pubKeyLen || pubKey) const auto reply = m_networkManager->post(request, encryptedPayload);
auto header = QByteArray(salt);
header.append("\x00\x00\x10\x00", 4); // Use 4096 as record size
const auto idLen = static_cast<quint8>(senderPublicKeyOctets.size());
header.append(reinterpret_cast<const char*>(&idLen), sizeof(idLen));
header.append(senderPublicKeyOctets);
// Append padding delimiter octet (0x02) to the payload and encrypt
const auto encryptedPayload = aes128gcmEncrypt(cek, nonce, payload + "\x02");
const auto reply = m_networkManager->post(request, header + encryptedPayload);
connect(reply, &QNetworkReply::finished, reply, [reply]() { connect(reply, &QNetworkReply::finished, reply, [reply]() {
if (reply->error() != QNetworkReply::NoError) if (reply->error() != QNetworkReply::NoError)

View file

@ -66,8 +66,9 @@ private:
Path m_configFilePath; Path m_configFilePath;
QNetworkAccessManager *m_networkManager; QNetworkAccessManager *m_networkManager;
QList<PushSubscription> m_registeredSubscriptions; QList<PushSubscription> m_registeredSubscriptions;
QByteArray m_vapidPublicKeyOctets;
EVP_PKEY* m_vapidPrivateKey; QString m_vapidPublicKeyString;
EVP_PKEY *m_vapidPrivateKey;
void applyProxySettings(); void applyProxySettings();
void saveSubscriptions(); void saveSubscriptions();

View file

@ -0,0 +1,481 @@
/*
* Bittorrent Client using Qt and libtorrent.
* Copyright (C) 2025 tehcneko
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* In addition, as a special exception, the copyright holders give permission to
* link this program with the OpenSSL project's "OpenSSL" library (or with
* modified versions of it that use the same license as the "OpenSSL" library),
* and distribute the linked executables. You must obey the GNU General Public
* License in all respects for all of the code used other than "OpenSSL". If you
* modify file(s), you may extend this exception to your version of the file(s),
* but you are not obligated to do so. If you do not wish to do so, delete this
* exception statement from your version.
*/
#include <openssl/core_names.h>
#include <openssl/kdf.h>
#include <openssl/pem.h>
#include <openssl/rand.h>
#include <QByteArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QString>
#include <QUrl>
#include "base/logger.h"
#include "base/utils/string.h"
namespace
{
QByteArray base64UrlDecode(const QByteArray& data)
{
return QByteArray::fromBase64(data, QByteArray::Base64UrlEncoding);
}
QByteArray base64UrlEncode(const QByteArray& data)
{
return data.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
}
QByteArray generateSalt()
{
QByteArray salt(16, 0);
RAND_bytes(reinterpret_cast<unsigned char*>(salt.data()), salt.size());
return salt;
}
QByteArray getECPublicOctets(EVP_PKEY *key)
{
size_t outLen = 0;
if (EVP_PKEY_get_octet_string_param(key, OSSL_PKEY_PARAM_PUB_KEY, nullptr, 0, &outLen) <= 0)
{
return {};
}
QByteArray out(outLen, 0);
if (EVP_PKEY_get_octet_string_param(key, OSSL_PKEY_PARAM_PUB_KEY,
reinterpret_cast<unsigned char*>(out.data()), outLen, &outLen) <= 0)
{
return {};
}
return out;
}
EVP_PKEY *createPublicKeyFromBytes(const QByteArray& publicKeyBytes)
{
EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new_from_name(NULL, "EC", NULL);
if (!ctx)
return nullptr;
if (EVP_PKEY_fromdata_init(ctx) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return nullptr;
}
EVP_PKEY *pkey = nullptr;
OSSL_PARAM params[3];
params[0] = OSSL_PARAM_construct_utf8_string(OSSL_PKEY_PARAM_GROUP_NAME, const_cast<char*>("prime256v1"), 0);
auto data = static_cast<const void*>(publicKeyBytes.data());
params[1] = OSSL_PARAM_construct_octet_string(OSSL_PKEY_PARAM_PUB_KEY, const_cast<void*>(data), publicKeyBytes.size());
params[2] = OSSL_PARAM_construct_end();
if (EVP_PKEY_fromdata(ctx, &pkey, EVP_PKEY_PUBLIC_KEY, params) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return nullptr;
}
EVP_PKEY_CTX_free(ctx);
return pkey;
}
QByteArray computeECDHSecret(EVP_PKEY *senderPrivateKey, EVP_PKEY *receiverPublicKey)
{
EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new(senderPrivateKey, nullptr);
if (!ctx)
return {};
if (EVP_PKEY_derive_init(ctx) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_derive_set_peer(ctx, receiverPublicKey) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
size_t outLen = EVP_MAX_MD_SIZE;
if (EVP_PKEY_derive(ctx, nullptr, &outLen) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
QByteArray out(outLen, 0);
if (EVP_PKEY_derive(ctx, reinterpret_cast<unsigned char*>(out.data()), &outLen) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
out.resize(outLen);
EVP_PKEY_CTX_free(ctx);
return out;
}
QByteArray hkdfExtract(const QByteArray& salt, const QByteArray& ikm)
{
EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_HKDF, nullptr);
if (!ctx)
return {};
if (EVP_PKEY_derive_init(ctx) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set_hkdf_mode(ctx, EVP_PKEY_HKDEF_MODE_EXTRACT_ONLY) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set_hkdf_md(ctx, EVP_sha256()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set1_hkdf_key(ctx, reinterpret_cast<const unsigned char*>(ikm.constData()), ikm.size()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set1_hkdf_salt(ctx, reinterpret_cast<const unsigned char*>(salt.constData()), salt.size()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
size_t outLen = EVP_MAX_MD_SIZE;
if (EVP_PKEY_derive(ctx, nullptr, &outLen) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
QByteArray out(outLen, 0);
if (EVP_PKEY_derive(ctx, reinterpret_cast<unsigned char*>(out.data()), &outLen) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
EVP_PKEY_CTX_free(ctx);
return out;
}
QByteArray hkdfExpand(const QByteArray& prk, const QByteArray& info, size_t length)
{
EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_HKDF, nullptr);
if (!ctx)
return {};
if (EVP_PKEY_derive_init(ctx) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set_hkdf_mode(ctx, EVP_PKEY_HKDEF_MODE_EXPAND_ONLY) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set_hkdf_md(ctx, EVP_sha256()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set1_hkdf_key(ctx, reinterpret_cast<const unsigned char*>(prk.constData()), prk.size()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_add1_hkdf_info(ctx, reinterpret_cast<const unsigned char*>(info.constData()), info.size()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
size_t outLen = EVP_MAX_MD_SIZE;
QByteArray out(outLen, 0);
if (EVP_PKEY_derive(ctx, reinterpret_cast<unsigned char*>(out.data()), &outLen) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
out.resize(length);
EVP_PKEY_CTX_free(ctx);
return out;
}
// Derive keys for WebPush. Returns CEK (16), NONCE (12)
// https://datatracker.ietf.org/doc/html/rfc8291#section-4
QPair<QByteArray, QByteArray> deriveWebPushKeys(const QByteArray& salt,
EVP_PKEY *senderPrivateKey, const QByteArray& senderPublicKeyOctets,
EVP_PKEY *receiverPublicKey, const QByteArray& authSecret
)
{
// ecdh_secret = ECDH(as_private, ua_public)
const auto ecdhSecret = computeECDHSecret(senderPrivateKey, receiverPublicKey);
// PRK_key = HKDF-Extract(salt=auth_secret, IKM=ecdh_secret)
const auto prkKey = hkdfExtract(authSecret, ecdhSecret);
const auto receiverPublicKeyOctets = getECPublicOctets(receiverPublicKey);
// IKM = HKDF-Expand(PRK_key, key_info, L_key=32)
auto keyInfo = QByteArray("WebPush: info");
keyInfo.append('\0');
keyInfo.append(receiverPublicKeyOctets);
keyInfo.append(senderPublicKeyOctets);
const auto ikm = hkdfExpand(prkKey, keyInfo, 32);
// PRK = HKDF-Extract(salt, IKM)
const auto prk = hkdfExtract(salt, ikm);
// CEK = HKDF-Expand(PRK, "Content-Encoding: aes128gcm" || 0x00, L=16)
const auto cekInfo = QByteArray("Content-Encoding: aes128gcm").append('\0');
const auto cek = hkdfExpand(prk, cekInfo, 16);
// NONCE = HKDF-Expand(PRK, "Content-Encoding: nonce" || 0x00, L=12)
const auto nonceInfo = QByteArray("Content-Encoding: nonce").append('\0');
const auto nonce = hkdfExpand(prk, nonceInfo, 12);
return { cek, nonce };
}
QByteArray aes128gcmEncrypt(const QByteArray& cek, const QByteArray& nonce, const QByteArray& plaintext)
{
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
if (!ctx)
return {};
int len;
QByteArray ciphertext(plaintext.size(), 0);
QByteArray tag(16, 0);
if (EVP_EncryptInit_ex(ctx, EVP_aes_128_gcm(), nullptr,
reinterpret_cast<const unsigned char*>(cek.constData()),
reinterpret_cast<const unsigned char*>(nonce.constData())) <= 0)
{
EVP_CIPHER_CTX_free(ctx);
return {};
}
if (EVP_EncryptUpdate(ctx,
reinterpret_cast<unsigned char*>(ciphertext.data()), &len,
reinterpret_cast<const unsigned char*>(plaintext.constData()), plaintext.size()) <= 0)
{
EVP_CIPHER_CTX_free(ctx);
return {};
}
if (EVP_EncryptFinal_ex(ctx, reinterpret_cast<unsigned char*>(ciphertext.data()) + len, &len) <= 0)
{
EVP_CIPHER_CTX_free(ctx);
return {};
}
if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, tag.data()) <= 0)
{
EVP_CIPHER_CTX_free(ctx);
return {};
}
EVP_CIPHER_CTX_free(ctx);
return ciphertext + tag;
}
QString getAudienceFromEndpoint(const QString& endpoint)
{
QUrl url(endpoint);
QString audience = url.scheme() + u"://"_s + url.host();
if (url.port() != -1)
{
audience += u":"_s + QString::number(url.port());
}
return audience;
}
QByteArray derSigToRaw(const QByteArray& derSig)
{
const unsigned char *ptr = reinterpret_cast<const unsigned char*>(derSig.constData());
ECDSA_SIG *sig = d2i_ECDSA_SIG(nullptr, &ptr, derSig.size());
if (!sig)
return {};
const BIGNUM *r;
const BIGNUM *s;
ECDSA_SIG_get0(sig, &r, &s);
QByteArray rawSig;
rawSig.resize(64);
BN_bn2binpad(r, reinterpret_cast<unsigned char*>(rawSig.data()), 32);
BN_bn2binpad(s, reinterpret_cast<unsigned char*>(rawSig.data() + 32), 32);
ECDSA_SIG_free(sig);
return rawSig;
}
QByteArray ecdsaSign(EVP_PKEY *pkey, const QByteArray& data)
{
QByteArray signature;
EVP_MD_CTX *ctx = EVP_MD_CTX_new();
if (!ctx)
return {};
if (EVP_DigestSignInit(ctx, nullptr, EVP_sha256(), nullptr, pkey) <= 0)
{
EVP_MD_CTX_free(ctx);
return {};
}
if (EVP_DigestSignUpdate(ctx, data.constData(), data.size()) <= 0)
{
EVP_MD_CTX_free(ctx);
return {};
}
size_t sigLen = 0;
if (EVP_DigestSignFinal(ctx, nullptr, &sigLen) <= 0)
{
EVP_MD_CTX_free(ctx);
return {};
}
signature.resize(sigLen);
if (EVP_DigestSignFinal(ctx, reinterpret_cast<unsigned char*>(signature.data()), &sigLen) <= 0)
{
EVP_MD_CTX_free(ctx);
return {};
}
EVP_MD_CTX_free(ctx);
signature.resize(sigLen);
return derSigToRaw(signature);
}
}
EVP_PKEY *generateECDHKeypair()
{
return EVP_EC_gen(const_cast<char*>("prime256v1"));
}
EVP_PKEY *createPrivateKeyFromPemString(const QString& pemString)
{
const auto pemBytes = pemString.toLatin1();
BIO *bio = BIO_new_mem_buf(pemBytes.constData(), pemBytes.size());
if (!bio)
{
return nullptr;
}
EVP_PKEY *pkey = PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr);
BIO_free(bio);
if (!pkey)
{
return nullptr;
}
return pkey;
}
QString savePrivateKeyToPemString(EVP_PKEY *pkey)
{
BIO *bio = BIO_new(BIO_s_mem());
if (!bio)
return {};
if (!PEM_write_bio_PrivateKey(bio, pkey, nullptr, nullptr, 0, nullptr, nullptr))
{
BIO_free(bio);
return {};
}
BUF_MEM *bptr = nullptr;
BIO_get_mem_ptr(bio, &bptr);
if (!bptr || !bptr->data)
{
BIO_free(bio);
return {};
}
QString pemStr = QString::fromLatin1(bptr->data, bptr->length);
BIO_free(bio);
return pemStr;
}
QPair<QByteArray, QByteArray> buildWebPushPayload(const QString& p256dh, const QString& auth, const QByteArray& payload)
{
const auto salt = generateSalt();
const auto receiverPublicKey = createPublicKeyFromBytes(base64UrlDecode(p256dh.toLatin1()));
const auto authSecret = base64UrlDecode(auth.toLatin1());
const auto senderPrivateKey = generateECDHKeypair();
const auto senderPublicKeyOctets = getECPublicOctets(senderPrivateKey);
const auto [cek, nonce] = deriveWebPushKeys(salt, senderPrivateKey, senderPublicKeyOctets, receiverPublicKey, authSecret);
// Build header (salt || record size || pubKeyLen || pubKey)
auto header = QByteArray(salt);
header.append("\x00\x00\x10\x00", 4); // Use 4096 as record size
const auto idLen = static_cast<quint8>(senderPublicKeyOctets.size());
header.append(reinterpret_cast<const char*>(&idLen), sizeof(idLen));
header.append(senderPublicKeyOctets);
// Append padding delimiter octet (0x02) to the payload and encrypt
const auto encryptedPayload = aes128gcmEncrypt(cek, nonce, payload + "\x02");
return { header + encryptedPayload, base64UrlEncode(senderPublicKeyOctets) };
}
// https://datatracker.ietf.org/doc/html/rfc8292#section-2
QString createVapidJWT(EVP_PKEY *privateKey, const QString& endpoint)
{
const auto now = QDateTime::currentSecsSinceEpoch();
const QJsonObject header{ {u"alg"_s, u"ES256"_s}, {u"typ"_s, u"JWT"_s} };
const QJsonObject payload
{
{u"aud"_s, getAudienceFromEndpoint(endpoint)},
// Limiting this to 24 hours balances the need for reuse
// against the potential cost and likelihood of theft of a valid token.
{u"exp"_s, now + 60 *60 *24}, // 24 hours
{u"sub"_s, u"https://qbittorrent.org"_s}
};
const auto headerJson = QJsonDocument(header).toJson(QJsonDocument::Compact);
const auto payloadJson = QJsonDocument(payload).toJson(QJsonDocument::Compact);
QByteArray signingInput = base64UrlEncode(headerJson) + "." + base64UrlEncode(payloadJson);
QByteArray signature = base64UrlEncode(ecdsaSign(privateKey, signingInput));
return QString::fromLatin1(signingInput + "." + signature);
}
QString getVapidPublicKeyString(EVP_PKEY *privateKey)
{
const auto publicKeyOctets = getECPublicOctets(privateKey);
return QString::fromLatin1(base64UrlEncode(publicKeyOctets));
}

View file

@ -0,0 +1,43 @@
/*
* Bittorrent Client using Qt and libtorrent.
* Copyright (C) 2025 tehcneko
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* In addition, as a special exception, the copyright holders give permission to
* link this program with the OpenSSL project's "OpenSSL" library (or with
* modified versions of it that use the same license as the "OpenSSL" library),
* and distribute the linked executables. You must obey the GNU General Public
* License in all respects for all of the code used other than "OpenSSL". If you
* modify file(s), you may extend this exception to your version of the file(s),
* but you are not obligated to do so. If you do not wish to do so, delete this
* exception statement from your version.
*/
#pragma once
#include <openssl/evp.h>
#include <QByteArray>
#include <QString>
EVP_PKEY *generateECDHKeypair();
EVP_PKEY *createPrivateKeyFromPemString(const QString& pemString);
QString savePrivateKeyToPemString(EVP_PKEY *pkey);
QPair<QByteArray, QByteArray> buildWebPushPayload(const QString& p256dh, const QString& auth, const QByteArray& payload);
QString createVapidJWT(EVP_PKEY *privateKey, const QString& endpoint);
QString getVapidPublicKeyString(EVP_PKEY *privateKey);