mirror of
https://github.com/qbittorrent/qBittorrent
synced 2025-08-20 13:23:34 -07:00
move webpush encryption code to seperate file
This commit is contained in:
parent
8cd28f64c3
commit
774446130f
5 changed files with 545 additions and 456 deletions
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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();
|
||||||
|
|
481
src/webui/api/webpush/webpush_utils.cpp
Normal file
481
src/webui/api/webpush/webpush_utils.cpp
Normal 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));
|
||||||
|
}
|
43
src/webui/api/webpush/webpush_utils.h
Normal file
43
src/webui/api/webpush/webpush_utils.h
Normal 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);
|
Loading…
Add table
Add a link
Reference in a new issue