Files
EetCpp/libEet/eetca.cpp
T

428 lines
14 KiB
C++

#include "eetca.h"
#include <QCryptographicHash>
#include <QEventLoop>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QTimer>
#include <openssl/evp.h>
#include <openssl/pkcs12.h>
#include <openssl/x509.h>
#include <openssl/crypto.h>
#if OPENSSL_VERSION_NUMBER >= 0x30000000L
#include <openssl/provider.h>
#endif
namespace
{
struct SigningMaterial
{
EVP_PKEY *key{nullptr};
QByteArray certificateDer;
};
void setError(QString *error, const QString &message)
{
if (error != nullptr)
{
*error = message;
}
}
QByteArray base64Url(const QByteArray &data)
{
return data.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
}
bool loadSigningMaterial(const QString &path, const QByteArray &password,
SigningMaterial *material, QString *error)
{
OPENSSL_init_crypto(OPENSSL_INIT_LOAD_CONFIG, nullptr);
#if OPENSSL_VERSION_NUMBER >= 0x30000000L
OSSL_PROVIDER_load(nullptr, "default");
OSSL_PROVIDER_load(nullptr, "legacy");
#endif
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
{
setError(error, QStringLiteral("Cannot read the CA signing PKCS#12 file."));
return false;
}
const QByteArray data = file.readAll();
BIO *bio = BIO_new_mem_buf(data.constData(), data.size());
PKCS12 *container = bio == nullptr ? nullptr : d2i_PKCS12_bio(bio, nullptr);
BIO_free(bio);
if (container == nullptr)
{
setError(error, QStringLiteral("The CA signing PKCS#12 file is invalid."));
return false;
}
EVP_PKEY *key = nullptr;
X509 *certificate = nullptr;
STACK_OF(X509) *chain = nullptr;
const bool parsed = PKCS12_parse(container, password.constData(), &key,
&certificate, &chain) == 1;
PKCS12_free(container);
sk_X509_pop_free(chain, X509_free);
if (!parsed || key == nullptr || certificate == nullptr)
{
EVP_PKEY_free(key);
X509_free(certificate);
setError(error, QStringLiteral("The CA signing PKCS#12 password is invalid."));
return false;
}
const int length = i2d_X509(certificate, nullptr);
if (length <= 0)
{
EVP_PKEY_free(key);
X509_free(certificate);
setError(error, QStringLiteral("The CA signing certificate is invalid."));
return false;
}
material->certificateDer.resize(length);
unsigned char *destination =
reinterpret_cast<unsigned char *>(material->certificateDer.data());
if (i2d_X509(certificate, &destination) != length)
{
EVP_PKEY_free(key);
X509_free(certificate);
material->certificateDer.clear();
setError(error, QStringLiteral("The CA signing certificate cannot be encoded."));
return false;
}
material->key = key;
X509_free(certificate);
return true;
}
QByteArray createJwt(const SigningMaterial &material, QString *error)
{
const QByteArray thumbprint = base64Url(
QCryptographicHash::hash(material.certificateDer, QCryptographicHash::Sha256));
const QByteArray header = QJsonDocument(QJsonObject{
{QStringLiteral("alg"), QStringLiteral("RS256")},
{QStringLiteral("typ"), QStringLiteral("JWT")},
{QStringLiteral("x5t#S256"), QString::fromLatin1(thumbprint)}
}).toJson(QJsonDocument::Compact);
const QByteArray payload = QByteArrayLiteral("{}");
const QByteArray signingInput = base64Url(header) + '.' + base64Url(payload);
EVP_MD_CTX *context = EVP_MD_CTX_new();
if (context == nullptr ||
EVP_DigestSignInit(context, nullptr, EVP_sha256(), nullptr, material.key) != 1 ||
EVP_DigestSignUpdate(context, signingInput.constData(), signingInput.size()) != 1)
{
EVP_MD_CTX_free(context);
setError(error, QStringLiteral("Cannot initialize CA JWT signing."));
return {};
}
size_t signatureSize = 0;
if (EVP_DigestSignFinal(context, nullptr, &signatureSize) != 1)
{
EVP_MD_CTX_free(context);
setError(error, QStringLiteral("Cannot determine CA JWT signature size."));
return {};
}
QByteArray signature(static_cast<qsizetype>(signatureSize), Qt::Uninitialized);
if (EVP_DigestSignFinal(context, reinterpret_cast<unsigned char *>(signature.data()),
&signatureSize) != 1)
{
EVP_MD_CTX_free(context);
setError(error, QStringLiteral("Cannot create CA JWT signature."));
return {};
}
EVP_MD_CTX_free(context);
signature.resize(static_cast<qsizetype>(signatureSize));
return signingInput + '.' + base64Url(signature);
}
bool parseJsonObject(const QByteArray &data, QJsonObject *object, QString *error)
{
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(data, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
setError(error, QStringLiteral("CA API returned invalid JSON."));
return false;
}
*object = document.object();
return true;
}
bool parseApiError(const QByteArray &data, int statusCode, QString *error)
{
QJsonObject object;
if (parseJsonObject(data, &object, nullptr))
{
const QString message = object.value(QStringLiteral("message")).toString();
if (!message.isEmpty())
{
setError(error, QStringLiteral("CA API HTTP %1: %2").arg(statusCode).arg(message));
return false;
}
}
setError(error, QStringLiteral("CA API returned HTTP status %1.").arg(statusCode));
return false;
}
bool parseCertificate(const QJsonObject &object, EetCaCertificate *certificate)
{
certificate->serialNumber = object.value(QStringLiteral("sn")).toString();
certificate->validFrom = QDateTime::fromString(
object.value(QStringLiteral("validFrom")).toString(), Qt::ISODate);
certificate->validTo = QDateTime::fromString(
object.value(QStringLiteral("validTo")).toString(), Qt::ISODate);
certificate->state = object.value(QStringLiteral("state")).toString();
certificate->description = object.value(QStringLiteral("description")).toString();
certificate->email = object.value(QStringLiteral("email")).toString();
return !certificate->serialNumber.isEmpty() && certificate->validFrom.isValid() &&
certificate->validTo.isValid() && !certificate->state.isEmpty();
}
bool performRequest(const EetCaClientConfig &config, const QString &path,
const QByteArray &method, QByteArray *data, int *statusCode,
QString *error)
{
if (!config.apiBaseUrl.isValid() || config.apiBaseUrl.scheme() != QLatin1String("https") ||
config.apiBaseUrl.host().isEmpty() || config.timeoutMilliseconds <= 0)
{
setError(error, QStringLiteral("CA API requires a valid HTTPS base URL and positive timeout."));
return false;
}
SigningMaterial material;
if (!loadSigningMaterial(config.signingPkcs12Path, config.signingPkcs12Password,
&material, error))
{
return false;
}
const QByteArray jwt = createJwt(material, error);
EVP_PKEY_free(material.key);
if (jwt.isEmpty())
{
return false;
}
QNetworkAccessManager manager;
QUrl endpoint(config.apiBaseUrl);
QString basePath = endpoint.path();
if (!basePath.endsWith(QLatin1Char('/')))
{
basePath.append(QLatin1Char('/'));
}
endpoint.setPath(basePath + path.mid(1));
QNetworkRequest request(endpoint);
if (!config.sslConfiguration.isNull())
{
request.setSslConfiguration(config.sslConfiguration);
}
request.setRawHeader("Authorization", "Bearer " + jwt);
QNetworkReply *reply = method == QByteArrayLiteral("POST")
? manager.post(request, QByteArray())
: manager.get(request);
QEventLoop loop;
QTimer timer;
timer.setSingleShot(true);
bool timedOut = false;
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
QObject::connect(&timer, &QTimer::timeout, reply, [&] { timedOut = true; reply->abort(); });
timer.start(config.timeoutMilliseconds);
loop.exec();
if (timedOut || reply->error() != QNetworkReply::NoError)
{
setError(error, timedOut ? QStringLiteral("CA API request timed out.")
: reply->errorString());
reply->deleteLater();
return false;
}
*statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
*data = reply->readAll();
reply->deleteLater();
return true;
}
}
EetCaClient::EetCaClient(EetCaClientConfig config)
: m_config(std::move(config))
{
}
bool EetCaClient::requestRenewal(QUuid *requestId, QString *error) const
{
if (requestId == nullptr)
{
setError(error, QStringLiteral("A request ID output object is required."));
return false;
}
*requestId = {};
QByteArray data;
int statusCode = 0;
if (!performRequest(m_config, QStringLiteral("/request/renew"), QByteArrayLiteral("POST"),
&data, &statusCode, error))
{
return false;
}
if (statusCode != 201)
{
return parseApiError(data, statusCode, error);
}
QJsonObject object;
if (!parseJsonObject(data, &object, error))
{
return false;
}
const QUuid id(object.value(QStringLiteral("reqId")).toString());
if (id.isNull())
{
setError(error, QStringLiteral("CA API returned an invalid request ID."));
return false;
}
*requestId = id;
return true;
}
bool EetCaClient::requestStatus(const QUuid &requestId,
EetCaRequestStatus *status,
QString *error) const
{
if (status == nullptr || requestId.isNull())
{
setError(error, QStringLiteral("A valid request ID and output object are required."));
return false;
}
QByteArray data;
int statusCode = 0;
if (!performRequest(m_config, QStringLiteral("/request/%1/status").arg(
requestId.toString(QUuid::WithoutBraces)),
QByteArrayLiteral("GET"), &data, &statusCode, error))
{
return false;
}
if (statusCode != 200)
{
return parseApiError(data, statusCode, error);
}
QJsonObject object;
if (!parseJsonObject(data, &object, error))
{
return false;
}
const QUuid returnedId(object.value(QStringLiteral("reqId")).toString());
const QString state = object.value(QStringLiteral("state")).toString();
if (returnedId.isNull() || state.isEmpty())
{
setError(error, QStringLiteral("CA API returned an invalid request status."));
return false;
}
status->requestId = returnedId;
status->state = state;
status->pollAfterSeconds = object.value(QStringLiteral("pollAfterSeconds")).toInt();
status->rejectedReason = object.value(QStringLiteral("rejectedReason")).toString();
return true;
}
bool EetCaClient::listUnfinished(QList<QUuid> *requestIds, QString *error) const
{
if (requestIds == nullptr)
{
setError(error, QStringLiteral("A request ID list output object is required."));
return false;
}
requestIds->clear();
QByteArray data;
int statusCode = 0;
if (!performRequest(m_config, QStringLiteral("/request/not-finished"),
QByteArrayLiteral("GET"), &data, &statusCode, error))
{
return false;
}
if (statusCode != 200)
{
return parseApiError(data, statusCode, error);
}
QJsonObject object;
if (!parseJsonObject(data, &object, error))
{
return false;
}
for (const QJsonValue &value : object.value(QStringLiteral("data")).toArray())
{
const QUuid id(value.toString());
if (id.isNull())
{
setError(error, QStringLiteral("CA API returned an invalid request ID."));
requestIds->clear();
return false;
}
requestIds->append(id);
}
return true;
}
bool EetCaClient::claimDownload(const QUuid &requestId,
EetCaPkcs12 *result,
QString *error) const
{
if (result == nullptr || requestId.isNull())
{
setError(error, QStringLiteral("A valid request ID and output object are required."));
return false;
}
*result = {};
QByteArray data;
int statusCode = 0;
if (!performRequest(m_config, QStringLiteral("/request/%1/claim-download").arg(
requestId.toString(QUuid::WithoutBraces)),
QByteArrayLiteral("POST"), &data, &statusCode, error))
{
return false;
}
if (statusCode != 200)
{
return parseApiError(data, statusCode, error);
}
QJsonObject object;
if (!parseJsonObject(data, &object, error))
{
return false;
}
result->pkcs12 = QByteArray::fromBase64(
object.value(QStringLiteral("pkcs12")).toString().toLatin1());
result->password = object.value(QStringLiteral("password")).toString().toUtf8();
if (result->pkcs12.isEmpty() || result->password.isEmpty() ||
!parseCertificate(object.value(QStringLiteral("certificate")).toObject(),
&result->certificate))
{
*result = {};
setError(error, QStringLiteral("CA API returned an invalid certificate package."));
return false;
}
return true;
}
bool EetCaClient::acknowledgeDownload(const QUuid &requestId, QString *error) const
{
if (requestId.isNull())
{
setError(error, QStringLiteral("A valid request ID is required."));
return false;
}
QByteArray data;
int statusCode = 0;
if (!performRequest(m_config, QStringLiteral("/request/%1/ack-download").arg(
requestId.toString(QUuid::WithoutBraces)),
QByteArrayLiteral("POST"), &data, &statusCode, error))
{
return false;
}
if (statusCode != 204)
{
return parseApiError(data, statusCode, error);
}
return true;
}