Compare commits

...

1 Commits

Author SHA1 Message Date
dranik 62fa095153 Fix: verify installer authenticity before launch 2026-06-12 16:25:32 +03:00
5 changed files with 106 additions and 1 deletions
+87 -1
View File
@@ -1,6 +1,9 @@
#include "updateController.h"
#include <QCryptographicHash>
#include <QFile>
#include <QNetworkReply>
#include <QProcess>
#include <QVersionNumber>
#include <QUrl>
#include <QJsonDocument>
@@ -29,6 +32,17 @@ namespace
const QLatin1String kInstallerRemoteFileNamePattern("AmneziaVPN_%1_linux_x64.run");
const QString kInstallerLocalPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation) + "/AmneziaVPN.run";
#endif
#if defined(Q_OS_MACOS) && !defined(MACOS_NE)
// Pinned signing identity of release installers. spctl prints the origin as
// origin=Developer ID Installer: <Name> (<TEAMID>)
// We require this exact substring so a different (even validly notarized)
// package is rejected.
// TODO(#1): replace with the real release identity before shipping, e.g.
// "Developer ID Installer: Amnezia OU (XXXXXXXXXX)".
// Until set, macOS update verification fails closed (updates are rejected).
const QLatin1String kExpectedMacSigningOrigin("Developer ID Installer: SET_ME_BEFORE_RELEASE");
#endif
}
UpdateController::UpdateController(SecureAppSettingsRepository* appSettingsRepository, QObject *parent)
@@ -121,6 +135,10 @@ void UpdateController::fetchGatewayUrl()
}
m_baseUrl = baseUrl;
// Expected installer SHA-256 for the requesting OS, delivered over the
// encrypted gateway channel (used by Windows/Linux verification).
m_expectedSha256 = gatewayData.value("sha256").toString().trimmed().toLower();
fetchVersionInfo();
});
});
@@ -209,6 +227,53 @@ QString UpdateController::composeDownloadUrl() const
#endif
}
bool UpdateController::verifySha256(const QByteArray &data) const
{
if (m_expectedSha256.isEmpty()) {
logger.error() << "No expected installer checksum provided by gateway; rejecting installer";
return false;
}
const QString actual = QString::fromLatin1(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex());
if (actual.compare(m_expectedSha256, Qt::CaseInsensitive) != 0) {
logger.error() << "Installer checksum mismatch. expected:" << m_expectedSha256 << "actual:" << actual;
return false;
}
return true;
}
#if defined(Q_OS_MACOS) && !defined(MACOS_NE)
bool UpdateController::verifyMacInstallerSignature(const QString &installerPath) const
{
// Gatekeeper assessment for installer packages: the .pkg must be signed AND
// notarized under our Apple Developer ID. spctl writes its verdict to stderr.
QProcess spctl;
spctl.start(QStringLiteral("/usr/sbin/spctl"),
{ QStringLiteral("--assess"), QStringLiteral("--type"), QStringLiteral("install"),
QStringLiteral("-vv"), installerPath });
if (!spctl.waitForFinished(15000)) {
logger.error() << "spctl assessment did not finish in time";
return false;
}
const QString output = QString::fromUtf8(spctl.readAllStandardError()) + QString::fromUtf8(spctl.readAllStandardOutput());
if (spctl.exitStatus() != QProcess::NormalExit || spctl.exitCode() != 0) {
logger.error() << "spctl rejected the installer:" << output.trimmed();
return false;
}
// Pin the signing identity so a different (even validly notarized) package is rejected.
if (!output.contains(kExpectedMacSigningOrigin)) {
logger.error() << "Installer signed by an unexpected identity:" << output.trimmed();
return false;
}
return true;
}
#endif
void UpdateController::runInstaller()
{
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE)
@@ -225,6 +290,8 @@ void UpdateController::runInstaller()
QObject::connect(reply, &QNetworkReply::finished, [this, reply]() {
if (reply->error() == QNetworkReply::NoError) {
const QByteArray data = reply->readAll();
QFile file(kInstallerLocalPath);
if (!file.open(QIODevice::WriteOnly)) {
logger.error() << "Failed to open installer file for writing:" << kInstallerLocalPath << "Error:" << file.errorString();
@@ -232,7 +299,7 @@ void UpdateController::runInstaller()
return;
}
if (file.write(reply->readAll()) == -1) {
if (file.write(data) == -1) {
logger.error() << "Failed to write installer data to file:" << kInstallerLocalPath << "Error:" << file.errorString();
file.close();
reply->deleteLater();
@@ -241,6 +308,25 @@ void UpdateController::runInstaller()
file.close();
// Fail-closed: never launch an installer whose authenticity we could not verify.
#if defined(Q_OS_MACOS) && !defined(MACOS_NE)
if (!verifyMacInstallerSignature(kInstallerLocalPath)) {
logger.error() << "Installer signature verification failed; refusing to launch";
QFile::remove(kInstallerLocalPath);
emit installerVerificationFailed(tr("Update verification failed: the installer signature is invalid. The update was not launched."));
reply->deleteLater();
return;
}
#else
if (!verifySha256(data)) {
logger.error() << "Installer checksum verification failed; refusing to launch";
QFile::remove(kInstallerLocalPath);
emit installerVerificationFailed(tr("Update verification failed: the installer checksum does not match. The update was not launched."));
reply->deleteLater();
return;
}
#endif
#if defined(Q_OS_WINDOWS)
runWindowsInstaller(kInstallerLocalPath);
#elif defined(Q_OS_MACOS) && !defined(MACOS_NE)
@@ -23,6 +23,7 @@ public slots:
signals:
void updateFound();
void installerVerificationFailed(const QString &reason);
private:
void finishUpdateCheck();
@@ -43,11 +44,18 @@ private:
QString m_version;
QString m_releaseDate;
QString m_downloadUrl;
QString m_expectedSha256;
bool m_updateCheckRunning = false;
// Verify the downloaded installer before launching it (fail-closed):
// Windows/Linux compare a SHA-256 delivered over the encrypted gateway channel.
bool verifySha256(const QByteArray &data) const;
#if defined(Q_OS_WINDOWS)
int runWindowsInstaller(const QString &installerPath);
#elif defined(Q_OS_MACOS)
// macOS verifies the .pkg Gatekeeper assessment (notarized + pinned Developer ID).
bool verifyMacInstallerSignature(const QString &installerPath) const;
int runMacInstaller(const QString &installerPath);
#elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID)
int runLinuxInstaller(const QString &installerPath);
@@ -5,6 +5,8 @@ UpdateUiController::UpdateUiController(UpdateController* updateController, QObje
{
if (m_updateController) {
connect(m_updateController, &UpdateController::updateFound, this, &UpdateUiController::updateFound);
connect(m_updateController, &UpdateController::installerVerificationFailed, this,
&UpdateUiController::installerVerificationFailed);
}
}
@@ -25,6 +25,7 @@ public slots:
signals:
void updateFound();
void installerVerificationFailed(const QString &message);
private:
UpdateController* m_updateController;
+8
View File
@@ -454,4 +454,12 @@ Window {
anchors.fill: parent
}
}
Connections {
target: UpdateController
function onInstallerVerificationFailed(message) {
PageController.showBusyIndicator(false)
PageController.showNotificationMessage(message)
}
}
}