Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/gui/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ target_sources(OpenCloudGui PRIVATE
application.cpp
fetchserversettings.cpp
commonstrings.cpp
clientcertificatedialog.cpp
connectionvalidator.cpp
folder.cpp
folderdefinition.cpp
Expand Down
36 changes: 36 additions & 0 deletions src/gui/accountmanager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ auto caCertsKeyC()
return QStringLiteral("CaCertificates");
}

auto clientCertKeyC()
{
return QStringLiteral("ClientCertificate");
}

auto clientKeyKeyC()
{
return QStringLiteral("ClientPrivateKey");
}

auto accountsC()
{
return QStringLiteral("Accounts");
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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);
}
Expand Down
8 changes: 7 additions & 1 deletion src/gui/accountsettings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -489,6 +489,12 @@ const QSet<Notification> &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
Expand Down
1 change: 1 addition & 0 deletions src/gui/accountsettings.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
20 changes: 20 additions & 0 deletions src/gui/accountstate.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
158 changes: 158 additions & 0 deletions src/gui/clientcertificatedialog.cpp
Original file line number Diff line number Diff line change
@@ -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 <QFileDialog>
#include <QLocale>
#include <QGroupBox>
#include <QInputDialog>
#include <QLoggingCategory>
#include <QMessageBox>
#include <QVBoxLayout>

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(
"<table>"
"<tr><td><b>%1</b></td><td>%2</td></tr>"
"<tr><td><b>%3</b></td><td>%4</td></tr>"
"<tr><td><b>%5</b></td><td>%6</td></tr>"
"<tr><td><b>%7</b></td><td>%8</td></tr>"
"<tr><td><b>%9</b></td><td>%10</td></tr>"
"</table>")
.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);
}
}

}
46 changes: 46 additions & 0 deletions src/gui/clientcertificatedialog.h
Original file line number Diff line number Diff line change
@@ -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 <QLabel>
#include <QPushButton>
#include <QWidget>

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;
};

}
14 changes: 10 additions & 4 deletions src/gui/connectionvalidator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
4 changes: 4 additions & 0 deletions src/gui/qml/FolderDelegate.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions src/libsync/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ add_library(libsync SHARED
jobqueue.cpp
logger.cpp
accessmanager.cpp
clientcertificateutils.cpp
configfile.cpp
globalconfig.cpp
abstractnetworkjob.cpp
Expand Down
Loading