diff --git a/rpm/sailpush.spec b/rpm/sailpush.spec index 57288fe..27a2700 100644 --- a/rpm/sailpush.spec +++ b/rpm/sailpush.spec @@ -13,6 +13,7 @@ Requires: sailfishsilica-qt5 >= 0.10.9 # Requires: nemo-qml-plugin-dbus (not in emulator, needed for QML UI) Requires: systemd-user-session-targets Requires: mce +Requires: sailfishsecretsdaemon-secretsplugins-default BuildRequires: pkgconfig(sailfishapp) >= 1.0.2 BuildRequires: pkgconfig(Qt5Core) @@ -22,6 +23,7 @@ BuildRequires: pkgconfig(Qt5Network) BuildRequires: pkgconfig(Qt5WebSockets) BuildRequires: pkgconfig(Qt5DBus) BuildRequires: pkgconfig(Qt5Multimedia) +BuildRequires: pkgconfig(sailfishsecrets) BuildRequires: desktop-file-utils %description diff --git a/sailpush.desktop b/sailpush.desktop index 3cb03de..634789f 100644 --- a/sailpush.desktop +++ b/sailpush.desktop @@ -8,7 +8,7 @@ Name=SailPush [X-Sailjail] OrganizationName=com.zackslash ApplicationName=sailpush -Permissions=Internet;AppLaunch;UserDirs;Audio +Permissions=Internet;AppLaunch;UserDirs;Audio;Secrets Sandboxing=Disabled [X-HarbourBackup] diff --git a/sailpush.pro b/sailpush.pro index 3a5da77..ec10ba4 100644 --- a/sailpush.pro +++ b/sailpush.pro @@ -2,11 +2,12 @@ TARGET = sailpush CONFIG += sailfishapp QT += core gui quick qml network websockets dbus multimedia +PKGCONFIG += sailfishsecrets # Auto-detect version from git. CI builds (tarball, no .git) use sed-replaced # fallback in source; local dev builds get "v0.9.0-42-gabc1234" style strings. GIT_VERSION = $$system(git describe --tags --always 2>/dev/null) -isEmpty(GIT_VERSION): GIT_VERSION = "dev" +isEmpty(GIT_VERSION): GIT_VERSION = dev-$$system(git rev-parse --short HEAD 2>/dev/null)-$$system(date +%s) DEFINES += GIT_VERSION=\\\"$$GIT_VERSION\\\" SOURCES += src/main.cpp \ @@ -23,10 +24,11 @@ SOURCES += src/main.cpp \ src/soundplayer.cpp HEADERS += src/message.h \ + src/icredentialstore.h \ + src/credentialstore.h \ src/sailpushclient.h \ src/websocketmanager.h \ src/messagestore.h \ - src/credentialstore.h \ src/dbusinterface.h \ src/notificationmanager.h \ src/networkmonitor.h \ diff --git a/src/credentialstore.cpp b/src/credentialstore.cpp index 7936f2a..5272831 100644 --- a/src/credentialstore.cpp +++ b/src/credentialstore.cpp @@ -1,67 +1,155 @@ #include "credentialstore.h" -#include -#include -#include #include #include #include #include -#include -#include -#include +#include + +#include +#include +#include +#include +#include Q_LOGGING_CATEGORY(lcCredentialStore, "com.zackslash.sailpush.credentials") -static const QString CREDENTIALS_FILE = QStringLiteral("credentials.json"); -static const int IV_SIZE = 16; +const QString CredentialStore::COLLECTION_NAME = QStringLiteral("SailPushCredentials"); +const QString CredentialStore::SECRET_NAME = QStringLiteral("credentials"); CredentialStore::CredentialStore(const QString &dataPath, QObject *parent) - : QObject(parent) - , m_dataPath(dataPath) + : ICredentialStore(parent) , m_lastError(LoadError::None) - , m_keyCached(false) + , m_hasCreds(false) + , m_collectionReady(false) { + Q_UNUSED(dataPath) + + if (!m_manager.isInitialized()) { + qCWarning(lcCredentialStore) << "sailfish-secrets daemon not available"; + m_lastError = LoadError::BackendUnavailable; + return; + } + + if (!ensureCollection()) { + qCWarning(lcCredentialStore) << "Failed to ensure collection exists"; + return; + } + + // Check if credentials already exist + m_hasCreds = checkSecretExists(); } CredentialStore::~CredentialStore() { - // Zero sensitive key material before destruction - m_cachedKey.fill(0); } -bool CredentialStore::save(const QString &secret, const QString &deviceId, const QString &userKey, const QString &deviceName) +bool CredentialStore::ensureCollection() { - QJsonObject obj; - obj.insert("version", 2); - obj.insert("secret", QString::fromUtf8(encrypt(secret.toUtf8()))); - obj.insert("device_id", QString::fromUtf8(encrypt(deviceId.toUtf8()))); - obj.insert("user_key", QString::fromUtf8(encrypt(userKey.toUtf8()))); - obj.insert("device_name", QString::fromUtf8(encrypt(deviceName.toUtf8()))); - - QJsonDocument doc(obj); - QFile file(storagePath()); - QDir dir = QFileInfo(file).dir(); - if (!dir.exists()) { - if (!dir.mkpath(".")) { - qCWarning(lcCredentialStore) << "Failed to create directory:" << dir.path(); - return false; - } + if (m_collectionReady) { + return true; + } + + Sailfish::Secrets::CreateCollectionRequest ccr; + ccr.setManager(&m_manager); + ccr.setCollectionName(COLLECTION_NAME); + ccr.setAccessControlMode(Sailfish::Secrets::SecretManager::NoAccessControlMode); + ccr.setCollectionLockType(Sailfish::Secrets::CreateCollectionRequest::DeviceLock); + ccr.setDeviceLockUnlockSemantic(Sailfish::Secrets::SecretManager::DeviceLockKeepUnlocked); + ccr.setStoragePluginName(Sailfish::Secrets::SecretManager::DefaultEncryptedStoragePluginName); + ccr.setEncryptionPluginName(Sailfish::Secrets::SecretManager::DefaultEncryptedStoragePluginName); + ccr.startRequest(); + ccr.waitForFinished(); + + if (ccr.result().errorCode() == Sailfish::Secrets::Result::NoError) { + m_collectionReady = true; + qCInfo(lcCredentialStore) << "Collection created:" << COLLECTION_NAME; + return true; + } + + // Already exists — that's fine + if (ccr.result().errorCode() == Sailfish::Secrets::Result::CollectionAlreadyExistsError) { + m_collectionReady = true; + qCInfo(lcCredentialStore) << "Collection already exists:" << COLLECTION_NAME; + return true; } - if (!file.open(QIODevice::WriteOnly)) { - qCWarning(lcCredentialStore) << "Failed to save credentials:" << file.errorString(); + qCWarning(lcCredentialStore) << "Collection creation failed: code=" << ccr.result().errorCode() + << "msg=" << ccr.result().errorMessage(); + m_lastError = LoadError::BackendUnavailable; + return false; +} + +bool CredentialStore::checkSecretExists() +{ + Sailfish::Secrets::Secret::Identifier ident(SECRET_NAME, COLLECTION_NAME, + Sailfish::Secrets::SecretManager::DefaultEncryptedStoragePluginName); + Sailfish::Secrets::StoredSecretRequest gsr; + gsr.setManager(&m_manager); + gsr.setIdentifier(ident); + gsr.setUserInteractionMode(Sailfish::Secrets::SecretManager::SystemInteraction); + gsr.startRequest(); + gsr.waitForFinished(); + + return gsr.result().errorCode() == Sailfish::Secrets::Result::NoError; +} + +bool CredentialStore::save(const QString &secret, const QString &deviceId, const QString &userKey, const QString &deviceName) +{ + if (!m_manager.isInitialized()) { + m_lastError = LoadError::BackendUnavailable; return false; } - file.write(doc.toJson(QJsonDocument::Compact)); - file.close(); + if (!ensureCollection()) { + return false; + } - // Restrict file permissions to owner only - if (!QFile::setPermissions(storagePath(), QFile::ReadOwner | QFile::WriteOwner)) { - qCWarning(lcCredentialStore) << "Failed to set file permissions"; + // Serialize credentials to JSON + QJsonObject obj; + obj.insert("secret", secret); + obj.insert("device_id", deviceId); + obj.insert("user_key", userKey); + obj.insert("device_name", deviceName); + QByteArray jsonData = QJsonDocument(obj).toJson(QJsonDocument::Compact); + + // Delete existing secret first (save() must succeed even if secret already exists) + Sailfish::Secrets::Secret::Identifier ident(SECRET_NAME, COLLECTION_NAME, + Sailfish::Secrets::SecretManager::DefaultEncryptedStoragePluginName); + Sailfish::Secrets::DeleteSecretRequest dsr; + dsr.setManager(&m_manager); + dsr.setIdentifier(ident); + dsr.setUserInteractionMode(Sailfish::Secrets::SecretManager::SystemInteraction); + dsr.startRequest(); + dsr.waitForFinished(); + // Ignore result — secret may not exist yet + + // Create secret object + Sailfish::Secrets::Secret secretObj(ident); + secretObj.setData(jsonData); + secretObj.setType(Sailfish::Secrets::Secret::TypeBlob); + + // Store the secret + Sailfish::Secrets::StoreSecretRequest ssr; + ssr.setManager(&m_manager); + ssr.setSecretStorageType(Sailfish::Secrets::StoreSecretRequest::CollectionSecret); + ssr.setUserInteractionMode(Sailfish::Secrets::SecretManager::SystemInteraction); + ssr.setSecret(secretObj); + ssr.startRequest(); + ssr.waitForFinished(); + + // Zero the intermediate JSON data immediately (before error check) + jsonData.fill(0); + + if (ssr.result().errorCode() != Sailfish::Secrets::Result::NoError) { + qCWarning(lcCredentialStore) << "Failed to store secret: code=" << ssr.result().errorCode() + << "msg=" << ssr.result().errorMessage(); + m_lastError = LoadError::BackendUnavailable; + return false; } - qCInfo(lcCredentialStore) << "Credentials saved"; + m_hasCreds = true; + m_lastError = LoadError::None; + qCInfo(lcCredentialStore) << "Credentials saved to sailfish-secrets"; return true; } @@ -69,194 +157,103 @@ bool CredentialStore::load(QString &secret, QString &deviceId, QString &userKey, { m_lastError = LoadError::None; - QFile file(storagePath()); - if (!file.exists()) { - qCInfo(lcCredentialStore) << "No credentials file found"; - m_lastError = LoadError::FileNotFound; + if (!m_manager.isInitialized()) { + m_lastError = LoadError::BackendUnavailable; return false; } - if (!file.open(QIODevice::ReadOnly)) { - qCWarning(lcCredentialStore) << "Failed to open credentials:" << file.errorString(); - m_lastError = LoadError::DecryptionFailed; + if (!ensureCollection()) { return false; } - QByteArray data = file.readAll(); - file.close(); - - QJsonParseError parseError; - QJsonDocument doc = QJsonDocument::fromJson(data, &parseError); - if (parseError.error != QJsonParseError::NoError) { - qCWarning(lcCredentialStore) << "Failed to parse credentials:" << parseError.errorString(); - m_lastError = LoadError::DecryptionFailed; + // Retrieve the secret + Sailfish::Secrets::Secret::Identifier ident(SECRET_NAME, COLLECTION_NAME, + Sailfish::Secrets::SecretManager::DefaultEncryptedStoragePluginName); + Sailfish::Secrets::StoredSecretRequest gsr; + gsr.setManager(&m_manager); + gsr.setIdentifier(ident); + gsr.setUserInteractionMode(Sailfish::Secrets::SecretManager::SystemInteraction); + gsr.startRequest(); + gsr.waitForFinished(); + + if (gsr.result().errorCode() != Sailfish::Secrets::Result::NoError) { + if (gsr.result().errorCode() == Sailfish::Secrets::Result::InvalidSecretError) { + qCInfo(lcCredentialStore) << "Secret not found in sailfish-secrets"; + } else { + qCWarning(lcCredentialStore) << "Failed to load secret: code=" << gsr.result().errorCode() + << "msg=" << gsr.result().errorMessage(); + } + m_lastError = LoadError::SecretNotFound; + m_hasCreds = false; return false; } - QJsonObject obj = doc.object(); + // Deserialize JSON + QByteArray jsonData = gsr.secret().data(); + QJsonParseError parseError; + QJsonDocument doc = QJsonDocument::fromJson(jsonData, &parseError); - // Check encryption version — v1 used XOR with different key derivation, - // v2 uses HMAC-SHA256 counter mode with machine-id-bound key. - // Old credentials cannot be decrypted with the new scheme. - int version = obj.value("version").toInt(0); - if (version < 2) { - qCWarning(lcCredentialStore) << "Credentials encrypted with old format (v" << version << "), need re-login"; - m_lastError = LoadError::OldFormat; + // Zero the intermediate data immediately + jsonData.fill(0); + + if (parseError.error != QJsonParseError::NoError) { + qCWarning(lcCredentialStore) << "Failed to parse stored credentials:" << parseError.errorString(); + m_lastError = LoadError::InvalidFormat; + m_hasCreds = false; return false; } - bool ok = true; - auto decryptField = [&](const QString &key) -> QString { - QByteArray encrypted = obj.value(key).toString().toUtf8(); - QByteArray decrypted = decrypt(encrypted); - if (decrypted.isEmpty() && !encrypted.isEmpty()) { - ok = false; - } - return QString::fromUtf8(decrypted); - }; - - secret = decryptField("secret"); - deviceId = decryptField("device_id"); - userKey = decryptField("user_key"); - deviceName = decryptField("device_name"); - - if (!ok) { - qCWarning(lcCredentialStore) << "Failed to decrypt some credential fields"; - m_lastError = LoadError::DecryptionFailed; + QJsonObject obj = doc.object(); + secret = obj.value("secret").toString(); + deviceId = obj.value("device_id").toString(); + userKey = obj.value("user_key").toString(); + deviceName = obj.value("device_name").toString(); + + if (secret.isEmpty() || deviceId.isEmpty()) { + qCWarning(lcCredentialStore) << "Stored credentials have empty required fields"; + m_lastError = LoadError::InvalidFormat; + m_hasCreds = false; return false; } + m_hasCreds = true; m_lastError = LoadError::None; - qCInfo(lcCredentialStore) << "Credentials loaded"; + qCInfo(lcCredentialStore) << "Credentials loaded from sailfish-secrets"; return true; } bool CredentialStore::clear() { - QFile file(storagePath()); - if (file.exists()) { - if (!file.remove()) { - qCWarning(lcCredentialStore) << "Failed to remove credentials:" << file.errorString(); + if (!m_manager.isInitialized()) { + m_lastError = LoadError::BackendUnavailable; + return false; + } + + Sailfish::Secrets::Secret::Identifier ident(SECRET_NAME, COLLECTION_NAME, + Sailfish::Secrets::SecretManager::DefaultEncryptedStoragePluginName); + Sailfish::Secrets::DeleteSecretRequest dsr; + dsr.setManager(&m_manager); + dsr.setIdentifier(ident); + dsr.setUserInteractionMode(Sailfish::Secrets::SecretManager::SystemInteraction); + dsr.startRequest(); + dsr.waitForFinished(); + + if (dsr.result().errorCode() != Sailfish::Secrets::Result::NoError) { + // InvalidSecretError means secret doesn't exist — treat as success (already cleared) + if (dsr.result().errorCode() != Sailfish::Secrets::Result::InvalidSecretError) { + qCWarning(lcCredentialStore) << "Failed to delete secret:" << dsr.result().errorMessage(); + m_lastError = LoadError::BackendUnavailable; return false; } } - qCInfo(lcCredentialStore) << "Credentials cleared"; + m_hasCreds = false; + m_lastError = LoadError::None; + qCInfo(lcCredentialStore) << "Credentials cleared from sailfish-secrets"; return true; } bool CredentialStore::hasCredentials() const { - return QFile::exists(storagePath()); -} - -QString CredentialStore::storagePath() const -{ - return QDir(m_dataPath).filePath(CREDENTIALS_FILE); -} - -QString CredentialStore::machineId() const -{ - QFile f(QStringLiteral("/etc/machine-id")); - if (f.open(QIODevice::ReadOnly)) { - return QString::fromUtf8(f.readAll()).trimmed(); - } - // Fallback: generate a per-install unique ID stored alongside credentials - QString fallbackPath = QDir(m_dataPath).filePath(".machine-id"); - QFile fallback(fallbackPath); - if (fallback.open(QIODevice::ReadOnly)) { - return QString::fromUtf8(fallback.readAll()).trimmed(); - } - QString id = QUuid::createUuid().toString(); - if (fallback.open(QIODevice::WriteOnly)) { - fallback.write(id.toUtf8()); - fallback.close(); - } - return id; -} - -QByteArray CredentialStore::deriveKey() const -{ - if (!m_keyCached) { - // Derive key from machine-id (device-specific) + data path + app secret - // This ensures credentials are bound to this device and app installation - QString seed = machineId() + m_dataPath + QStringLiteral("sailpush-sailfish-key"); - m_cachedKey = QCryptographicHash::hash(seed.toUtf8(), QCryptographicHash::Sha256); - m_keyCached = true; - } - return m_cachedKey; -} - -QByteArray CredentialStore::encrypt(const QByteArray &data) const -{ - // Generate random IV for each encryption - QByteArray iv = QUuid::createUuid().toRfc4122().left(IV_SIZE); - QByteArray key = deriveKey(); - - // HMAC-SHA256 counter mode keystream generation - QByteArray keystream; - keystream.reserve(data.size()); - int counter = 0; - while (keystream.size() < data.size()) { - QByteArray counterBytes = QByteArray::number(counter, 16).rightJustified(8, '0'); - QByteArray hmacInput = iv + counterBytes; - QByteArray block = QMessageAuthenticationCode::hash(hmacInput, key, QCryptographicHash::Sha256); - keystream.append(block); - counter++; - } - - // XOR data with keystream - QByteArray encrypted; - encrypted.reserve(data.size()); - for (int i = 0; i < data.size(); ++i) { - encrypted.append(data[i] ^ keystream[i]); - } - - // Compute HMAC of (IV + ciphertext) for authentication - QByteArray mac = QMessageAuthenticationCode::hash(iv + encrypted, key, QCryptographicHash::Sha256); - // Prepend IV, append MAC, then base64 encode - QByteArray result = iv + encrypted + mac; - return result.toBase64(); -} - -QByteArray CredentialStore::decrypt(const QByteArray &data) const -{ - QByteArray decoded = QByteArray::fromBase64(data); - // IV (16) + ciphertext (>=1) + HMAC (32) = minimum 49 bytes - if (decoded.size() < IV_SIZE + 1 + 32) { - return QByteArray(); - } - - // Extract IV, ciphertext, and MAC - QByteArray iv = decoded.left(IV_SIZE); - QByteArray mac = decoded.right(32); - QByteArray ciphertext = decoded.mid(IV_SIZE, decoded.size() - IV_SIZE - 32); - QByteArray key = deriveKey(); - - // Verify HMAC before decryption - QByteArray expectedMac = QMessageAuthenticationCode::hash(iv + ciphertext, key, QCryptographicHash::Sha256); - if (mac != expectedMac) { - qCWarning(lcCredentialStore) << "HMAC verification failed — credentials may be tampered"; - return QByteArray(); - } - - // Regenerate the same keystream - QByteArray keystream; - keystream.reserve(ciphertext.size()); - int counter = 0; - while (keystream.size() < ciphertext.size()) { - QByteArray counterBytes = QByteArray::number(counter, 16).rightJustified(8, '0'); - QByteArray hmacInput = iv + counterBytes; - QByteArray block = QMessageAuthenticationCode::hash(hmacInput, key, QCryptographicHash::Sha256); - keystream.append(block); - counter++; - } - - // XOR ciphertext with keystream - QByteArray decrypted; - decrypted.reserve(ciphertext.size()); - for (int i = 0; i < ciphertext.size(); ++i) { - decrypted.append(ciphertext[i] ^ keystream[i]); - } - return decrypted; + return m_hasCreds; } diff --git a/src/credentialstore.h b/src/credentialstore.h index f09dcc8..8dc1a1b 100644 --- a/src/credentialstore.h +++ b/src/credentialstore.h @@ -1,43 +1,34 @@ #ifndef CREDENTIALSTORE_H #define CREDENTIALSTORE_H -#include -#include +#include "icredentialstore.h" +#include +#include -class CredentialStore : public QObject { +class CredentialStore : public ICredentialStore { Q_OBJECT public: - enum class LoadError { - None, - FileNotFound, - OldFormat, - DecryptionFailed - }; - explicit CredentialStore(const QString &dataPath, QObject *parent = nullptr); ~CredentialStore() override; - bool save(const QString &secret, const QString &deviceId, const QString &userKey, const QString &deviceName); - bool load(QString &secret, QString &deviceId, QString &userKey, QString &deviceName); - bool clear(); - bool hasCredentials() const; - - LoadError lastError() const { return m_lastError; } + bool save(const QString &secret, const QString &deviceId, const QString &userKey, const QString &deviceName) override; + bool load(QString &secret, QString &deviceId, QString &userKey, QString &deviceName) override; + bool clear() override; + bool hasCredentials() const override; + LoadError lastError() const override { return m_lastError; } private: - QString storagePath() const; - QByteArray encrypt(const QByteArray &data) const; - QByteArray decrypt(const QByteArray &data) const; - QByteArray deriveKey() const; - QString machineId() const; + bool ensureCollection(); + bool checkSecretExists(); - QString m_dataPath; + static const QString COLLECTION_NAME; + static const QString SECRET_NAME; + Sailfish::Secrets::SecretManager m_manager; LoadError m_lastError; - - mutable QByteArray m_cachedKey; - mutable bool m_keyCached; + bool m_hasCreds; + bool m_collectionReady; }; #endif // CREDENTIALSTORE_H diff --git a/src/daemon.cpp b/src/daemon.cpp index b1f2b20..28b0da5 100644 --- a/src/daemon.cpp +++ b/src/daemon.cpp @@ -1,7 +1,9 @@ #include "daemon.h" +#include "credentialstore.h" #include #include #include +#include #include #include @@ -516,15 +518,40 @@ void Daemon::onDbusRequestOpenMessage(const QString &messageId) // Emit signal for warm-start case (UI already running and receives this) m_dbus->notifyOpenMessageRequested(messageId); - // Activate the app via invoker. On Sailfish OS, the invoker handles - // single-instance — if the app is already running, it brings it to foreground. - // Use QTimer::singleShot(0, ...) to defer until after the current D-Bus method - // handler returns to the event loop. - QTimer::singleShot(0, this, [this]() { - qCInfo(lcDaemon) << "Activating app via invoker"; - QProcess::startDetached("/usr/bin/invoker", - QStringList() << "--type=silica-qt5" << "/usr/bin/sailpush"); - }); + // Check if the UI is already running by scanning /proc for another + // sailpush process besides our own (the daemon). + // If it is, the D-Bus signal above is sufficient — don't spawn a duplicate. + // If it's not, launch via invoker for cold start. + bool uiRunning = false; + qint64 myPid = QCoreApplication::applicationPid(); + QDir procDir("/proc"); + for (const QString &entry : procDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + bool ok; + qint64 pid = entry.toLongLong(&ok); + if (!ok || pid == myPid) continue; + + QFile cmdlineFile(QStringLiteral("/proc/%1/cmdline").arg(pid)); + if (cmdlineFile.open(QIODevice::ReadOnly)) { + QByteArray cmdline = cmdlineFile.readAll(); + // Match UI process: starts with "sailpush" but is NOT a daemon instance + if ((cmdline.startsWith("sailpush") || cmdline.startsWith("/usr/bin/sailpush")) + && !cmdline.contains("--daemon")) { + uiRunning = true; + break; + } + } + } + + if (!uiRunning) { + // Cold start: launch the app via invoker + QTimer::singleShot(0, this, [this]() { + qCInfo(lcDaemon) << "Activating app via invoker (cold start)"; + QProcess::startDetached("/usr/bin/invoker", + QStringList() << "--type=silica-qt5" << "/usr/bin/sailpush"); + }); + } else { + qCInfo(lcDaemon) << "UI already running, relying on D-Bus signal"; + } } void Daemon::onNotificationAction(const QString &messageId, const QString &action, const QString &receipt) diff --git a/src/daemon.h b/src/daemon.h index 52f8253..f28e1d5 100644 --- a/src/daemon.h +++ b/src/daemon.h @@ -9,7 +9,7 @@ #include "sailpushclient.h" #include "websocketmanager.h" #include "messagestore.h" -#include "credentialstore.h" +#include "icredentialstore.h" #include "dbusinterface.h" #include "notificationmanager.h" #include "networkmonitor.h" @@ -67,7 +67,7 @@ private slots: SailPushClient *m_client; WebSocketManager *m_wsManager; MessageStore *m_store; - CredentialStore *m_credentials; + ICredentialStore *m_credentials; DbusInterface *m_dbus; NotificationManager *m_notificationManager; NetworkMonitor *m_networkMonitor; diff --git a/src/filecredentialstore.cpp b/src/filecredentialstore.cpp new file mode 100644 index 0000000..6e638cd --- /dev/null +++ b/src/filecredentialstore.cpp @@ -0,0 +1,244 @@ +#include "filecredentialstore.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(lcFileCredentialStore, "com.zackslash.sailpush.filecredentials") + +static const QString CREDENTIALS_FILE = QStringLiteral("credentials.json"); +static const int IV_SIZE = 16; + +FileCredentialStore::FileCredentialStore(const QString &dataPath, QObject *parent) + : ICredentialStore(parent) + , m_dataPath(dataPath) + , m_lastError(LoadError::None) + , m_keyCached(false) +{ +} + +FileCredentialStore::~FileCredentialStore() +{ + m_cachedKey.fill(0); +} + +bool FileCredentialStore::save(const QString &secret, const QString &deviceId, const QString &userKey, const QString &deviceName) +{ + QJsonObject obj; + obj.insert("version", 2); + obj.insert("secret", QString::fromUtf8(encrypt(secret.toUtf8()))); + obj.insert("device_id", QString::fromUtf8(encrypt(deviceId.toUtf8()))); + obj.insert("user_key", QString::fromUtf8(encrypt(userKey.toUtf8()))); + obj.insert("device_name", QString::fromUtf8(encrypt(deviceName.toUtf8()))); + + QJsonDocument doc(obj); + QFile file(storagePath()); + QDir dir = QFileInfo(file).dir(); + if (!dir.exists()) { + if (!dir.mkpath(".")) { + qCWarning(lcFileCredentialStore) << "Failed to create directory:" << dir.path(); + return false; + } + } + + if (!file.open(QIODevice::WriteOnly)) { + qCWarning(lcFileCredentialStore) << "Failed to save credentials:" << file.errorString(); + return false; + } + + file.write(doc.toJson(QJsonDocument::Compact)); + file.close(); + + if (!QFile::setPermissions(storagePath(), QFile::ReadOwner | QFile::WriteOwner)) { + qCWarning(lcFileCredentialStore) << "Failed to set file permissions"; + } + + qCInfo(lcFileCredentialStore) << "Credentials saved"; + return true; +} + +bool FileCredentialStore::load(QString &secret, QString &deviceId, QString &userKey, QString &deviceName) +{ + m_lastError = LoadError::None; + + QFile file(storagePath()); + if (!file.exists()) { + qCInfo(lcFileCredentialStore) << "No credentials file found"; + m_lastError = LoadError::SecretNotFound; + return false; + } + + if (!file.open(QIODevice::ReadOnly)) { + qCWarning(lcFileCredentialStore) << "Failed to open credentials:" << file.errorString(); + m_lastError = LoadError::DecryptionFailed; + return false; + } + + QByteArray data = file.readAll(); + file.close(); + + QJsonParseError parseError; + QJsonDocument doc = QJsonDocument::fromJson(data, &parseError); + if (parseError.error != QJsonParseError::NoError) { + qCWarning(lcFileCredentialStore) << "Failed to parse credentials:" << parseError.errorString(); + m_lastError = LoadError::DecryptionFailed; + return false; + } + + QJsonObject obj = doc.object(); + + int version = obj.value("version").toInt(0); + if (version < 2) { + qCWarning(lcFileCredentialStore) << "Credentials encrypted with old format (v" << version << "), need re-login"; + m_lastError = LoadError::InvalidFormat; + return false; + } + + bool ok = true; + auto decryptField = [&](const QString &key) -> QString { + QByteArray encrypted = obj.value(key).toString().toUtf8(); + QByteArray decrypted = decrypt(encrypted); + if (decrypted.isEmpty() && !encrypted.isEmpty()) { + ok = false; + } + return QString::fromUtf8(decrypted); + }; + + secret = decryptField("secret"); + deviceId = decryptField("device_id"); + userKey = decryptField("user_key"); + deviceName = decryptField("device_name"); + + if (!ok) { + qCWarning(lcFileCredentialStore) << "Failed to decrypt some credential fields"; + m_lastError = LoadError::DecryptionFailed; + return false; + } + + m_lastError = LoadError::None; + qCInfo(lcFileCredentialStore) << "Credentials loaded"; + return true; +} + +bool FileCredentialStore::clear() +{ + QFile file(storagePath()); + if (file.exists()) { + if (!file.remove()) { + qCWarning(lcFileCredentialStore) << "Failed to remove credentials:" << file.errorString(); + return false; + } + } + + qCInfo(lcFileCredentialStore) << "Credentials cleared"; + return true; +} + +bool FileCredentialStore::hasCredentials() const +{ + return QFile::exists(storagePath()); +} + +QString FileCredentialStore::storagePath() const +{ + return QDir(m_dataPath).filePath(CREDENTIALS_FILE); +} + +QString FileCredentialStore::machineId() const +{ + QFile f(QStringLiteral("/etc/machine-id")); + if (f.open(QIODevice::ReadOnly)) { + return QString::fromUtf8(f.readAll()).trimmed(); + } + QString fallbackPath = QDir(m_dataPath).filePath(".machine-id"); + QFile fallback(fallbackPath); + if (fallback.open(QIODevice::ReadOnly)) { + return QString::fromUtf8(fallback.readAll()).trimmed(); + } + QString id = QUuid::createUuid().toString(); + if (fallback.open(QIODevice::WriteOnly)) { + fallback.write(id.toUtf8()); + fallback.close(); + } + return id; +} + +QByteArray FileCredentialStore::deriveKey() const +{ + if (!m_keyCached) { + QString seed = machineId() + m_dataPath + QStringLiteral("sailpush-sailfish-key"); + m_cachedKey = QCryptographicHash::hash(seed.toUtf8(), QCryptographicHash::Sha256); + m_keyCached = true; + } + return m_cachedKey; +} + +QByteArray FileCredentialStore::encrypt(const QByteArray &data) const +{ + QByteArray iv = QUuid::createUuid().toRfc4122().left(IV_SIZE); + QByteArray key = deriveKey(); + + QByteArray keystream; + keystream.reserve(data.size()); + int counter = 0; + while (keystream.size() < data.size()) { + QByteArray counterBytes = QByteArray::number(counter, 16).rightJustified(8, '0'); + QByteArray hmacInput = iv + counterBytes; + QByteArray block = QMessageAuthenticationCode::hash(hmacInput, key, QCryptographicHash::Sha256); + keystream.append(block); + counter++; + } + + QByteArray encrypted; + encrypted.reserve(data.size()); + for (int i = 0; i < data.size(); ++i) { + encrypted.append(data[i] ^ keystream[i]); + } + + QByteArray mac = QMessageAuthenticationCode::hash(iv + encrypted, key, QCryptographicHash::Sha256); + QByteArray result = iv + encrypted + mac; + return result.toBase64(); +} + +QByteArray FileCredentialStore::decrypt(const QByteArray &data) const +{ + QByteArray decoded = QByteArray::fromBase64(data); + if (decoded.size() < IV_SIZE + 1 + 32) { + return QByteArray(); + } + + QByteArray iv = decoded.left(IV_SIZE); + QByteArray mac = decoded.right(32); + QByteArray ciphertext = decoded.mid(IV_SIZE, decoded.size() - IV_SIZE - 32); + QByteArray key = deriveKey(); + + QByteArray expectedMac = QMessageAuthenticationCode::hash(iv + ciphertext, key, QCryptographicHash::Sha256); + if (mac != expectedMac) { + qCWarning(lcFileCredentialStore) << "HMAC verification failed — credentials may be tampered"; + return QByteArray(); + } + + QByteArray keystream; + keystream.reserve(ciphertext.size()); + int counter = 0; + while (keystream.size() < ciphertext.size()) { + QByteArray counterBytes = QByteArray::number(counter, 16).rightJustified(8, '0'); + QByteArray hmacInput = iv + counterBytes; + QByteArray block = QMessageAuthenticationCode::hash(hmacInput, key, QCryptographicHash::Sha256); + keystream.append(block); + counter++; + } + + QByteArray decrypted; + decrypted.reserve(ciphertext.size()); + for (int i = 0; i < ciphertext.size(); ++i) { + decrypted.append(ciphertext[i] ^ keystream[i]); + } + return decrypted; +} diff --git a/src/filecredentialstore.h b/src/filecredentialstore.h new file mode 100644 index 0000000..964997e --- /dev/null +++ b/src/filecredentialstore.h @@ -0,0 +1,33 @@ +#ifndef FILECREDENTIALSTORE_H +#define FILECREDENTIALSTORE_H + +#include "icredentialstore.h" + +class FileCredentialStore : public ICredentialStore { + Q_OBJECT + +public: + explicit FileCredentialStore(const QString &dataPath, QObject *parent = nullptr); + ~FileCredentialStore() override; + + bool save(const QString &secret, const QString &deviceId, const QString &userKey, const QString &deviceName) override; + bool load(QString &secret, QString &deviceId, QString &userKey, QString &deviceName) override; + bool clear() override; + bool hasCredentials() const override; + LoadError lastError() const override { return m_lastError; } + +private: + QString storagePath() const; + QByteArray encrypt(const QByteArray &data) const; + QByteArray decrypt(const QByteArray &data) const; + QByteArray deriveKey() const; + QString machineId() const; + + QString m_dataPath; + LoadError m_lastError; + + mutable QByteArray m_cachedKey; + mutable bool m_keyCached; +}; + +#endif // FILECREDENTIALSTORE_H diff --git a/src/icredentialstore.h b/src/icredentialstore.h new file mode 100644 index 0000000..2059d8d --- /dev/null +++ b/src/icredentialstore.h @@ -0,0 +1,28 @@ +#ifndef ICREDENTIALSTORE_H +#define ICREDENTIALSTORE_H + +#include +#include + +class ICredentialStore : public QObject { + +public: + enum class LoadError { + None, + SecretNotFound, + InvalidFormat, + DecryptionFailed, + BackendUnavailable + }; + + explicit ICredentialStore(QObject *parent = nullptr) : QObject(parent) {} + virtual ~ICredentialStore() = default; + + virtual bool save(const QString &secret, const QString &deviceId, const QString &userKey, const QString &deviceName) = 0; + virtual bool load(QString &secret, QString &deviceId, QString &userKey, QString &deviceName) = 0; + virtual bool clear() = 0; + virtual bool hasCredentials() const = 0; + virtual LoadError lastError() const = 0; +}; + +#endif // ICREDENTIALSTORE_H diff --git a/src/loginhelper.cpp b/src/loginhelper.cpp index 6f46e3d..136e573 100644 --- a/src/loginhelper.cpp +++ b/src/loginhelper.cpp @@ -1,4 +1,5 @@ #include "loginhelper.h" +#include "credentialstore.h" #include "daemon.h" #include @@ -19,11 +20,13 @@ LoginHelper::LoginHelper(QObject *parent) { QString secret, deviceId, userKey, deviceName; if (!m_store->load(secret, deviceId, userKey, deviceName)) { - CredentialStore::LoadError err = m_store->lastError(); - if (err == CredentialStore::LoadError::OldFormat) { + ICredentialStore::LoadError err = m_store->lastError(); + if (err == ICredentialStore::LoadError::InvalidFormat) { m_migrationReason = tr("App was upgraded — saved credentials use an older format and cannot be migrated. Please log in again."); - } else if (err == CredentialStore::LoadError::DecryptionFailed) { + } else if (err == ICredentialStore::LoadError::DecryptionFailed) { m_migrationReason = tr("Saved credentials could not be decrypted. This can happen after a system update. Please log in again."); + } else if (err == ICredentialStore::LoadError::BackendUnavailable) { + m_migrationReason = tr("Secrets service is not available. Please restart the device and try again."); } } diff --git a/src/loginhelper.h b/src/loginhelper.h index 6638c68..b97ed45 100644 --- a/src/loginhelper.h +++ b/src/loginhelper.h @@ -4,7 +4,7 @@ #include #include #include "sailpushclient.h" -#include "credentialstore.h" +#include "icredentialstore.h" class LoginHelper : public QObject { Q_OBJECT @@ -54,7 +54,7 @@ private slots: void setErrorString(const QString &value); SailPushClient *m_client; - CredentialStore *m_store; + ICredentialStore *m_store; bool m_loggingIn; bool m_registering; diff --git a/src/main.cpp b/src/main.cpp index 2ddbae2..3e9c584 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -121,15 +121,14 @@ int main(int argc, char *argv[]) } else { // Daemon is running — check if its version matches ours. // After an RPM update, the old daemon may still be running with stale code. - // For dev builds (no git tags), always restart to pick up latest changes. + // For dev builds, version includes a build timestamp that changes on each rebuild. QDBusInterface versionCheck(DBUS_SERVICE, "/com/zackslash/sailpush", DBUS_SERVICE, sessionBus); QDBusReply daemonVersion = versionCheck.call("GetVersion"); QString uiVersion = QStringLiteral(GIT_VERSION); bool versionMismatch = !daemonVersion.isValid() || daemonVersion.value() != uiVersion; - bool isDevBuild = (uiVersion == QStringLiteral("dev")); - if (versionMismatch || isDevBuild) { + if (versionMismatch) { qCInfo(lcMain) << "Daemon version mismatch: daemon=" << (daemonVersion.isValid() ? daemonVersion.value() : "") << "ui=" << uiVersion << "— restarting daemon"; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4549150..a016697 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -38,7 +38,7 @@ add_test(NAME SailPushClient COMMAND tst_sailpushclient) add_executable(tst_credentialstore tst_credentialstore.cpp - ${CMAKE_SOURCE_DIR}/../src/credentialstore.cpp + ${CMAKE_SOURCE_DIR}/../src/filecredentialstore.cpp ) target_link_libraries(tst_credentialstore PRIVATE Qt5::Core Qt5::Test) add_test(NAME CredentialStore COMMAND tst_credentialstore) diff --git a/tests/tst_credentialstore.cpp b/tests/tst_credentialstore.cpp index 15e836d..a04b654 100644 --- a/tests/tst_credentialstore.cpp +++ b/tests/tst_credentialstore.cpp @@ -3,7 +3,7 @@ #include #include #include -#include "credentialstore.h" +#include "filecredentialstore.h" class TestCredentialStore : public QObject { Q_OBJECT @@ -19,20 +19,19 @@ private slots: void testEmptyValues(); void testLoadNonexistent(); void testLastErrorNoneOnSuccess(); - void testLastErrorFileNotFound(); - void testLastErrorOldFormat(); + void testLastErrorSecretNotFound(); + void testLastErrorInvalidFormat(); void testFilePermissionsAfterSave(); private: QTemporaryDir m_tempDir; - CredentialStore *m_store; + FileCredentialStore *m_store; }; void TestCredentialStore::init() { - // Clear any leftover credentials from previous test QFile::remove(m_tempDir.path() + "/credentials.json"); - m_store = new CredentialStore(m_tempDir.path(), this); + m_store = new FileCredentialStore(m_tempDir.path(), this); } void TestCredentialStore::cleanup() @@ -92,8 +91,6 @@ void TestCredentialStore::testOverwrite() void TestCredentialStore::testEmptyValues() { - // Empty strings cannot be encrypted — save succeeds but load will fail - // because HMAC verification fails on empty ciphertext m_store->save("", "", "", ""); QString secret, deviceId, userKey, deviceName; @@ -102,10 +99,10 @@ void TestCredentialStore::testEmptyValues() void TestCredentialStore::testLoadNonexistent() { - CredentialStore *emptyStore = new CredentialStore("/nonexistent/path/that/does/not/exist", this); + FileCredentialStore *emptyStore = new FileCredentialStore("/nonexistent/path/that/does/not/exist", this); QString secret, deviceId, userKey, deviceName; QVERIFY(!emptyStore->load(secret, deviceId, userKey, deviceName)); - QCOMPARE(emptyStore->lastError(), CredentialStore::LoadError::FileNotFound); + QCOMPARE(emptyStore->lastError(), FileCredentialStore::LoadError::SecretNotFound); delete emptyStore; } @@ -115,19 +112,18 @@ void TestCredentialStore::testLastErrorNoneOnSuccess() QString secret, deviceId, userKey, deviceName; QVERIFY(m_store->load(secret, deviceId, userKey, deviceName)); - QCOMPARE(m_store->lastError(), CredentialStore::LoadError::None); + QCOMPARE(m_store->lastError(), FileCredentialStore::LoadError::None); } -void TestCredentialStore::testLastErrorFileNotFound() +void TestCredentialStore::testLastErrorSecretNotFound() { QString secret, deviceId, userKey, deviceName; QVERIFY(!m_store->load(secret, deviceId, userKey, deviceName)); - QCOMPARE(m_store->lastError(), CredentialStore::LoadError::FileNotFound); + QCOMPARE(m_store->lastError(), FileCredentialStore::LoadError::SecretNotFound); } -void TestCredentialStore::testLastErrorOldFormat() +void TestCredentialStore::testLastErrorInvalidFormat() { - // Write a v1-format credentials file (version < 2) QJsonObject obj; obj.insert("version", 1); obj.insert("secret", "encrypted"); @@ -144,14 +140,13 @@ void TestCredentialStore::testLastErrorOldFormat() QString secret, deviceId, userKey, deviceName; QVERIFY(!m_store->load(secret, deviceId, userKey, deviceName)); - QCOMPARE(m_store->lastError(), CredentialStore::LoadError::OldFormat); + QCOMPARE(m_store->lastError(), FileCredentialStore::LoadError::InvalidFormat); } void TestCredentialStore::testFilePermissionsAfterSave() { m_store->save("secret", "device", "user", "name"); QFile::Permissions perms = QFile::permissions(m_tempDir.path() + "/credentials.json"); - // Qt expands ReadOwner/WriteOwner to include ReadUser/WriteUser in the getter QFile::Permissions expected = QFile::ReadOwner | QFile::WriteOwner | QFile::ReadUser | QFile::WriteUser; QCOMPARE(perms, expected);