From 7979a108c96586d0fd64799cdc9ffcc5a3fb995b Mon Sep 17 00:00:00 2001 From: dela Date: Mon, 22 Jun 2026 13:51:10 +0800 Subject: [PATCH] feat: encrypt saved server profile tokens --- src/qEmbyCore/models/sync/configbundle.cpp | 5 +- .../services/manager/servermanager.cpp | 71 ++++++- .../services/sync/webdavsyncservice.cpp | 16 +- src/qEmbyCore/utils/serverprofilecrypto.cpp | 201 ++++++++++++++++++ src/qEmbyCore/utils/serverprofilecrypto.h | 25 +++ 5 files changed, 306 insertions(+), 12 deletions(-) create mode 100644 src/qEmbyCore/utils/serverprofilecrypto.cpp create mode 100644 src/qEmbyCore/utils/serverprofilecrypto.h diff --git a/src/qEmbyCore/models/sync/configbundle.cpp b/src/qEmbyCore/models/sync/configbundle.cpp index e19f252..4298aef 100644 --- a/src/qEmbyCore/models/sync/configbundle.cpp +++ b/src/qEmbyCore/models/sync/configbundle.cpp @@ -1,6 +1,7 @@ #include "configbundle.h" #include "../../config/configstore.h" +#include "../../utils/serverprofilecrypto.h" #include #include @@ -304,7 +305,9 @@ ConfigBundle ConfigBundle::collectFromLocal() f.close(); const QJsonDocument doc = QJsonDocument::fromJson(bytes); if (doc.isArray()) { - b.servers = doc.array(); + bool migrated = false; + b.servers = + ServerProfileCrypto::decryptServerArray(doc.array(), &migrated); } else { qWarning() << "[ConfigBundle] servers.json is not array, skipped"; } diff --git a/src/qEmbyCore/services/manager/servermanager.cpp b/src/qEmbyCore/services/manager/servermanager.cpp index cc24890..a3c3715 100644 --- a/src/qEmbyCore/services/manager/servermanager.cpp +++ b/src/qEmbyCore/services/manager/servermanager.cpp @@ -1,11 +1,25 @@ #include "servermanager.h" #include "../../api/embywebsocket.h" +#include "../../utils/serverprofilecrypto.h" +#include #include #include #include +#include #include #include +namespace +{ + +QString serversJsonPath() +{ + const QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + return QDir(dir).filePath(QStringLiteral("servers.json")); +} + +} // namespace + ServerManager::ServerManager(NetworkManager* nm, QObject* parent) : QObject(parent), m_network(nm) { loadSettings(); @@ -181,20 +195,55 @@ void ServerManager::saveSettings() { array.append(obj); } - QString path = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); - QDir().mkpath(path); - QFile file(path + "/servers.json"); - if (file.open(QIODevice::WriteOnly)) { - file.write(QJsonDocument(array).toJson()); + const QString filePath = serversJsonPath(); + const QString dirPath = QFileInfo(filePath).absolutePath(); + if (!QDir().mkpath(dirPath)) { + qWarning() << "[ServerManager] failed to create server config dir:" + << dirPath; + return; } + + bool encrypted = true; + const QJsonArray encryptedArray = + ServerProfileCrypto::encryptServerArray(array, &encrypted); + if (!encrypted) { + qWarning() << "[ServerManager] refusing to save server config because token encryption failed"; + return; + } + const QByteArray payload = QJsonDocument(encryptedArray).toJson(); + const QString tmpPath = filePath + QStringLiteral(".tmp"); + QFile file(tmpPath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + qWarning() << "[ServerManager] failed to open server config for write:" + << tmpPath << "|" << file.errorString(); + return; + } + if (file.write(payload) != payload.size()) { + qWarning() << "[ServerManager] failed to write complete server config:" + << tmpPath; + file.close(); + QFile::remove(tmpPath); + return; + } + file.close(); + + QFile::remove(filePath); + if (!QFile::rename(tmpPath, filePath)) { + qWarning() << "[ServerManager] failed to replace server config:" + << filePath; + QFile::remove(tmpPath); + return; + } + ServerProfileCrypto::setOwnerOnlyPermissions(filePath); } void ServerManager::loadSettings() { - QString path = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); - QFile file(path + "/servers.json"); + QFile file(serversJsonPath()); if (!file.open(QIODevice::ReadOnly)) return; - QJsonArray array = QJsonDocument::fromJson(file.readAll()).array(); + bool shouldResave = false; + QJsonArray array = ServerProfileCrypto::decryptServerArray( + QJsonDocument::fromJson(file.readAll()).array(), &shouldResave); m_servers.clear(); for (auto val : array) { QJsonObject obj = val.toObject(); @@ -220,6 +269,11 @@ void ServerManager::loadSettings() { if (!m_servers.isEmpty()) { setActiveServer(m_servers.first().id); } + + if (shouldResave) { + qInfo() << "[ServerManager] migrating plaintext server tokens to encrypted storage"; + saveSettings(); + } } void ServerManager::clearActiveSession() @@ -229,4 +283,3 @@ void ServerManager::clearActiveSession() m_activeClient.reset(); Q_EMIT activeServerChanged(m_activeProfile); } - diff --git a/src/qEmbyCore/services/sync/webdavsyncservice.cpp b/src/qEmbyCore/services/sync/webdavsyncservice.cpp index ede863e..da21d0f 100644 --- a/src/qEmbyCore/services/sync/webdavsyncservice.cpp +++ b/src/qEmbyCore/services/sync/webdavsyncservice.cpp @@ -2,6 +2,7 @@ #include "../../api/webdav/webdavclient.h" #include "../../config/configstore.h" +#include "../../utils/serverprofilecrypto.h" #include "../../utils/securesecretbox.h" #include @@ -81,7 +82,8 @@ QJsonArray readServersJson() } const QByteArray bytes = f.readAll(); f.close(); - return QJsonDocument::fromJson(bytes).array(); + return ServerProfileCrypto::decryptServerArray( + QJsonDocument::fromJson(bytes).array()); } bool writeServersJson(const QJsonArray &arr) @@ -94,7 +96,16 @@ bool writeServersJson(const QJsonArray &arr) return false; } - const QByteArray payload = QJsonDocument(arr).toJson(QJsonDocument::Indented); + bool encryptedOk = true; + const QJsonArray encrypted = + ServerProfileCrypto::encryptServerArray(arr, &encryptedOk); + if (!encryptedOk) + { + qWarning() << "[WebdavSyncService] refusing to write servers.json because token encryption failed"; + return false; + } + const QByteArray payload = + QJsonDocument(encrypted).toJson(QJsonDocument::Indented); const QString tmpPath = path + QStringLiteral(".tmp"); { @@ -124,6 +135,7 @@ bool writeServersJson(const QJsonArray &arr) } QFile::remove(tmpPath); } + ServerProfileCrypto::setOwnerOnlyPermissions(path); return true; } diff --git a/src/qEmbyCore/utils/serverprofilecrypto.cpp b/src/qEmbyCore/utils/serverprofilecrypto.cpp new file mode 100644 index 0000000..aa35a32 --- /dev/null +++ b/src/qEmbyCore/utils/serverprofilecrypto.cpp @@ -0,0 +1,201 @@ +#include "serverprofilecrypto.h" + +#include "securesecretbox.h" + +#include +#include +#include +#include + +namespace +{ + +constexpr char kAccessToken[] = "accessToken"; +constexpr char kAccessTokenEncrypted[] = "accessTokenEncrypted"; + +QString encryptToken(const QString &plainToken) +{ + if (plainToken.isEmpty()) + { + return QString(); + } + + QByteArray tokenBytes = plainToken.toUtf8(); + const QByteArray cipher = SecureSecretBox::encryptLocalSecret(tokenBytes); + SecureSecretBox::secureZero(tokenBytes); + if (cipher.isEmpty()) + { + return QString(); + } + + return QString::fromLatin1(cipher.toBase64(QByteArray::Base64Encoding)); +} + +QString decryptToken(const QString &cipherBase64) +{ + if (cipherBase64.isEmpty()) + { + return QString(); + } + + const QByteArray cipher = QByteArray::fromBase64( + cipherBase64.toLatin1(), QByteArray::Base64Encoding); + if (cipher.isEmpty()) + { + return QString(); + } + + auto plain = SecureSecretBox::decryptLocalSecret(cipher); + if (!plain.has_value()) + { + return QString(); + } + + QString token = QString::fromUtf8(plain->constData(), plain->size()); + SecureSecretBox::secureZero(*plain); + return token; +} + +} // namespace + +QJsonObject ServerProfileCrypto::encryptServerObject(QJsonObject obj, bool *ok) +{ + if (ok) + { + *ok = true; + } + + const QString token = + obj.value(QString::fromLatin1(kAccessToken)).toString(); + if (token.isEmpty()) + { + obj.remove(QString::fromLatin1(kAccessToken)); + obj.remove(QString::fromLatin1(kAccessTokenEncrypted)); + return obj; + } + + const QString cipher = encryptToken(token); + if (cipher.isEmpty()) + { + qWarning() << "[ServerProfileCrypto] failed to encrypt access token" + << "| serverId:" << obj.value(QStringLiteral("id")).toString(); + if (ok) + { + *ok = false; + } + return obj; + } + + obj.insert(QString::fromLatin1(kAccessToken), cipher); + obj.insert(QString::fromLatin1(kAccessTokenEncrypted), true); + return obj; +} + +QJsonArray ServerProfileCrypto::encryptServerArray(const QJsonArray &array, + bool *ok) +{ + bool allOk = true; + QJsonArray encrypted; + for (const QJsonValue &value : array) + { + if (value.isObject()) + { + bool objectOk = true; + encrypted.append(encryptServerObject(value.toObject(), &objectOk)); + allOk = allOk && objectOk; + } + else + { + encrypted.append(value); + } + } + if (ok) + { + *ok = allOk; + } + return encrypted; +} + +QJsonObject ServerProfileCrypto::decryptServerObject(QJsonObject obj, + bool *migrated) +{ + if (migrated) + { + *migrated = false; + } + + const QString token = + obj.value(QString::fromLatin1(kAccessToken)).toString(); + if (token.isEmpty()) + { + obj.remove(QString::fromLatin1(kAccessTokenEncrypted)); + return obj; + } + + const bool encrypted = + obj.value(QString::fromLatin1(kAccessTokenEncrypted)).toBool(false); + if (!encrypted) + { + if (migrated) + { + *migrated = true; + } + return obj; + } + + const QString plain = decryptToken(token); + if (plain.isEmpty()) + { + qWarning() << "[ServerProfileCrypto] failed to decrypt access token" + << "| serverId:" << obj.value(QStringLiteral("id")).toString(); + obj.remove(QString::fromLatin1(kAccessToken)); + obj.remove(QString::fromLatin1(kAccessTokenEncrypted)); + return obj; + } + + obj.insert(QString::fromLatin1(kAccessToken), plain); + obj.remove(QString::fromLatin1(kAccessTokenEncrypted)); + return obj; +} + +QJsonArray ServerProfileCrypto::decryptServerArray(const QJsonArray &array, + bool *migrated) +{ + bool anyMigrated = false; + QJsonArray decrypted; + for (const QJsonValue &value : array) + { + if (!value.isObject()) + { + decrypted.append(value); + continue; + } + + bool objectMigrated = false; + decrypted.append(decryptServerObject(value.toObject(), &objectMigrated)); + anyMigrated = anyMigrated || objectMigrated; + } + + if (migrated) + { + *migrated = anyMigrated; + } + return decrypted; +} + +bool ServerProfileCrypto::setOwnerOnlyPermissions(const QString &path) +{ + if (path.isEmpty()) + { + return false; + } + + const bool ok = QFile::setPermissions( + path, QFileDevice::ReadOwner | QFileDevice::WriteOwner); + if (!ok) + { + qWarning() << "[ServerProfileCrypto] failed to set owner-only permissions:" + << path; + } + return ok; +} diff --git a/src/qEmbyCore/utils/serverprofilecrypto.h b/src/qEmbyCore/utils/serverprofilecrypto.h new file mode 100644 index 0000000..a7ce2ae --- /dev/null +++ b/src/qEmbyCore/utils/serverprofilecrypto.h @@ -0,0 +1,25 @@ +#ifndef SERVERPROFILECRYPTO_H +#define SERVERPROFILECRYPTO_H + +#include "../qEmbyCore_global.h" + +#include +#include +#include + +class QEMBYCORE_EXPORT ServerProfileCrypto +{ +public: + static QJsonObject encryptServerObject(QJsonObject obj, bool *ok = nullptr); + static QJsonArray encryptServerArray(const QJsonArray &array, bool *ok = nullptr); + + static QJsonObject decryptServerObject(QJsonObject obj, bool *migrated = nullptr); + static QJsonArray decryptServerArray(const QJsonArray &array, bool *migrated = nullptr); + + static bool setOwnerOnlyPermissions(const QString &path); + +private: + ServerProfileCrypto() = delete; +}; + +#endif