Files

367 lines
11 KiB
C++
Raw Permalink Normal View History

2026-07-09 09:17:30 +00:00
#include "DeviceIdentityHelper.h"
2026-07-09 09:17:30 +00:00
#include "ConfigHelper.h"
2026-07-09 09:17:30 +00:00
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QEventLoop>
#include <QFile>
#include <QFileInfo>
2026-07-09 09:17:30 +00:00
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSaveFile>
#include <QSysInfo>
#include <QTimer>
#include <QUuid>
2026-07-09 09:17:30 +00:00
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#endif
namespace
{
QString machineHash(const QString& installationId)
{
const QByteArray identity =
QSysInfo::machineUniqueId() + installationId.toUtf8();
return QString::fromLatin1(
QCryptographicHash::hash(identity, QCryptographicHash::Sha256).toHex());
}
}
DeviceIdentityHelper::DeviceIdentityHelper(const QString& installDir)
: m_installDir(installDir)
{
}
QString DeviceIdentityHelper::deviceId() const
{
return m_deviceId;
}
QString DeviceIdentityHelper::errorString() const
{
return m_error;
}
bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,
const QString& signatureBase64)
{
2026-07-09 09:17:30 +00:00
#ifndef HAVE_OPENSSL
Q_UNUSED(payload);
Q_UNUSED(signatureBase64);
m_error = "OpenSSL unavailable";
return false;
2026-07-09 09:17:30 +00:00
#else
QFile keyFile(
QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
if (!keyFile.open(QIODevice::ReadOnly))
{
m_error = "embedded device public key missing";
return false;
}
const QByteArray keyData = keyFile.readAll();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY* key = bio
? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr)
: nullptr;
if (bio)
BIO_free(bio);
if (!key)
{
m_error = "device public key invalid";
return false;
}
EVP_MD_CTX* context = EVP_MD_CTX_new();
const QByteArray signature =
QByteArray::fromBase64(signatureBase64.toUtf8());
const bool valid =
context
&& EVP_DigestVerifyInit(
context, nullptr, EVP_sha256(), nullptr, key)
== 1
&& EVP_DigestVerifyUpdate(
context, payload.constData(), payload.size())
== 1
&& EVP_DigestVerifyFinal(
context,
reinterpret_cast<const unsigned char*>(signature.constData()),
signature.size())
== 1;
if (context)
EVP_MD_CTX_free(context);
EVP_PKEY_free(key);
if (!valid)
m_error = "device credential RSA signature invalid";
return valid;
2026-07-09 09:17:30 +00:00
#endif
}
bool DeviceIdentityHelper::loadAndVerify(const QString& expectedAppId,
const QString& expectedChannel)
{
QFile credential(
QDir(m_installDir).filePath("config/client_identity.dat"));
if (!credential.open(QIODevice::ReadOnly))
{
m_error = "device credential is missing or unreadable";
return false;
}
QJsonParseError wrapperError;
const QJsonDocument wrapperDocument =
QJsonDocument::fromJson(credential.readAll(), &wrapperError);
if (wrapperError.error != QJsonParseError::NoError
|| !wrapperDocument.isObject())
{
m_error = "device credential JSON invalid";
return false;
}
const QJsonObject wrapper = wrapperDocument.object();
const QByteArray identityText =
wrapper.value("identity_text").toString().toUtf8();
if (identityText.isEmpty()
|| !verifySignature(
identityText, wrapper.value("signature").toString()))
{
return false;
}
QJsonParseError identityError;
const QJsonDocument identityDocument =
QJsonDocument::fromJson(identityText, &identityError);
if (identityError.error != QJsonParseError::NoError
|| !identityDocument.isObject())
{
m_error = "signed device identity JSON invalid";
return false;
}
const QJsonObject identity = identityDocument.object();
const QString signedInstallationId =
identity.value("installation_id").toString();
const QString signedDeviceId = identity.value("device_id").toString();
ConfigHelper& config = ConfigHelper::instance();
const QString localInstallationId =
config.getValue("Device", "installation_id").trimmed();
const QString localDeviceId =
config.getValue("Update", "device_id").trimmed();
if (identity.value("app_id").toString() != expectedAppId
|| identity.value("channel").toString() != expectedChannel
|| identity.value("license_id").toString().isEmpty()
|| signedInstallationId.isEmpty() || signedDeviceId.isEmpty())
{
m_error = "device/license credential identity mismatch";
return false;
}
if (localInstallationId.isEmpty()
|| signedInstallationId != localInstallationId)
{
m_error = "device credential belongs to another installation";
return false;
}
if (!localDeviceId.isEmpty() && signedDeviceId != localDeviceId)
{
m_error = "device credential does not match the registered device";
return false;
}
const QString signedMachineHash =
identity.value("machine_hash").toString();
if (!signedMachineHash.isEmpty()
&& signedMachineHash.compare(
machineHash(localInstallationId), Qt::CaseInsensitive)
!= 0)
{
m_error = "device credential belongs to another machine";
return false;
}
const QDateTime expiry = QDateTime::fromString(
identity.value("valid_until").toString(), Qt::ISODate);
if (!expiry.isValid() || expiry <= QDateTime::currentDateTimeUtc())
{
m_error = "license expired";
return false;
}
m_deviceId = signedDeviceId;
return true;
}
bool DeviceIdentityHelper::verifyLocal(const QString& appId,
const QString& channel)
{
m_error.clear();
m_deviceId.clear();
return loadAndVerify(appId, channel);
}
bool DeviceIdentityHelper::ensureIssued(const QString& apiBaseUrl,
const QString& clientToken,
const QString& appId,
const QString& channel,
const QString& licenseKey)
{
m_error.clear();
m_deviceId.clear();
ConfigHelper& config = ConfigHelper::instance();
if (loadAndVerify(appId, channel))
{
if (!config.setValue("Update", "device_id", m_deviceId))
{
m_error = QString("cannot save server device id to %1: %2")
.arg(config.configPath(), config.lastError());
return false;
}
return true;
}
const QString server = apiBaseUrl.trimmed();
if (appId.trimmed().isEmpty())
{
m_error = "app_id is empty in the embedded product configuration";
return false;
}
if (channel.trimmed().isEmpty())
{
m_error = "channel is empty in the embedded product configuration";
return false;
}
if (server.isEmpty()
|| server.contains("YOUR_SERVER_IP", Qt::CaseInsensitive))
{
m_error = "api_base_url is not configured";
return false;
}
if (clientToken.trimmed().isEmpty())
{
m_error = "client_token is empty in the embedded product configuration";
return false;
}
if (licenseKey.trimmed().isEmpty())
{
m_error = "license_key is empty";
return false;
}
QString installationId =
config.getValue("Device", "installation_id").trimmed();
if (installationId.isEmpty())
{
installationId =
QUuid::createUuid().toString(QUuid::WithoutBraces);
if (!config.setValue("Device", "installation_id", installationId))
{
m_error = QString("cannot save installation id to %1: %2")
.arg(config.configPath(), config.lastError());
return false;
}
}
const QJsonObject requestBody{
{"app_id", appId},
{"channel", channel},
{"license_key", licenseKey},
{"installation_id", installationId},
{"machine_hash", machineHash(installationId)}};
QNetworkAccessManager manager;
QNetworkRequest request{QUrl(server + "/api/v1/device/issue")};
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setRawHeader("X-Client-Token", clientToken.toUtf8());
QNetworkReply* reply = manager.post(
request, QJsonDocument(requestBody).toJson(QJsonDocument::Compact));
QEventLoop loop;
bool timeoutValid = false;
int timeoutMs =
config.getValue("Update", "request_timeout_ms").toInt(&timeoutValid);
if (!timeoutValid || timeoutMs < 1000)
timeoutMs = 5000;
QTimer timer;
timer.setSingleShot(true);
QObject::connect(&timer, &QTimer::timeout, [&]() {
if (reply && reply->isRunning())
reply->abort();
});
QObject::connect(reply, &QNetworkReply::finished,
&loop, &QEventLoop::quit);
timer.start(timeoutMs);
loop.exec();
timer.stop();
const int status =
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray responseBytes = reply->readAll();
const QString networkError = reply->errorString();
reply->deleteLater();
if (status != 200)
{
m_error = QString("device issue failed (HTTP %1): %2 %3")
.arg(status)
.arg(networkError, QString::fromUtf8(responseBytes));
return false;
}
QJsonParseError responseError;
const QJsonDocument responseDocument =
QJsonDocument::fromJson(responseBytes, &responseError);
if (responseError.error != QJsonParseError::NoError
|| !responseDocument.isObject())
{
m_error = "device issue response is invalid JSON";
return false;
}
const QJsonObject response = responseDocument.object();
const QJsonObject wrapper{
{"identity_text", response.value("identity_text")},
{"signature", response.value("signature")}};
if (!wrapper.value("identity_text").isString()
|| wrapper.value("identity_text").toString().isEmpty()
|| !wrapper.value("signature").isString()
|| wrapper.value("signature").toString().isEmpty())
{
m_error = "device issue response is missing signed identity fields";
return false;
}
const QString credentialPath =
QDir(m_installDir).filePath("config/client_identity.dat");
if (!QDir().mkpath(QFileInfo(credentialPath).path()))
{
m_error = "cannot create the device credential directory";
return false;
}
QSaveFile output(credentialPath);
const QByteArray credentialBytes =
QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
if (!output.open(QIODevice::WriteOnly)
|| output.write(credentialBytes) != credentialBytes.size()
|| !output.commit())
{
m_error = QString("cannot save device credential to %1: %2")
.arg(credentialPath, output.errorString());
return false;
}
if (!loadAndVerify(appId, channel))
return false;
if (!config.setValue("Update", "device_id", m_deviceId))
{
m_error = QString("cannot save server device id to %1: %2")
.arg(config.configPath(), config.lastError());
return false;
}
return true;
2026-07-09 09:17:30 +00:00
}