diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 6f53259813..5a953efcba 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -38,6 +38,7 @@ target_sources(OpenCloudGui PRIVATE application.cpp fetchserversettings.cpp commonstrings.cpp + clientcertificatedialog.cpp connectionvalidator.cpp folder.cpp folderdefinition.cpp diff --git a/src/gui/accountmanager.cpp b/src/gui/accountmanager.cpp index 7ccbea0352..61d1036965 100644 --- a/src/gui/accountmanager.cpp +++ b/src/gui/accountmanager.cpp @@ -55,6 +55,16 @@ auto caCertsKeyC() return QStringLiteral("CaCertificates"); } +auto clientCertKeyC() +{ + return QStringLiteral("ClientCertificate"); +} + +auto clientKeyKeyC() +{ + return QStringLiteral("ClientPrivateKey"); +} + auto accountsC() { return QStringLiteral("Accounts"); @@ -121,6 +131,23 @@ bool AccountManager::restore() qCInfo(lcAccountManager) << u"Restored: " << certs.count() << u" unknown certs."; acc->setApprovedCerts(certs); + // restore client certificate for mTLS + const auto clientCertData = settings.value(clientCertKeyC()).toByteArray(); + const auto clientKeyData = settings.value(clientKeyKeyC()).toByteArray(); + if (!clientCertData.isEmpty() && !clientKeyData.isEmpty()) { + const auto clientCerts = QSslCertificate::fromData(clientCertData, QSsl::Pem); + if (!clientCerts.isEmpty()) { + QSslKey clientKey(clientKeyData, QSsl::Rsa, QSsl::Pem); + if (clientKey.isNull()) { + clientKey = QSslKey(clientKeyData, QSsl::Ec, QSsl::Pem); + } + if (!clientKey.isNull()) { + acc->setClientCertificate(clientCerts.first(), clientKey); + qCInfo(lcAccountManager) << u"Restored client certificate for mTLS:" << clientCerts.first().subjectDisplayName(); + } + } + } + if (auto accState = AccountState::loadFromSettings(acc, settings)) { addAccountState(std::move(accState)); } @@ -162,6 +189,15 @@ void AccountManager::save() settings.setValue(caCertsKeyC(), certs); } + // save client certificate for mTLS + if (account->hasClientCertificate()) { + settings.setValue(clientCertKeyC(), account->clientCertificate().toPem()); + settings.setValue(clientKeyKeyC(), account->clientPrivateKey().toPem()); + } else { + settings.remove(clientCertKeyC()); + settings.remove(clientKeyKeyC()); + } + // save the account state accountState->writeToSettings(settings); } diff --git a/src/gui/accountsettings.cpp b/src/gui/accountsettings.cpp index 5ebc5b52f8..c94c11f75b 100644 --- a/src/gui/accountsettings.cpp +++ b/src/gui/accountsettings.cpp @@ -16,8 +16,8 @@ #include "accountsettings.h" #include "ui_accountsettings.h" - #include "account.h" +#include "clientcertificatedialog.h" #include "accountmanager.h" #include "accountstate.h" #include "application.h" @@ -489,6 +489,12 @@ const QSet &AccountSettings::notifications() const return _notifications; } +void AccountSettings::slotConfigureClientCertificate() +{ + auto *dialog = new ClientCertificateDialog(_accountState->account(), this); + addModalLegacyDialog(dialog, ModalWidgetSizePolicy::Minimum); +} + void AccountSettings::slotDeleteAccount() { // Deleting the account potentially deletes 'this', so diff --git a/src/gui/accountsettings.h b/src/gui/accountsettings.h index a03618ebe4..7c60ebe37b 100644 --- a/src/gui/accountsettings.h +++ b/src/gui/accountsettings.h @@ -92,6 +92,7 @@ class OPENCLOUD_GUI_EXPORT AccountSettings : public QWidget public Q_SLOTS: void slotAccountStateChanged(); void slotSpacesUpdated(); + Q_INVOKABLE void slotConfigureClientCertificate(); protected Q_SLOTS: void slotAddFolder(); diff --git a/src/gui/accountstate.cpp b/src/gui/accountstate.cpp index 02251e1f23..3bb764b174 100644 --- a/src/gui/accountstate.cpp +++ b/src/gui/accountstate.cpp @@ -75,6 +75,7 @@ AccountState::AccountState(AccountPtr account) connect(account.data(), &Account::credentialsAsked, this, &AccountState::slotCredentialsAsked); connect(account.data(), &Account::unknownConnectionState, this, [this] { checkConnectivity(true); }); + connect(account.data(), &Account::clientCertificateChanged, this, [this] { checkConnectivity(true); }); connect(account.data(), &Account::capabilitiesChanged, this, [this] { if (_account->capabilities().checkForUpdates() && isOcApp()) { @@ -90,6 +91,18 @@ AccountState::AccountState(AccountPtr account) case NetworkInformation::Reachability::Site: [[fallthrough]]; case NetworkInformation::Reachability::Unknown: + // Abort any running validator — its results are stale since the network changed. + // Without this, the guard in checkConnectivity() would skip the new attempt + // and leave the account stuck in "Connecting" state indefinitely. + if (_connectionValidator) { + _connectionValidator->disconnect(this); + _connectionValidator->deleteLater(); + _connectionValidator.clear(); + } + // Drop stale TCP connections from the old network interface so the + // upcoming connectivity check (and all subsequent requests) open + // fresh sockets on the new interface. + _account->accessManager()->clearConnectionCache(); // the connection might not yet be established QTimer::singleShot(0, this, [this] { checkConnectivity(false); }); break; @@ -131,6 +144,13 @@ AccountState::AccountState(AccountPtr account) _queueGuard.unblock(); } + // Abort any running validator — captive portal state changed, so its results are stale. + if (_connectionValidator) { + _connectionValidator->disconnect(this); + _connectionValidator->deleteLater(); + _connectionValidator.clear(); + } + // A direct connect is not possible, because then the state parameter of `isBehindCaptivePortalChanged` // would become the `verifyServerState` argument to `checkConnectivity`. // The call is also made for when we "go behind" a captive portal. That ensures that not diff --git a/src/gui/clientcertificatedialog.cpp b/src/gui/clientcertificatedialog.cpp new file mode 100644 index 0000000000..edc47e85bc --- /dev/null +++ b/src/gui/clientcertificatedialog.cpp @@ -0,0 +1,158 @@ +/* + * Copyright (C) by OpenCloud GmbH + * + * 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. + */ + +#include "clientcertificatedialog.h" + +#include "libsync/clientcertificateutils.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace OCC { + +Q_LOGGING_CATEGORY(lcClientCertDialog, "gui.clientcertdialog", QtInfoMsg) + +ClientCertificateDialog::ClientCertificateDialog(const AccountPtr &account, QWidget *parent) + : QWidget(parent) + , _account(account) +{ + setWindowTitle(tr("Client Certificate (mTLS)")); + setAttribute(Qt::WA_DeleteOnClose); + + auto *layout = new QVBoxLayout(this); + + auto *statusGroup = new QGroupBox(tr("Certificate Status"), this); + auto *statusLayout = new QVBoxLayout(statusGroup); + + _statusLabel = new QLabel(this); + _statusLabel->setWordWrap(true); + statusLayout->addWidget(_statusLabel); + + _detailsLabel = new QLabel(this); + _detailsLabel->setWordWrap(true); + _detailsLabel->setTextFormat(Qt::RichText); + statusLayout->addWidget(_detailsLabel); + + layout->addWidget(statusGroup); + + auto *buttonLayout = new QHBoxLayout; + _importButton = new QPushButton(tr("Import Certificate..."), this); + _removeButton = new QPushButton(tr("Remove Certificate"), this); + + auto *closeButton = new QPushButton(tr("Close"), this); + + buttonLayout->addWidget(_importButton); + buttonLayout->addWidget(_removeButton); + buttonLayout->addStretch(); + buttonLayout->addWidget(closeButton); + + layout->addLayout(buttonLayout); + layout->addStretch(); + + connect(_importButton, &QPushButton::clicked, this, &ClientCertificateDialog::slotImportCertificate); + connect(_removeButton, &QPushButton::clicked, this, &ClientCertificateDialog::slotRemoveCertificate); + connect(closeButton, &QPushButton::clicked, this, &ClientCertificateDialog::close); + + updateCertificateDisplay(); +} + +void ClientCertificateDialog::slotImportCertificate() +{ + const QString filePath = QFileDialog::getOpenFileName( + this, + tr("Select PKCS#12 Certificate"), + QString(), + tr("PKCS#12 Files (*.p12 *.pfx);;All Files (*)")); + + if (filePath.isEmpty()) { + return; + } + + bool ok = false; + const QString password = QInputDialog::getText( + this, + tr("Certificate Password"), + tr("Enter the password for the certificate file:"), + QLineEdit::Password, + QString(), + &ok); + + if (!ok) { + return; + } + + ClientCertificateUtils::Pkcs12Result result; + if (!ClientCertificateUtils::importPkcs12(filePath, password, &result)) { + QMessageBox::warning(this, tr("Import Failed"), + tr("Failed to import the certificate file.\n\n" + "Please check that the file is a valid PKCS#12 (.p12/.pfx) file " + "and that the password is correct.")); + return; + } + + _account->setClientCertificate(result.certificate, result.privateKey); + + if (!result.caCertificates.isEmpty()) { + _account->addApprovedCerts({result.caCertificates.begin(), result.caCertificates.end()}); + } + + qCInfo(lcClientCertDialog) << "Client certificate imported successfully:" << result.certificate.subjectDisplayName(); + + updateCertificateDisplay(); +} + +void ClientCertificateDialog::slotRemoveCertificate() +{ + _account->clearClientCertificate(); + qCInfo(lcClientCertDialog) << "Client certificate removed"; + updateCertificateDisplay(); +} + +void ClientCertificateDialog::updateCertificateDisplay() +{ + if (_account->hasClientCertificate()) { + const auto &cert = _account->clientCertificate(); + _statusLabel->setText(tr("A client certificate is configured for mTLS authentication.")); + + const QString details = QStringLiteral( + "" + "" + "" + "" + "" + "" + "
%1%2
%3%4
%5%6
%7%8
%9%10
") + .arg( + tr("Subject:"), cert.subjectDisplayName(), + tr("Issuer:"), cert.issuerDisplayName(), + tr("Valid from:"), QLocale().toString(cert.effectiveDate(), QLocale::LongFormat), + tr("Expires:"), QLocale().toString(cert.expiryDate(), QLocale::LongFormat), + tr("Fingerprint (SHA-256):"), QString::fromLatin1(cert.digest(QCryptographicHash::Sha256).toHex(':'))); + + _detailsLabel->setText(details); + _detailsLabel->setVisible(true); + _removeButton->setEnabled(true); + } else { + _statusLabel->setText(tr("No client certificate configured.")); + _detailsLabel->setVisible(false); + _removeButton->setEnabled(false); + } +} + +} diff --git a/src/gui/clientcertificatedialog.h b/src/gui/clientcertificatedialog.h new file mode 100644 index 0000000000..f413de5059 --- /dev/null +++ b/src/gui/clientcertificatedialog.h @@ -0,0 +1,46 @@ +/* + * Copyright (C) by OpenCloud GmbH + * + * 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. + */ + +#pragma once + +#include "account.h" + +#include +#include +#include + +namespace OCC { + +class ClientCertificateDialog : public QWidget +{ + Q_OBJECT + +public: + explicit ClientCertificateDialog(const AccountPtr &account, QWidget *parent = nullptr); + +private Q_SLOTS: + void slotImportCertificate(); + void slotRemoveCertificate(); + +private: + void updateCertificateDisplay(); + + AccountPtr _account; + QLabel *_statusLabel; + QLabel *_detailsLabel; + QPushButton *_importButton; + QPushButton *_removeButton; +}; + +} diff --git a/src/gui/connectionvalidator.cpp b/src/gui/connectionvalidator.cpp index a611f6f7b9..b3617ab6f4 100644 --- a/src/gui/connectionvalidator.cpp +++ b/src/gui/connectionvalidator.cpp @@ -47,11 +47,17 @@ ConnectionValidator::ConnectionValidator(AccountPtr account, QObject *parent) : QObject(parent) , _account(account) { - // TODO: 6.0 abort validator on 5min timeout + // Hard timeout: abort the validator if it hasn't completed within 60 seconds. + // This prevents the account from getting stuck in "Connecting" state when + // a network change leaves HTTP requests hanging on a dead socket. auto timer = new QTimer(this); - timer->setInterval(30s); - connect(timer, &QTimer::timeout, this, - [this] { qCInfo(lcConnectionValidator) << u"ConnectionValidator" << _account->displayNameWithHost() << u"still running after" << _duration; }); + timer->setSingleShot(true); + timer->setInterval(60s); + connect(timer, &QTimer::timeout, this, [this] { + qCWarning(lcConnectionValidator) << u"ConnectionValidator for" << _account->displayNameWithHost() << u"timed out after" << _duration; + _errors.append(tr("timeout")); + reportResult(Timeout); + }); timer->start(); } diff --git a/src/gui/qml/FolderDelegate.qml b/src/gui/qml/FolderDelegate.qml index 85e56fc6c3..ebb678b7cb 100644 --- a/src/gui/qml/FolderDelegate.qml +++ b/src/gui/qml/FolderDelegate.qml @@ -87,6 +87,10 @@ Pane { text: CommonStrings.showInWebBrowser() onTriggered: Qt.openUrlExternally(accountSettings.accountState.account.url) } + MenuItem { + text: qsTr("Client Certificate (mTLS)...") + onTriggered: accountSettings.slotConfigureClientCertificate() + } MenuItem { text: qsTr("Remove") diff --git a/src/libsync/CMakeLists.txt b/src/libsync/CMakeLists.txt index 4c4dbe4022..592ba305ec 100644 --- a/src/libsync/CMakeLists.txt +++ b/src/libsync/CMakeLists.txt @@ -22,6 +22,7 @@ add_library(libsync SHARED jobqueue.cpp logger.cpp accessmanager.cpp + clientcertificateutils.cpp configfile.cpp globalconfig.cpp abstractnetworkjob.cpp diff --git a/src/libsync/accessmanager.cpp b/src/libsync/accessmanager.cpp index 06f408ca8c..ed2b63a5d1 100644 --- a/src/libsync/accessmanager.cpp +++ b/src/libsync/accessmanager.cpp @@ -99,6 +99,10 @@ QNetworkReply *AccessManager::createRequest(QNetworkAccessManager::Operation op, // this behavior does not match the documentation sslConfiguration.addCaCertificates({ _customTrustedCaCertificates.begin(), _customTrustedCaCertificates.end() }); } + if (!_clientCertificate.isNull()) { + sslConfiguration.setLocalCertificate(_clientCertificate); + sslConfiguration.setPrivateKey(_clientPrivateKey); + } newRequest.setSslConfiguration(sslConfiguration); const auto reply = QNetworkAccessManager::createRequest(op, newRequest, outgoingData); @@ -126,6 +130,13 @@ void AccessManager::addCustomTrustedCaCertificates(const QList clearConnectionCache(); } +void AccessManager::setClientCertificate(const QSslCertificate &cert, const QSslKey &key) +{ + _clientCertificate = cert; + _clientPrivateKey = key; + clearConnectionCache(); +} + CookieJar *AccessManager::openCloudCookieJar() const { auto jar = qobject_cast(cookieJar()); diff --git a/src/libsync/accessmanager.h b/src/libsync/accessmanager.h index 5a8180b2af..c08623826f 100644 --- a/src/libsync/accessmanager.h +++ b/src/libsync/accessmanager.h @@ -17,6 +17,8 @@ #include "opencloudsynclib.h" #include +#include +#include class QByteArray; class QUrl; @@ -48,6 +50,12 @@ class OPENCLOUD_SYNC_EXPORT AccessManager : public QNetworkAccessManager */ void addCustomTrustedCaCertificates(const QList &certificates); + /*** + * Set the client certificate and private key for mTLS authentication. + * Warning calling this will break running network jobs. + */ + void setClientCertificate(const QSslCertificate &cert, const QSslKey &key); + CookieJar *openCloudCookieJar() const; /*** @@ -60,6 +68,8 @@ class OPENCLOUD_SYNC_EXPORT AccessManager : public QNetworkAccessManager private: QSet _customTrustedCaCertificates; + QSslCertificate _clientCertificate; + QSslKey _clientPrivateKey; }; } // namespace OCC diff --git a/src/libsync/account.cpp b/src/libsync/account.cpp index 6c02305098..d1abff5253 100644 --- a/src/libsync/account.cpp +++ b/src/libsync/account.cpp @@ -195,6 +195,9 @@ void Account::setCredentials(AbstractCredentials *cred) if (jar) { _am->setCookieJar(jar); } + if (!_clientCertificate.isNull()) { + _am->setClientCertificate(_clientCertificate, _clientPrivateKey); + } connect(_credentials.data(), &AbstractCredentials::fetched, this, [this] { Q_EMIT credentialsFetched(); _queueGuard.unblock(); @@ -251,6 +254,37 @@ void Account::addApprovedCerts(const QSet &certs) Q_EMIT wantsAccountSaved(this); } +QSslCertificate Account::clientCertificate() const +{ + return _clientCertificate; +} + +QSslKey Account::clientPrivateKey() const +{ + return _clientPrivateKey; +} + +bool Account::hasClientCertificate() const +{ + return !_clientCertificate.isNull(); +} + +void Account::setClientCertificate(const QSslCertificate &cert, const QSslKey &key) +{ + _clientCertificate = cert; + _clientPrivateKey = key; + if (_am) { + _am->setClientCertificate(cert, key); + } + Q_EMIT clientCertificateChanged(); + Q_EMIT wantsAccountSaved(this); +} + +void Account::clearClientCertificate() +{ + setClientCertificate(QSslCertificate(), QSslKey()); +} + void Account::setUrl(const QUrl &url) { if (_url != url) { diff --git a/src/libsync/account.h b/src/libsync/account.h index f0957d3a67..9a7dc2feb5 100644 --- a/src/libsync/account.h +++ b/src/libsync/account.h @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -148,6 +149,22 @@ class OPENCLOUD_SYNC_EXPORT Account : public QObject */ void addApprovedCerts(const QSet &certs); + /** Client certificate for mTLS authentication */ + QSslCertificate clientCertificate() const; + QSslKey clientPrivateKey() const; + bool hasClientCertificate() const; + + /*** + * Set the client certificate and private key for mTLS. + * Warning calling this will break running network jobs on the current access manager. + */ + void setClientCertificate(const QSslCertificate &cert, const QSslKey &key); + + /*** + * Remove the client certificate configuration. + */ + void clearClientCertificate(); + /** Access the server capabilities */ const Capabilities &capabilities() const; void setCapabilities(const Capabilities &caps); @@ -211,6 +228,8 @@ public Q_SLOTS: void unknownConnectionState(); + void clientCertificateChanged(); + void requestUrlUpdate(const QUrl &newUrl); // the signal exists on the Account object as the Approvider itself can change during runtime @@ -234,6 +253,8 @@ public Q_SLOTS: QString _cacheDirectory; QSet _approvedCerts; + QSslCertificate _clientCertificate; + QSslKey _clientPrivateKey; Capabilities _capabilities; QPointer _am; QPointer _networkCache = nullptr; diff --git a/src/libsync/clientcertificateutils.cpp b/src/libsync/clientcertificateutils.cpp new file mode 100644 index 0000000000..a1d681f101 --- /dev/null +++ b/src/libsync/clientcertificateutils.cpp @@ -0,0 +1,66 @@ +/* + * Copyright (C) by OpenCloud GmbH + * + * 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. + */ + +#include "clientcertificateutils.h" + +#include +#include +#include + +namespace OCC::ClientCertificateUtils { + +Q_LOGGING_CATEGORY(lcClientCert, "sync.clientcert", QtInfoMsg) + +bool importPkcs12(const QString &filePath, const QString &password, Pkcs12Result *result) +{ + Q_ASSERT(result); + + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) { + qCWarning(lcClientCert) << "Failed to open PKCS#12 file:" << filePath; + return false; + } + + QByteArray data = file.readAll(); + file.close(); + + QSslCertificate cert; + QSslKey key; + QList caCerts; + + QBuffer buffer(&data); + buffer.open(QIODevice::ReadOnly); + if (!QSslCertificate::importPkcs12(&buffer, &key, &cert, &caCerts, password.toUtf8())) { + qCWarning(lcClientCert) << "Failed to import PKCS#12 file (wrong password or invalid format):" << filePath; + return false; + } + + if (cert.isNull() || key.isNull()) { + qCWarning(lcClientCert) << "PKCS#12 file did not contain a valid certificate or key:" << filePath; + return false; + } + + qCInfo(lcClientCert) << "Successfully imported client certificate:" + << cert.subjectDisplayName() + << "issued by" << cert.issuerDisplayName() + << "expires" << cert.expiryDate().toString(Qt::ISODate); + + result->certificate = cert; + result->privateKey = key; + result->caCertificates = caCerts; + + return true; +} + +} diff --git a/src/libsync/clientcertificateutils.h b/src/libsync/clientcertificateutils.h new file mode 100644 index 0000000000..c5ee86ac01 --- /dev/null +++ b/src/libsync/clientcertificateutils.h @@ -0,0 +1,43 @@ +/* + * Copyright (C) by OpenCloud GmbH + * + * 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. + */ + +#pragma once + +#include "opencloudsynclib.h" + +#include +#include +#include +#include + +namespace OCC::ClientCertificateUtils { + +struct Pkcs12Result +{ + QSslCertificate certificate; + QSslKey privateKey; + QList caCertificates; +}; + +/** + * Import a PKCS#12 (.p12/.pfx) file. + * + * @param filePath Path to the PKCS#12 file + * @param password Password to decrypt the file + * @param result Output struct containing the certificate, key, and CA certs + * @return true on success, false on failure + */ +OPENCLOUD_SYNC_EXPORT bool importPkcs12(const QString &filePath, const QString &password, Pkcs12Result *result); + +} diff --git a/src/libsync/networkjobs/checkserverjobfactory.cpp b/src/libsync/networkjobs/checkserverjobfactory.cpp index ee8b687096..a06f954db3 100644 --- a/src/libsync/networkjobs/checkserverjobfactory.cpp +++ b/src/libsync/networkjobs/checkserverjobfactory.cpp @@ -69,6 +69,9 @@ CheckServerJobFactory CheckServerJobFactory::createFromAccount(const AccountPtr // in order to receive all ssl erorrs we need a fresh QNam auto nam = account->credentials()->createAM(); nam->setCustomTrustedCaCertificates(account->approvedCerts()); + if (account->hasClientCertificate()) { + nam->setClientCertificate(account->clientCertificate(), account->clientPrivateKey()); + } nam->setParent(parent); // do we start with the old cookies or new if (!(clearCookies && Theme::instance()->connectionValidatorClearCookies())) {