#include "DeviceIdentityHelper.h" #include "ConfigHelper.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef HAVE_OPENSSL #include #include #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) { #ifndef HAVE_OPENSSL Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false; #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(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; #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; }