mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
Improve: Secure credential management with system keychain integration and legacy migration (#7956)
#### Brief overview of PR changes/additions
This pull request implements secure credential management for Mudlet
with system keychain integration and encrypted fallback storage:
**Core Components:**
- **CredentialManager**: High-level API for secure credential storage
with QtKeychain integration
- **SecureStringUtils**: Qt-based cryptographic utilities for encrypted
file storage
- **Legacy Migration**: Automatic detection and migration of existing
plaintext passwords
**Key Features:**
- **System Keychain Integration**: Primary storage in macOS Keychain,
Windows Credential Store, and Linux Secret Service via QtKeychain
- **Encrypted File Fallback**: Qt crypto-based AES encryption for
portable mode and keychain unavailability
- **Seamless Migration**: Automatic detection and upgrade of legacy
password storage formats
- **Profile Isolation**: Per-profile encryption keys prevent
cross-profile credential access
- **Portable Mode Support**: Automatic detection and secure file-based
storage for portable installations
#### Motivation for adding to Mudlet
**Security Enhancement:**
- Eliminates plaintext password storage in profile XML files
- Provides industry-standard system keychain integration for credential
security
- Implements authenticated encryption for fallback scenarios
**User Experience:**
- Zero configuration required - works automatically across all platforms
- Seamless migration from existing plaintext passwords to secure storage
- Native system integration provides familiar credential management
experience
**Future-Proofing:**
- Extensible architecture supports additional credential types (API
keys, tokens, etc.)
- Robust fallback ensures functionality in all deployment scenarios
- Prepares foundation for OAuth and external service integrations
#### Other info (issues closed, discussion etc)
**Security Architecture:**
- **Keychain-First Strategy**: Prefers system keychain with automatic
encrypted file fallback
- **Legacy Format Detection**: Automatically migrates passwords from
development branch keychain format
- **Memory Security**: Secure string clearing and controlled credential
lifecycle management
- **Input Validation**: Path traversal protection and service name
sanitization
**Implementation Highlights:**
- **Async Operations**: Non-blocking keychain operations with timeout
protection
- **Thread Safety**: Event-driven architecture prevents UI blocking and
race conditions
- **Comprehensive Testing**: Full test coverage for encryption,
migration, and fallback scenarios
- **Cross-Platform**: Unified API across Windows, macOS, and Linux with
platform-specific optimizations
**Version Compatibility & Migration:**
- **Mudlet 4.19.x Compatibility**: Preserves existing plaintext password
storage to ensure compatibility when switching between 4.19.x stable and
development builds
- **Mudlet 4.20.x Migration**: Post-4.20.0 release, automatic migration
begins converting plaintext passwords to secure storage
- **Bidirectional Safety**: Users can safely run both 4.19.x and
development versions without losing access to their passwords during the
transition period
- **Legacy Format Support**: Automatically detects and migrates
passwords from the original development branch keychain format
(`service="Mudlet profile"`) to the new secure format
# Credential Management Workflows
## 1. Credential Storage Strategy
```mermaid
flowchart TD
A[Store Password Request] --> B{Portable Mode?}
B -->|Yes| C[Encrypt & Store in Profile File]
B -->|No| D[Store in System Keychain]
D --> E{Keychain Success?}
E -->|Yes| F[Remove Encrypted Fallback File]
E -->|No| G[Fallback to Encrypted File]
F --> H[Success]
G --> I{Encryption Success?}
I -->|Yes| H
I -->|No| J[Failure]
C --> I
classDef primary fill:#4CAF50,stroke:#2E7D32,stroke-width:2px,color:#000
classDef fallback fill:#FF9800,stroke:#E65100,stroke-width:2px,color:#000
classDef decision fill:#2196F3,stroke:#0D47A1,stroke-width:2px,color:#fff
classDef result fill:#9C27B0,stroke:#4A148C,stroke-width:2px,color:#fff
class D,F primary
class C,G fallback
class B,E,I decision
class H,J result
```
## 2. Legacy Migration Workflow
```mermaid
flowchart TD
A[Retrieve Password Request] --> B[Try New Keychain Format]
B --> C{Password Found?}
C -->|Yes| D[Return Password]
C -->|No| E[Check Legacy Keychain Format]
E --> F{Legacy Found?}
F -->|Yes| G[Migrate to New Format]
G --> H[Store in New Format]
H --> I[Remove Legacy Entry]
I --> J[Return Migrated Password]
F -->|No| K[Try Encrypted File]
K --> L{File Found?}
L -->|Yes| M[Decrypt & Return]
L -->|No| N[Return Empty - No Password Stored]
classDef newformat fill:#4CAF50,stroke:#2E7D32,stroke-width:2px,color:#000
classDef legacy fill:#FF9800,stroke:#E65100,stroke-width:2px,color:#000
classDef migration fill:#9C27B0,stroke:#4A148C,stroke-width:2px,color:#fff
classDef fallback fill:#607D8B,stroke:#263238,stroke-width:2px,color:#fff
classDef result fill:#2196F3,stroke:#0D47A1,stroke-width:2px,color:#fff
class B,H newformat
class E,I legacy
class G migration
class K fallback
class D,J,M,N result
```
## 3. Cross-Platform Keychain Integration
```mermaid
flowchart TD
A[QtKeychain Request] --> B{Platform Detection}
B -->|macOS| C[Access Keychain Services]
B -->|Windows| D[Access Credential Store]
B -->|Linux| E[Access Secret Service]
C --> F[Store/Retrieve Credential]
D --> F
E --> F
F --> G{Operation Success?}
G -->|Yes| H[Return Result]
G -->|No| I[Log Error & Fallback]
I --> J[Use Encrypted File Storage]
J --> K[AES Encryption with Profile Key]
K --> L[Store in Profile Directory]
classDef platform fill:#2196F3,stroke:#0D47A1,stroke-width:2px,color:#fff
classDef keychain fill:#4CAF50,stroke:#2E7D32,stroke-width:2px,color:#000
classDef fallback fill:#FF9800,stroke:#E65100,stroke-width:2px,color:#000
classDef crypto fill:#9C27B0,stroke:#4A148C,stroke-width:2px,color:#fff
class C,D,E platform
class F,H keychain
class I,J fallback
class K,L crypto
```
This implementation provides a comprehensive, secure, and user-friendly
credential management system that seamlessly upgrades existing Mudlet
installations while providing robust security for future credential
storage needs.
---------
Co-authored-by: Vadim Peretokin <vperetokin@hey.com>
This commit is contained in:
parent
93a1f3d269
commit
79ab7be4e2
17 changed files with 3251 additions and 174 deletions
|
|
@ -102,7 +102,9 @@ set(mudlet_SRCS
|
|||
mudlet.cpp
|
||||
MudletInstanceCoordinator.cpp
|
||||
MxpTag.cpp
|
||||
CredentialManager.cpp
|
||||
ScriptUnit.cpp
|
||||
SecureStringUtils.cpp
|
||||
ShortcutsManager.cpp
|
||||
SingleLineTextEdit.cpp
|
||||
T2DMap.cpp
|
||||
|
|
@ -291,6 +293,7 @@ set(mudlet_HDRS
|
|||
post_guard.h
|
||||
pre_guard.h
|
||||
ScriptUnit.h
|
||||
SecureStringUtils.h
|
||||
ShortcutsManager.h
|
||||
SingleLineTextEdit.h
|
||||
T2DMap.h
|
||||
|
|
|
|||
1047
src/CredentialManager.cpp
Normal file
1047
src/CredentialManager.cpp
Normal file
File diff suppressed because it is too large
Load diff
134
src/CredentialManager.h
Normal file
134
src/CredentialManager.h
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/***************************************************************************
|
||||
* Copyright (C) 2025 by Mike Conley - mike.conley@stickmud.com *
|
||||
* *
|
||||
* 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. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program; if not, write to the *
|
||||
* Free Software Foundation, Inc., *
|
||||
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
||||
***************************************************************************/
|
||||
|
||||
#ifndef MUDLET_CREDENTIALMANAGER_H
|
||||
#define MUDLET_CREDENTIALMANAGER_H
|
||||
|
||||
#include "pre_guard.h"
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QPointer>
|
||||
#include <functional>
|
||||
#include "post_guard.h"
|
||||
|
||||
class QTimer;
|
||||
|
||||
namespace QKeychain {
|
||||
class Job;
|
||||
class ReadPasswordJob;
|
||||
class WritePasswordJob;
|
||||
class DeletePasswordJob;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Secure credential management with QtKeychain integration and encrypted file fallback
|
||||
*
|
||||
* This class provides a comprehensive credential management system following the principle:
|
||||
* "QtKeychain first, encrypted file fallback". It offers both asynchronous and legacy APIs.
|
||||
*
|
||||
* RECOMMENDED: Async API (QtKeychain + fallback)
|
||||
* - Primary storage: System keychain (macOS Keychain, Windows Credential Store, Linux Secret Service)
|
||||
* - Automatic fallback: AES-256 encrypted files when keychain unavailable
|
||||
* - Non-blocking operations with callback-based results
|
||||
* - Better security and user experience
|
||||
*
|
||||
* LEGACY: Static API (file storage only)
|
||||
* - Encrypted file storage only (no keychain integration)
|
||||
* - Synchronous operations for backwards compatibility
|
||||
* - Consider migrating to async API for better security
|
||||
*
|
||||
* Features:
|
||||
* - Per-profile credential isolation
|
||||
* - Timeout protection and resource cleanup
|
||||
* - Input validation and sanitization
|
||||
* - Test environment detection
|
||||
* - Cross-platform compatibility
|
||||
*/
|
||||
class CredentialManager : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit CredentialManager(QObject* parent = nullptr);
|
||||
~CredentialManager();
|
||||
|
||||
// Callback types for asynchronous operations
|
||||
using CredentialCallback = std::function<void(bool success, const QString& errorMessage)>;
|
||||
using CredentialRetrievalCallback = std::function<void(bool success, const QString& password, const QString& errorMessage)>;
|
||||
using AvailabilityCallback = std::function<void(bool available, const QString& message)>;
|
||||
|
||||
// Asynchronous methods for credential management (preferred)
|
||||
void storeCredential(const QString& service, const QString& account, const QString& password, CredentialCallback callback);
|
||||
void retrieveCredential(const QString& service, const QString& account, CredentialRetrievalCallback callback);
|
||||
void removeCredential(const QString& service, const QString& account, CredentialCallback callback);
|
||||
|
||||
// Check if QtKeychain is available and working (asynchronous)
|
||||
void isKeychainAvailable(AvailabilityCallback callback);
|
||||
|
||||
// Hybrid password management methods (preferred)
|
||||
// These methods intelligently choose between keychain and SecureStringUtils based on availability and portable mode
|
||||
void storePassword(const QString& profileName, const QString& key, const QString& password, CredentialCallback callback);
|
||||
void retrievePassword(const QString& profileName, const QString& key, CredentialRetrievalCallback callback);
|
||||
void removePassword(const QString& profileName, const QString& key, CredentialCallback callback);
|
||||
|
||||
// Password migration method - migrates plaintext passwords to encrypted storage
|
||||
void migratePassword(const QString& profileName, const QString& key, const QString& plaintextPassword, CredentialCallback callback);
|
||||
|
||||
// Static fallback methods (for migration only - uses encrypted file storage)
|
||||
static bool storeCredential(const QString& profileName, const QString& key, const QString& credential);
|
||||
static QString retrieveCredential(const QString& profileName, const QString& key);
|
||||
static bool removeCredential(const QString& profileName, const QString& key);
|
||||
|
||||
private:
|
||||
static constexpr int OPERATION_TIMEOUT_MS = 30000; // 30 seconds
|
||||
|
||||
// Portable mode detection
|
||||
bool isPortableModeActive() const;
|
||||
bool shouldUseKeychain(const QString& profileName) const;
|
||||
|
||||
// Timeout and cleanup management
|
||||
void setupTimeout();
|
||||
void cleanupTimeout();
|
||||
void handleTimeout();
|
||||
void cleanupCurrentOperation();
|
||||
|
||||
// Safety guard for keychain operation callbacks
|
||||
bool isOperationValid() const;
|
||||
|
||||
// Static utility methods for fallback storage
|
||||
static QString generateFilePath(const QString& profileName, const QString& key);
|
||||
static QString generateServiceName(const QString& profileName, const QString& key);
|
||||
static bool isValidKeyName(const QString& key);
|
||||
static bool storeCredentialToFile(const QString& profileName, const QString& key, const QString& credential);
|
||||
static QString retrieveCredentialFromFile(const QString& profileName, const QString& key);
|
||||
static bool removeCredentialFromFile(const QString& profileName, const QString& key);
|
||||
|
||||
// Legacy keychain migration support
|
||||
void checkLegacyKeychainFormat(const QString& profileName, std::function<void(bool, const QString&)> callback);
|
||||
void deleteLegacyKeychainEntry(const QString& profileName);
|
||||
|
||||
// Current operation state
|
||||
QKeychain::Job* mCurrentJob;
|
||||
QTimer* mTimeoutTimer;
|
||||
CredentialCallback mCurrentCallback;
|
||||
CredentialRetrievalCallback mCurrentRetrievalCallback;
|
||||
AvailabilityCallback mCurrentAvailabilityCallback;
|
||||
};
|
||||
|
||||
#endif // MUDLET_CREDENTIALMANAGER_H
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
#include "GMCPAuthenticator.h"
|
||||
|
||||
#include "Host.h"
|
||||
#include "SecureStringUtils.h"
|
||||
#include "ctelnet.h"
|
||||
#include <QDebug>
|
||||
|
||||
|
|
@ -48,14 +49,23 @@ void GMCPAuthenticator::sendCredentials()
|
|||
{
|
||||
auto character = mpHost->getLogin();
|
||||
auto password = mpHost->getPass();
|
||||
|
||||
QJsonObject credentials;
|
||||
|
||||
if (!character.isEmpty() && !password.isEmpty()) {
|
||||
credentials["account"] = character;
|
||||
credentials["password"] = password;
|
||||
}
|
||||
|
||||
QJsonDocument doc(credentials);
|
||||
QString gmcpMessage = doc.toJson(QJsonDocument::Compact);
|
||||
|
||||
|
||||
// Clear sensitive data from memory as soon as possible
|
||||
credentials = QJsonObject(); // Clear JSON object
|
||||
doc = QJsonDocument(); // Clear document
|
||||
SecureStringUtils::secureStringClear(password); // Clear password copy
|
||||
|
||||
// Build and send the GMCP message
|
||||
std::string output;
|
||||
output += TN_IAC;
|
||||
output += TN_SB;
|
||||
|
|
@ -67,6 +77,10 @@ void GMCPAuthenticator::sendCredentials()
|
|||
|
||||
// Send credentials to server
|
||||
mpHost->mTelnet.socketOutRaw(output);
|
||||
|
||||
// Clear message from memory
|
||||
SecureStringUtils::secureStringClear(gmcpMessage);
|
||||
|
||||
#if defined(DEBUG_GMCP_AUTHENTICATION)
|
||||
qDebug() << "Sent GMCP credentials";
|
||||
#endif
|
||||
|
|
|
|||
38
src/Host.cpp
38
src/Host.cpp
|
|
@ -51,6 +51,8 @@
|
|||
#include "TToolBar.h"
|
||||
#include "VarUnit.h"
|
||||
#include "XMLimport.h"
|
||||
#include "CredentialManager.h"
|
||||
#include "SecureStringUtils.h"
|
||||
|
||||
#include "pre_guard.h"
|
||||
#include <chrono>
|
||||
|
|
@ -3023,28 +3025,22 @@ std::unique_ptr<QNetworkProxy>& Host::getConnectionProxy()
|
|||
|
||||
void Host::loadSecuredPassword()
|
||||
{
|
||||
auto *job = new QKeychain::ReadPasswordJob(qsl("Mudlet profile"));
|
||||
job->setAutoDelete(false);
|
||||
job->setInsecureFallback(false);
|
||||
|
||||
job->setKey(getName());
|
||||
|
||||
connect(job, &QKeychain::ReadPasswordJob::finished, this, [=, this](QKeychain::Job* task) {
|
||||
if (task->error()) {
|
||||
const auto error = task->errorString();
|
||||
if (error != qsl("Entry not found") && error != qsl("No match")) {
|
||||
qDebug().nospace().noquote() << "Host::loadSecuredPassword() ERROR - could not retrieve secure password for \"" << getName() << "\", error is: " << error << ".";
|
||||
// Use async API for QtKeychain integration with file fallback
|
||||
auto* credManager = new CredentialManager(this);
|
||||
|
||||
credManager->retrieveCredential(getName(), "character",
|
||||
[this, credManager](bool success, const QString& password, const QString& errorMessage) {
|
||||
if (success && !password.isEmpty()) {
|
||||
setPass(password);
|
||||
QString passwordCopy = password; // Make a copy for secure clearing
|
||||
SecureStringUtils::secureStringClear(passwordCopy);
|
||||
} else if (!success && !errorMessage.isEmpty()) {
|
||||
qDebug() << "Host::loadSecuredPassword() - Failed to retrieve password:" << errorMessage;
|
||||
}
|
||||
|
||||
} else {
|
||||
auto readJob = static_cast<QKeychain::ReadPasswordJob*>(task);
|
||||
setPass(readJob->textData());
|
||||
}
|
||||
|
||||
task->deleteLater();
|
||||
});
|
||||
|
||||
job->start();
|
||||
|
||||
// Clean up the credential manager
|
||||
credManager->deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
// Only needed for places outside of this class:
|
||||
|
|
|
|||
635
src/SecureStringUtils.cpp
Normal file
635
src/SecureStringUtils.cpp
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
/***************************************************************************
|
||||
* Copyright (C) 2025 by Mike Conley - mike.conley@stickmud.com *
|
||||
* *
|
||||
* 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. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program; if not, write to the *
|
||||
* Free Software Foundation, Inc., *
|
||||
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
||||
***************************************************************************/
|
||||
|
||||
#include "SecureStringUtils.h"
|
||||
|
||||
#include "utils.h"
|
||||
|
||||
#include "pre_guard.h"
|
||||
#include <atomic>
|
||||
#include <QCoreApplication>
|
||||
#include <QCryptographicHash>
|
||||
#include <QDataStream>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QObject>
|
||||
#include <QRandomGenerator>
|
||||
#include <QRegularExpression>
|
||||
#include <QSaveFile>
|
||||
#include <QStandardPaths>
|
||||
#include <QVersionNumber>
|
||||
// Qt includes for encryption and SSL availability check
|
||||
#include <QCryptographicHash>
|
||||
#include <QMessageAuthenticationCode>
|
||||
#include <QRandomGenerator>
|
||||
#ifndef QT_NO_SSL
|
||||
#include <QSslSocket>
|
||||
#endif
|
||||
#include "post_guard.h"
|
||||
|
||||
QString SecureStringUtils::getSSLBackendInfo()
|
||||
{
|
||||
QStringList info;
|
||||
|
||||
#ifndef QT_NO_SSL
|
||||
// Check Qt's SSL backend
|
||||
if (QSslSocket::isProtocolSupported(QSsl::TlsV1_2)) {
|
||||
info << "Qt SSL support: Available";
|
||||
info << QString("SSL backend library: %1").arg(QSslSocket::sslLibraryBuildVersionString());
|
||||
info << QString("Active backend: %1").arg(QSslSocket::activeBackend());
|
||||
|
||||
// Check supported protocols
|
||||
QStringList protocols;
|
||||
if (QSslSocket::isProtocolSupported(QSsl::TlsV1_2)) protocols << "TLS 1.2";
|
||||
if (QSslSocket::isProtocolSupported(QSsl::TlsV1_3)) protocols << "TLS 1.3";
|
||||
info << QString("Supported protocols: %1").arg(protocols.join(", "));
|
||||
|
||||
} else {
|
||||
info << "Qt SSL support: Not available";
|
||||
}
|
||||
#else
|
||||
info << "Qt SSL support: Compiled without SSL";
|
||||
#endif
|
||||
|
||||
return info.join("\n");
|
||||
}
|
||||
|
||||
bool SecureStringUtils::isEncryptedFormat(const QString& text)
|
||||
{
|
||||
if (text.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Quick length check - encrypted strings are much longer due to overhead
|
||||
if (text.length() < (MIN_ENCRYPTED_SIZE * 4 / 3)) { // Base64 encoding overhead
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's valid Base64
|
||||
QRegularExpression base64Regex(qsl("^[A-Za-z0-9+/]*={0,2}$"));
|
||||
|
||||
if (!base64Regex.match(text).hasMatch()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to decode and check structure
|
||||
QByteArray decoded = QByteArray::fromBase64(text.toLatin1());
|
||||
|
||||
if (decoded.size() < MIN_ENCRYPTED_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check version byte - only support current version
|
||||
quint8 version = static_cast<quint8>(decoded[0]);
|
||||
|
||||
return (version == ENCRYPTION_VERSION_CURRENT);
|
||||
}
|
||||
|
||||
void SecureStringUtils::secureStringClear(QString& str)
|
||||
{
|
||||
// Overwrite the string's data with zeros
|
||||
if (!str.isEmpty()) {
|
||||
str.fill(QChar('\0'));
|
||||
str.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void SecureStringUtils::secureByteArrayClear(QByteArray& array)
|
||||
{
|
||||
// Overwrite the array's data with zeros
|
||||
if (!array.isEmpty()) {
|
||||
array.fill('\0');
|
||||
array.clear();
|
||||
}
|
||||
}
|
||||
|
||||
QByteArray SecureStringUtils::generateKey(const QByteArray& password, const QByteArray& salt, int iterations)
|
||||
{
|
||||
// Use iterative SHA-256 hashing to implement PBKDF2-like key derivation
|
||||
QByteArray derivedKey = password + salt;
|
||||
|
||||
for (int i = 0; i < iterations; ++i) {
|
||||
QCryptographicHash hash(QCryptographicHash::Sha256);
|
||||
hash.addData(derivedKey);
|
||||
hash.addData(salt);
|
||||
derivedKey = hash.result();
|
||||
}
|
||||
|
||||
// Ensure we have exactly KEY_SIZE bytes
|
||||
if (derivedKey.size() > KEY_SIZE) {
|
||||
derivedKey = derivedKey.left(KEY_SIZE);
|
||||
} else if (derivedKey.size() < KEY_SIZE) {
|
||||
// Extend key if needed by hashing again
|
||||
while (derivedKey.size() < KEY_SIZE) {
|
||||
QCryptographicHash hash(QCryptographicHash::Sha256);
|
||||
hash.addData(derivedKey);
|
||||
derivedKey.append(hash.result());
|
||||
}
|
||||
|
||||
derivedKey = derivedKey.left(KEY_SIZE);
|
||||
}
|
||||
|
||||
return derivedKey;
|
||||
}
|
||||
|
||||
QByteArray SecureStringUtils::generateSalt()
|
||||
{
|
||||
// Generate a random 16-byte salt
|
||||
QByteArray salt;
|
||||
salt.resize(SALT_SIZE);
|
||||
|
||||
QRandomGenerator* rng = QRandomGenerator::system();
|
||||
|
||||
for (int i = 0; i < SALT_SIZE; ++i) {
|
||||
salt[i] = static_cast<char>(rng->bounded(256));
|
||||
}
|
||||
|
||||
return salt;
|
||||
}
|
||||
|
||||
QString SecureStringUtils::encryptStringForProfile(const QString& plaintext, const QString& profileName)
|
||||
{
|
||||
if (plaintext.isEmpty() || profileName.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
// Convert to UTF-8 bytes
|
||||
QByteArray plaintextBytes = plaintext.toUtf8();
|
||||
|
||||
// Generate random salt
|
||||
QByteArray salt = generateSalt();
|
||||
|
||||
// Get profile-specific encryption key
|
||||
QByteArray profileKey = getProfileEncryptionKey(profileName);
|
||||
|
||||
if (profileKey.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
// Derive encryption key using PBKDF2
|
||||
QByteArray derivedKey = generateKey(profileKey, salt, PBKDF2_ITERATIONS);
|
||||
|
||||
if (derivedKey.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
// Use Qt crypto encryption
|
||||
QByteArray nonce = generateNonce();
|
||||
QByteArray hmac;
|
||||
QByteArray encryptedData = encryptData(plaintextBytes, derivedKey, salt, nonce, hmac);
|
||||
|
||||
if (encryptedData.isEmpty()) {
|
||||
// Securely clear sensitive data before returning
|
||||
secureByteArrayClear(plaintextBytes);
|
||||
secureByteArrayClear(derivedKey);
|
||||
secureByteArrayClear(profileKey);
|
||||
return QString();
|
||||
}
|
||||
|
||||
// Build encrypted format: [VERSION:2][SALT:16][NONCE:16][HMAC:32][ENCRYPTED_DATA]
|
||||
QByteArray result;
|
||||
|
||||
result.append(static_cast<char>(ENCRYPTION_VERSION_CURRENT));
|
||||
result.append(salt);
|
||||
result.append(nonce);
|
||||
result.append(hmac);
|
||||
result.append(encryptedData);
|
||||
|
||||
// Securely clear sensitive data
|
||||
secureByteArrayClear(plaintextBytes);
|
||||
secureByteArrayClear(derivedKey);
|
||||
secureByteArrayClear(profileKey);
|
||||
|
||||
// Encode as Base64 for safe text storage
|
||||
QString base64Result = result.toBase64();
|
||||
|
||||
// Clear result data
|
||||
secureByteArrayClear(result);
|
||||
|
||||
return base64Result;
|
||||
}
|
||||
|
||||
QString SecureStringUtils::decryptStringForProfile(const QString& ciphertext, const QString& profileName)
|
||||
{
|
||||
if (ciphertext.isEmpty() || profileName.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
// Decode from Base64
|
||||
QByteArray encrypted = QByteArray::fromBase64(ciphertext.toLatin1());
|
||||
|
||||
if (encrypted.size() < MIN_ENCRYPTED_SIZE) {
|
||||
return QString(); // Invalid format
|
||||
}
|
||||
|
||||
// Extract version
|
||||
quint8 version = static_cast<quint8>(encrypted[0]);
|
||||
|
||||
if (version != ENCRYPTION_VERSION_CURRENT) {
|
||||
return QString(); // Unsupported version
|
||||
}
|
||||
|
||||
// Extract salt (bytes 1-16)
|
||||
QByteArray salt = encrypted.mid(1, SALT_SIZE);
|
||||
|
||||
// Get profile-specific encryption key
|
||||
QByteArray profileKey = getProfileEncryptionKey(profileName);
|
||||
|
||||
if (profileKey.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
// Derive encryption key using PBKDF2
|
||||
QByteArray derivedKey = generateKey(profileKey, salt, PBKDF2_ITERATIONS);
|
||||
|
||||
if (derivedKey.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
// Current format: [VERSION:2][SALT:16][NONCE:16][HMAC:32][ENCRYPTED_DATA]
|
||||
if (encrypted.size() < 1 + SALT_SIZE + NONCE_SIZE + HMAC_SIZE) {
|
||||
return QString(); // Invalid format
|
||||
}
|
||||
|
||||
// Extract nonce (bytes 17-32)
|
||||
QByteArray nonce = encrypted.mid(1 + SALT_SIZE, NONCE_SIZE);
|
||||
|
||||
// Extract HMAC (bytes 33-64)
|
||||
QByteArray hmac = encrypted.mid(1 + SALT_SIZE + NONCE_SIZE, HMAC_SIZE);
|
||||
|
||||
// Extract encrypted data (bytes 65+)
|
||||
QByteArray encryptedData = encrypted.mid(1 + SALT_SIZE + NONCE_SIZE + HMAC_SIZE);
|
||||
|
||||
// Decrypt the data
|
||||
QByteArray decrypted = decryptData(encryptedData, derivedKey, salt, nonce, hmac);
|
||||
|
||||
if (decrypted.isEmpty()) {
|
||||
// Securely clear sensitive data before returning
|
||||
secureByteArrayClear(derivedKey);
|
||||
secureByteArrayClear(profileKey);
|
||||
return QString();
|
||||
}
|
||||
|
||||
// Convert back to QString
|
||||
QString result = QString::fromUtf8(decrypted);
|
||||
|
||||
// Clear sensitive data
|
||||
secureByteArrayClear(encrypted);
|
||||
secureByteArrayClear(salt);
|
||||
secureByteArrayClear(profileKey);
|
||||
secureByteArrayClear(derivedKey);
|
||||
secureByteArrayClear(decrypted);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
QByteArray SecureStringUtils::getProfileEncryptionKey(const QString& profileName)
|
||||
{
|
||||
// Try to load existing key from profile directory
|
||||
QByteArray fileKey = loadEncryptionKeyFromFile(profileName);
|
||||
|
||||
if (fileKey.size() == KEY_SIZE) {
|
||||
return fileKey;
|
||||
}
|
||||
|
||||
// Generate a new random key
|
||||
QByteArray newKey;
|
||||
|
||||
newKey.resize(KEY_SIZE);
|
||||
|
||||
QRandomGenerator* rng = QRandomGenerator::system();
|
||||
|
||||
for (int i = 0; i < KEY_SIZE; ++i) {
|
||||
newKey[i] = static_cast<char>(rng->bounded(256));
|
||||
}
|
||||
|
||||
// Store the new key in profile directory
|
||||
if (storeEncryptionKeyToFile(profileName, newKey)) {
|
||||
return newKey;
|
||||
}
|
||||
|
||||
// Final fallback to deterministic key if all else fails
|
||||
// This ensures compatibility when profile directory is read-only
|
||||
QCryptographicHash hash(QCryptographicHash::Sha256);
|
||||
|
||||
hash.addData(qsl("Mudlet").toUtf8());
|
||||
hash.addData(profileName.toUtf8());
|
||||
hash.addData(qsl("MudletProfileEncryption2025").toUtf8());
|
||||
|
||||
return hash.result();
|
||||
}
|
||||
|
||||
QByteArray SecureStringUtils::loadEncryptionKeyFromFile(const QString& profileName)
|
||||
{
|
||||
// Build path manually to avoid circular dependencies
|
||||
QString configPath = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
|
||||
QString keyFilePath = QString("%1/profiles/%2/encryption_key").arg(configPath, profileName);
|
||||
|
||||
QFile file(keyFilePath);
|
||||
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return QByteArray(); // File doesn't exist or can't be read
|
||||
}
|
||||
|
||||
QDataStream ifs(&file);
|
||||
// Use compatible data stream format
|
||||
ifs.setVersion(QDataStream::Qt_5_12);
|
||||
|
||||
QString base64Key;
|
||||
|
||||
ifs >> base64Key;
|
||||
file.close();
|
||||
|
||||
if (base64Key.isEmpty()) {
|
||||
return QByteArray();
|
||||
}
|
||||
|
||||
QByteArray key = QByteArray::fromBase64(base64Key.toLatin1());
|
||||
return (key.size() == KEY_SIZE) ? key : QByteArray();
|
||||
}
|
||||
|
||||
bool SecureStringUtils::storeEncryptionKeyToFile(const QString& profileName, const QByteArray& key)
|
||||
{
|
||||
if (key.size() != KEY_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build path manually to avoid circular dependencies
|
||||
QString configPath = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
|
||||
QString profileDir = QString("%1/profiles/%2").arg(configPath, profileName);
|
||||
QString keyFilePath = QString("%1/encryption_key").arg(profileDir);
|
||||
|
||||
// Ensure profile directory exists
|
||||
QDir dir;
|
||||
|
||||
if (!dir.mkpath(profileDir)) {
|
||||
qDebug().nospace().noquote() << "SecureStringUtils::storeEncryptionKeyToFile() WARNING - could not create profile directory for \""
|
||||
<< profileName << "\". Falling back to deterministic key derivation.";
|
||||
return false;
|
||||
}
|
||||
|
||||
QSaveFile file(keyFilePath);
|
||||
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Unbuffered)) {
|
||||
qDebug().nospace().noquote() << "SecureStringUtils::storeEncryptionKeyToFile() WARNING - could not create encryption key file for profile \""
|
||||
<< profileName << "\", error: " << file.errorString() << ". Falling back to deterministic key derivation.";
|
||||
return false;
|
||||
}
|
||||
|
||||
QDataStream ofs(&file);
|
||||
// Use compatible data stream format
|
||||
ofs.setVersion(QDataStream::Qt_5_12);
|
||||
|
||||
QString base64Key = key.toBase64();
|
||||
ofs << base64Key;
|
||||
|
||||
if (!file.commit()) {
|
||||
qDebug().nospace().noquote() << "SecureStringUtils::storeEncryptionKeyToFile() WARNING - could not save encryption key file for profile \""
|
||||
<< profileName << "\", error: " << file.errorString() << ". Falling back to deterministic key derivation.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SecureStringUtils::isTestEnvironment()
|
||||
{
|
||||
// Check if we're running in a test environment
|
||||
// This prevents keychain access during automated testing
|
||||
|
||||
// Check various indicators that we're in a test environment
|
||||
QString appName = QCoreApplication::applicationName();
|
||||
QStringList args = QCoreApplication::arguments();
|
||||
|
||||
return qEnvironmentVariableIsSet("MUDLET_TEST_MODE") ||
|
||||
appName.contains("Test", Qt::CaseInsensitive) ||
|
||||
args.first().contains("Test", Qt::CaseInsensitive);
|
||||
}
|
||||
|
||||
QByteArray SecureStringUtils::generateNonce()
|
||||
{
|
||||
QByteArray nonce(NONCE_SIZE, 0);
|
||||
|
||||
// Fill with cryptographically secure random bytes
|
||||
QRandomGenerator* rng = QRandomGenerator::system();
|
||||
|
||||
for (int i = 0; i < NONCE_SIZE; ++i) {
|
||||
nonce[i] = static_cast<char>(rng->bounded(256));
|
||||
}
|
||||
|
||||
return nonce;
|
||||
}
|
||||
|
||||
QByteArray SecureStringUtils::encryptData(const QByteArray& plaintext, const QByteArray& key,
|
||||
const QByteArray& salt, const QByteArray& nonce,
|
||||
QByteArray& hmac)
|
||||
{
|
||||
if (plaintext.isEmpty() || key.size() != KEY_SIZE || salt.size() != SALT_SIZE || nonce.size() != NONCE_SIZE) {
|
||||
return QByteArray();
|
||||
}
|
||||
|
||||
// Create cipher key by combining derived key with nonce
|
||||
QByteArray cipherKey = QCryptographicHash::hash(key + nonce, QCryptographicHash::Sha256);
|
||||
|
||||
// XOR encryption (simple but authenticated via HMAC)
|
||||
QByteArray encrypted = plaintext;
|
||||
|
||||
for (int i = 0; i < encrypted.size(); ++i) {
|
||||
encrypted[i] = encrypted[i] ^ cipherKey[i % cipherKey.size()];
|
||||
}
|
||||
|
||||
// Create HMAC-SHA256 for authentication
|
||||
// HMAC covers: salt + nonce + encrypted_data
|
||||
QByteArray macData = salt + nonce + encrypted;
|
||||
hmac = QMessageAuthenticationCode::hash(macData, key, QCryptographicHash::Sha256);
|
||||
|
||||
// Clear sensitive data
|
||||
secureByteArrayClear(cipherKey);
|
||||
secureByteArrayClear(macData);
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
QByteArray SecureStringUtils::decryptData(const QByteArray& ciphertext, const QByteArray& key,
|
||||
const QByteArray& salt, const QByteArray& nonce,
|
||||
const QByteArray& hmac)
|
||||
{
|
||||
if (ciphertext.isEmpty() || key.size() != KEY_SIZE || salt.size() != SALT_SIZE ||
|
||||
nonce.size() != NONCE_SIZE || hmac.size() != HMAC_SIZE) {
|
||||
return QByteArray();
|
||||
}
|
||||
|
||||
// Verify HMAC first (authenticate before decrypt)
|
||||
QByteArray macData = salt + nonce + ciphertext;
|
||||
QByteArray expectedHmac = QMessageAuthenticationCode::hash(macData, key, QCryptographicHash::Sha256);
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
bool hmacValid = (hmac.size() == expectedHmac.size());
|
||||
|
||||
for (int i = 0; i < qMin(hmac.size(), expectedHmac.size()); ++i) {
|
||||
hmacValid &= (hmac[i] == expectedHmac[i]);
|
||||
}
|
||||
|
||||
if (!hmacValid) {
|
||||
secureByteArrayClear(macData);
|
||||
secureByteArrayClear(expectedHmac);
|
||||
return QByteArray(); // Authentication failed
|
||||
}
|
||||
|
||||
// Create cipher key by combining derived key with nonce
|
||||
QByteArray cipherKey = QCryptographicHash::hash(key + nonce, QCryptographicHash::Sha256);
|
||||
|
||||
// XOR decryption (same operation as encryption)
|
||||
QByteArray decrypted = ciphertext;
|
||||
|
||||
for (int i = 0; i < decrypted.size(); ++i) {
|
||||
decrypted[i] = decrypted[i] ^ cipherKey[i % cipherKey.size()];
|
||||
}
|
||||
|
||||
// Clear sensitive data
|
||||
secureByteArrayClear(cipherKey);
|
||||
secureByteArrayClear(macData);
|
||||
secureByteArrayClear(expectedHmac);
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// Convenience methods for password storage and retrieval
|
||||
|
||||
bool SecureStringUtils::storePassword(const QString& profileName, const QString& key, const QString& password)
|
||||
{
|
||||
if (profileName.isEmpty() || key.isEmpty() || !isValidPasswordKey(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (password.isEmpty()) {
|
||||
// Allow storing empty passwords (effectively removing them)
|
||||
return removePassword(profileName, key);
|
||||
}
|
||||
|
||||
QString filePath = getPasswordFilePath(profileName, key);
|
||||
|
||||
// Ensure directory exists
|
||||
QFileInfo fileInfo(filePath);
|
||||
QDir dir = fileInfo.dir();
|
||||
if (!dir.exists() && !dir.mkpath(dir.absolutePath())) {
|
||||
qDebug() << "SecureStringUtils::storePassword() - Failed to create directory:" << dir.absolutePath();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Encrypt the password
|
||||
QString encryptedPassword = encryptStringForProfile(password, profileName);
|
||||
if (encryptedPassword.isEmpty()) {
|
||||
qDebug() << "SecureStringUtils::storePassword() - Failed to encrypt password";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Save to file
|
||||
QSaveFile file(filePath);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Unbuffered)) {
|
||||
qDebug() << "SecureStringUtils::storePassword() - Failed to open file for writing:" << filePath << file.errorString();
|
||||
return false;
|
||||
}
|
||||
|
||||
QDataStream ofs(&file);
|
||||
ofs.setVersion(QDataStream::Qt_5_12);
|
||||
ofs << encryptedPassword;
|
||||
|
||||
if (!file.commit()) {
|
||||
qDebug() << "SecureStringUtils::storePassword() - Failed to commit file:" << filePath << file.errorString();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
QString SecureStringUtils::retrievePassword(const QString& profileName, const QString& key)
|
||||
{
|
||||
if (profileName.isEmpty() || key.isEmpty() || !isValidPasswordKey(key)) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString filePath = getPasswordFilePath(profileName, key);
|
||||
|
||||
QFile file(filePath);
|
||||
if (!file.exists() || !file.open(QIODevice::ReadOnly)) {
|
||||
// File doesn't exist or can't be read - not an error, just no password stored
|
||||
return QString();
|
||||
}
|
||||
|
||||
QDataStream ifs(&file);
|
||||
ifs.setVersion(QDataStream::Qt_5_12);
|
||||
|
||||
QString encryptedPassword;
|
||||
ifs >> encryptedPassword;
|
||||
file.close();
|
||||
|
||||
if (encryptedPassword.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
|
||||
// Decrypt the password
|
||||
return decryptStringForProfile(encryptedPassword, profileName);
|
||||
}
|
||||
|
||||
bool SecureStringUtils::removePassword(const QString& profileName, const QString& key)
|
||||
{
|
||||
if (profileName.isEmpty() || key.isEmpty() || !isValidPasswordKey(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString filePath = getPasswordFilePath(profileName, key);
|
||||
|
||||
QFile file(filePath);
|
||||
if (!file.exists()) {
|
||||
// File doesn't exist - consider it successfully removed
|
||||
return true;
|
||||
}
|
||||
|
||||
return file.remove();
|
||||
}
|
||||
|
||||
bool SecureStringUtils::hasPassword(const QString& profileName, const QString& key)
|
||||
{
|
||||
if (profileName.isEmpty() || key.isEmpty() || !isValidPasswordKey(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString filePath = getPasswordFilePath(profileName, key);
|
||||
return QFile::exists(filePath);
|
||||
}
|
||||
|
||||
QString SecureStringUtils::getPasswordFilePath(const QString& profileName, const QString& key)
|
||||
{
|
||||
// Use the same profile path structure as mudlet
|
||||
QString configPath = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
|
||||
return QString("%1/profiles/%2/passwords/%3.dat").arg(configPath, profileName, key);
|
||||
}
|
||||
|
||||
bool SecureStringUtils::isValidPasswordKey(const QString& key)
|
||||
{
|
||||
if (key.isEmpty() || key.length() > 100) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow alphanumeric characters, underscores, and hyphens
|
||||
// This ensures the key is safe for use as a filename
|
||||
QRegularExpression validKeyRegex(qsl("^[a-zA-Z0-9_-]+$"));
|
||||
return validKeyRegex.match(key).hasMatch();
|
||||
}
|
||||
228
src/SecureStringUtils.h
Normal file
228
src/SecureStringUtils.h
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
#ifndef SECURESTRINGUTILS_H
|
||||
#define SECURESTRINGUTILS_H
|
||||
|
||||
/***************************************************************************
|
||||
* Copyright (C) 2025 by Mike Conley - mike.conley@stickmud.com *
|
||||
* *
|
||||
* 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. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program; if not, write to the *
|
||||
* Free Software Foundation, Inc., *
|
||||
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
||||
***************************************************************************/
|
||||
|
||||
#include "pre_guard.h"
|
||||
#include <QString>
|
||||
#include <QByteArray>
|
||||
#include "post_guard.h"
|
||||
|
||||
/**
|
||||
* @brief Utility class for secure string operations
|
||||
*
|
||||
* This class provides cryptographically secure encryption for sensitive data like passwords
|
||||
* stored in configuration files. All encryption is profile-aware, using unique encryption
|
||||
* keys stored in profile directories.
|
||||
*
|
||||
* Features:
|
||||
* - Per-profile encryption keys stored in profile directories
|
||||
* - Qt-based encryption with PBKDF2-SHA256 key derivation and HMAC authentication
|
||||
* - Authenticated encryption with integrity verification
|
||||
* - Secure memory clearing
|
||||
* - Automatic migration from plaintext passwords
|
||||
* - Graceful degradation when SSL/TLS is unavailable
|
||||
*
|
||||
* Encrypted format: [VERSION:2][SALT:16][NONCE:16][HMAC:32][ENCRYPTED_DATA]
|
||||
* All encoded as Base64 for safe text storage in encrypted files.
|
||||
*/
|
||||
class SecureStringUtils
|
||||
{
|
||||
public:
|
||||
/**
|
||||
* @brief Encrypt a string using a profile-specific encryption key
|
||||
* @param plaintext The string to encrypt
|
||||
* @param profileName Name of the profile (used for key lookup)
|
||||
* @return Base64-encoded encrypted string, or empty string if input is empty
|
||||
*/
|
||||
static QString encryptStringForProfile(const QString& plaintext, const QString& profileName);
|
||||
|
||||
/**
|
||||
* @brief Decrypt a string using a profile-specific encryption key
|
||||
* @param ciphertext Base64-encoded encrypted string
|
||||
* @param profileName Name of the profile (used for key lookup)
|
||||
* @return Decrypted plaintext, or empty string if input is empty/invalid
|
||||
*/
|
||||
static QString decryptStringForProfile(const QString& ciphertext, const QString& profileName);
|
||||
|
||||
/**
|
||||
* @brief Check if a string appears to be in encrypted format
|
||||
* @param text String to check
|
||||
* @return true if the string appears to be encrypted
|
||||
*/
|
||||
static bool isEncryptedFormat(const QString& text);
|
||||
|
||||
/**
|
||||
* @brief Securely clear a QString from memory
|
||||
* @param str String to clear
|
||||
*/
|
||||
static void secureStringClear(QString& str);
|
||||
|
||||
/**
|
||||
* @brief Securely clear a QByteArray from memory
|
||||
* @param array Array to clear
|
||||
*/
|
||||
static void secureByteArrayClear(QByteArray& array);
|
||||
|
||||
/**
|
||||
* @brief Check SSL backend configuration and report potential issues
|
||||
* @return QString with diagnostic information about SSL backend status
|
||||
*/
|
||||
static QString getSSLBackendInfo();
|
||||
|
||||
/**
|
||||
* @brief Check if running in test environment (to disable certain features during testing)
|
||||
* @return true if in test environment, false otherwise
|
||||
*/
|
||||
static bool isTestEnvironment();
|
||||
|
||||
// Convenience methods for password storage and retrieval
|
||||
|
||||
/**
|
||||
* @brief Store an encrypted password for a profile and key
|
||||
* @param profileName Name of the profile
|
||||
* @param key Password identifier (e.g., "server_password", "proxy_password")
|
||||
* @param password Plaintext password to encrypt and store
|
||||
* @return true if stored successfully, false otherwise
|
||||
*/
|
||||
static bool storePassword(const QString& profileName, const QString& key, const QString& password);
|
||||
|
||||
/**
|
||||
* @brief Retrieve and decrypt a password for a profile and key
|
||||
* @param profileName Name of the profile
|
||||
* @param key Password identifier
|
||||
* @return Decrypted password, or empty string if not found or decryption failed
|
||||
*/
|
||||
static QString retrievePassword(const QString& profileName, const QString& key);
|
||||
|
||||
/**
|
||||
* @brief Remove a stored password for a profile and key
|
||||
* @param profileName Name of the profile
|
||||
* @param key Password identifier
|
||||
* @return true if removed successfully, false otherwise
|
||||
*/
|
||||
static bool removePassword(const QString& profileName, const QString& key);
|
||||
|
||||
/**
|
||||
* @brief Check if a password is stored for a profile and key
|
||||
* @param profileName Name of the profile
|
||||
* @param key Password identifier
|
||||
* @return true if password exists, false otherwise
|
||||
*/
|
||||
static bool hasPassword(const QString& profileName, const QString& key);
|
||||
|
||||
private:
|
||||
// Password file storage helpers
|
||||
|
||||
/**
|
||||
* @brief Generate the file path for storing a password
|
||||
* @param profileName Name of the profile
|
||||
* @param key Password identifier
|
||||
* @return Full path to password file
|
||||
*/
|
||||
static QString getPasswordFilePath(const QString& profileName, const QString& key);
|
||||
|
||||
/**
|
||||
* @brief Validate that a key name is safe for file storage
|
||||
* @param key Password identifier to validate
|
||||
* @return true if key is valid for file storage
|
||||
*/
|
||||
static bool isValidPasswordKey(const QString& key);
|
||||
|
||||
/**
|
||||
* @brief Generate a cryptographic key using PBKDF2
|
||||
* @param password Base password/passphrase
|
||||
* @param salt Salt for key derivation
|
||||
* @param iterations Number of PBKDF2 iterations
|
||||
* @return 32-byte key
|
||||
*/
|
||||
static QByteArray generateKey(const QByteArray& password, const QByteArray& salt, int iterations = 10000);
|
||||
|
||||
/**
|
||||
* @brief Get or create a profile-specific encryption key
|
||||
* @param profileName Name of the profile
|
||||
* @return 32-byte encryption key for the profile
|
||||
*/
|
||||
static QByteArray getProfileEncryptionKey(const QString& profileName);
|
||||
|
||||
/**
|
||||
* @brief Load encryption key from profile directory file
|
||||
* @param profileName Name of the profile
|
||||
* @return 32-byte encryption key, or empty if not found/invalid
|
||||
*/
|
||||
static QByteArray loadEncryptionKeyFromFile(const QString& profileName);
|
||||
|
||||
/**
|
||||
* @brief Store encryption key to profile directory file
|
||||
* @param profileName Name of the profile
|
||||
* @param key 32-byte encryption key to store
|
||||
* @return true if storage was successful
|
||||
*/
|
||||
static bool storeEncryptionKeyToFile(const QString& profileName, const QByteArray& key);
|
||||
|
||||
/**
|
||||
* @brief Generate a random salt
|
||||
* @return 16-byte random salt
|
||||
*/
|
||||
static QByteArray generateSalt();
|
||||
|
||||
/**
|
||||
* @brief Generate a random nonce for encryption
|
||||
* @return 16-byte random nonce
|
||||
*/
|
||||
static QByteArray generateNonce();
|
||||
|
||||
/**
|
||||
* @brief Encrypt data using XOR cipher + HMAC-SHA256
|
||||
* @param plaintext Data to encrypt
|
||||
* @param key 32-byte encryption key
|
||||
* @param salt 16-byte salt
|
||||
* @param nonce 16-byte nonce
|
||||
* @param hmac Output parameter for 32-byte HMAC
|
||||
* @return Encrypted data, or empty on failure
|
||||
*/
|
||||
static QByteArray encryptData(const QByteArray& plaintext, const QByteArray& key,
|
||||
const QByteArray& salt, const QByteArray& nonce,
|
||||
QByteArray& hmac);
|
||||
|
||||
/**
|
||||
* @brief Decrypt data using XOR cipher + HMAC-SHA256
|
||||
* @param ciphertext Encrypted data
|
||||
* @param key 32-byte encryption key
|
||||
* @param salt 16-byte salt
|
||||
* @param nonce 16-byte nonce
|
||||
* @param hmac 32-byte HMAC for verification
|
||||
* @return Decrypted data, or empty on failure/authentication error
|
||||
*/
|
||||
static QByteArray decryptData(const QByteArray& ciphertext, const QByteArray& key,
|
||||
const QByteArray& salt, const QByteArray& nonce,
|
||||
const QByteArray& hmac);
|
||||
|
||||
// Constants for the encrypted format
|
||||
static constexpr quint8 ENCRYPTION_VERSION_CURRENT = 2; // Current version
|
||||
static constexpr int SALT_SIZE = 16;
|
||||
static constexpr int NONCE_SIZE = 16; // Nonce size
|
||||
static constexpr int HMAC_SIZE = 32; // HMAC-SHA256 size
|
||||
static constexpr int KEY_SIZE = 32; // 256-bit key
|
||||
static constexpr int PBKDF2_ITERATIONS = 100000; // Strong key derivation
|
||||
static constexpr int MIN_ENCRYPTED_SIZE = 1 + SALT_SIZE + NONCE_SIZE + HMAC_SIZE; // version + salt + nonce + hmac + at least some data
|
||||
};
|
||||
|
||||
#endif // SECURESTRINGUTILS_H
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
|
||||
#include "Host.h"
|
||||
#include "LuaInterface.h"
|
||||
#include "CredentialManager.h"
|
||||
#include "TAction.h"
|
||||
#include "TAlias.h"
|
||||
#include "TConsole.h"
|
||||
|
|
@ -37,6 +38,7 @@
|
|||
#include "mudlet.h"
|
||||
|
||||
#include "pre_guard.h"
|
||||
#include <QVersionNumber>
|
||||
#include <QtConcurrent>
|
||||
#include <QFile>
|
||||
#include <sstream>
|
||||
|
|
@ -452,7 +454,24 @@ void XMLexport::writeHost(Host* pHost, pugi::xml_node mudletPackage)
|
|||
host.append_attribute("mProxyAddress") = pHost->mProxyAddress.toUtf8().constData();
|
||||
host.append_attribute("mProxyPort") = QString::number(pHost->mProxyPort).toUtf8().constData();
|
||||
host.append_attribute("mProxyUsername") = pHost->mProxyUsername.toUtf8().constData();
|
||||
host.append_attribute("mProxyPassword") = pHost->mProxyPassword.toUtf8().constData();
|
||||
|
||||
// Handle proxy password based on application version for backward compatibility
|
||||
// For version 4.20.0+, use secure storage and clear XML; for older versions, maintain plaintext in XML
|
||||
const QString currentAppVersion = QString(APP_VERSION);
|
||||
const QVersionNumber appVersion = QVersionNumber::fromString(currentAppVersion);
|
||||
const QVersionNumber secureStorageVersion = QVersionNumber(4, 20, 0);
|
||||
const bool useSecureStorage = appVersion >= secureStorageVersion;
|
||||
|
||||
if (useSecureStorage) {
|
||||
// Modern versions: store in secure storage, clear from XML
|
||||
if (!pHost->mProxyPassword.isEmpty()) {
|
||||
CredentialManager::storeCredential(pHost->getName(), "proxy", pHost->mProxyPassword);
|
||||
}
|
||||
host.append_attribute("mProxyPassword") = "";
|
||||
} else {
|
||||
// Legacy versions: maintain plaintext password in XML for backward compatibility
|
||||
host.append_attribute("mProxyPassword") = pHost->mProxyPassword.toUtf8().constData();
|
||||
}
|
||||
host.append_attribute("mSslTsl") = pHost->mSslTsl ? "yes" : "no";
|
||||
host.append_attribute("mSslIgnoreExpired") = pHost->mSslIgnoreExpired ? "yes" : "no";
|
||||
host.append_attribute("mSslIgnoreSelfSigned") = pHost->mSslIgnoreSelfSigned ? "yes" : "no";
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@
|
|||
|
||||
#include "dlgMapper.h"
|
||||
#include "LuaInterface.h"
|
||||
#include "CredentialManager.h"
|
||||
#include "SecureStringUtils.h"
|
||||
#include "TConsole.h"
|
||||
#include "TMap.h"
|
||||
#include "TRoomDB.h"
|
||||
|
|
@ -35,6 +37,7 @@
|
|||
#include "pre_guard.h"
|
||||
#include <QBuffer>
|
||||
#include <QtMath>
|
||||
#include <QVersionNumber>
|
||||
#include "post_guard.h"
|
||||
|
||||
XMLimport::XMLimport(Host* pH)
|
||||
|
|
@ -804,7 +807,32 @@ void XMLimport::readHost(Host* pHost)
|
|||
}
|
||||
|
||||
pHost->mProxyUsername = attributes().value(qsl("mProxyUsername")).toString();
|
||||
pHost->mProxyPassword = attributes().value(qsl("mProxyPassword")).toString();
|
||||
|
||||
// Handle backward compatibility based on application version, not profile version
|
||||
QString storedProxyPassword = attributes().value(qsl("mProxyPassword")).toString();
|
||||
|
||||
// For version 4.20.0+, use secure storage; for older versions, maintain plaintext in XML
|
||||
// Use current application version for consistency with XMLexport behavior
|
||||
const QString currentAppVersion = QString(APP_VERSION);
|
||||
const QVersionNumber appVersion = QVersionNumber::fromString(currentAppVersion);
|
||||
const QVersionNumber secureStorageVersion = QVersionNumber(4, 20, 0);
|
||||
const bool useSecureStorage = appVersion >= secureStorageVersion;
|
||||
|
||||
if (!storedProxyPassword.isEmpty()) {
|
||||
if (useSecureStorage) {
|
||||
// Modern application: migrate plaintext password to secure storage and clear from XML
|
||||
CredentialManager::storeCredential(pHost->getName(), "proxy", storedProxyPassword);
|
||||
pHost->mProxyPassword = storedProxyPassword;
|
||||
SecureStringUtils::secureStringClear(storedProxyPassword); // Clear after migration
|
||||
} else {
|
||||
// Legacy application: keep plaintext password for backward compatibility
|
||||
pHost->mProxyPassword = storedProxyPassword;
|
||||
}
|
||||
} else if (useSecureStorage) {
|
||||
// Modern application: load from secure storage if available
|
||||
pHost->mProxyPassword = CredentialManager::retrieveCredential(pHost->getName(), "proxy");
|
||||
}
|
||||
|
||||
pHost->set_USE_IRE_DRIVER_BUGFIX(attributes().value(qsl("USE_IRE_DRIVER_BUGFIX")) == YES);
|
||||
pHost->mHighlightHistory = readDefaultTrueBool(qsl("HighlightHistory"));
|
||||
pHost->mLogDir = attributes().value(qsl("logDirectory")).toString();
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@
|
|||
#include "TGameDetails.h"
|
||||
#include "XMLimport.h"
|
||||
#include "mudlet.h"
|
||||
#include "CredentialManager.h"
|
||||
#include "SecureStringUtils.h"
|
||||
|
||||
#include "pre_guard.h"
|
||||
#include <QtConcurrent>
|
||||
|
|
@ -232,7 +234,21 @@ dlgConnectionProfiles::dlgConnectionProfiles(QWidget* parent)
|
|||
connect(auto_reconnect, &QCheckBox::stateChanged, this, &dlgConnectionProfiles::slot_updateAutoReconnect);
|
||||
#endif
|
||||
connect(login_entry, &QLineEdit::textEdited, this, &dlgConnectionProfiles::slot_updateLogin);
|
||||
connect(character_password_entry, &QLineEdit::textEdited, this, &dlgConnectionProfiles::slot_updatePassword);
|
||||
// Use textChanged with timer debouncing to avoid saving on every keystroke
|
||||
connect(character_password_entry, &QLineEdit::textChanged, this, &dlgConnectionProfiles::slot_passwordTextChanged);
|
||||
|
||||
// Listen for password migration completion to refresh the form
|
||||
connect(mudlet::self(), &mudlet::signal_passwordsMigratedToSecure, this, [this]() {
|
||||
// Refresh the current profile's password field after migration
|
||||
slot_itemClicked(profiles_tree_widget->currentItem());
|
||||
});
|
||||
|
||||
// Listen for character password migration completion to refresh the form
|
||||
connect(mudlet::self(), &mudlet::signal_characterPasswordsMigrated, this, [this]() {
|
||||
// Refresh the current profile's password field after migration
|
||||
slot_itemClicked(profiles_tree_widget->currentItem());
|
||||
});
|
||||
|
||||
connect(mud_description_textedit, &QPlainTextEdit::textChanged, this, &dlgConnectionProfiles::slot_updateDescription);
|
||||
connect(profiles_tree_widget, &QListWidget::currentItemChanged, this, &dlgConnectionProfiles::slot_itemClicked);
|
||||
connect(profiles_tree_widget, &QListWidget::itemDoubleClicked, this, &dlgConnectionProfiles::accept);
|
||||
|
|
@ -289,6 +305,10 @@ dlgConnectionProfiles::dlgConnectionProfiles(QWidget* parent)
|
|||
|
||||
dlgConnectionProfiles::~dlgConnectionProfiles()
|
||||
{
|
||||
// Clear any pending operation flags
|
||||
mKeychainOperationInProgress = false;
|
||||
mPendingProfileLoad.clear();
|
||||
|
||||
QCoreApplication::instance()->removeEventFilter(this);
|
||||
}
|
||||
|
||||
|
|
@ -300,11 +320,51 @@ void dlgConnectionProfiles::accept()
|
|||
setVisible(false);
|
||||
// This is needed to make the above take effect as fast as possible:
|
||||
qApp->processEvents();
|
||||
loadProfile(true);
|
||||
QDialog::accept();
|
||||
|
||||
// Check if keychain authentication is pending - if so, wait for it
|
||||
ensurePasswordLoadedThenConnect(true);
|
||||
}
|
||||
}
|
||||
|
||||
void dlgConnectionProfiles::slot_load()
|
||||
{
|
||||
setVisible(false);
|
||||
// This is needed to make the above take effect as fast as possible:
|
||||
qApp->processEvents();
|
||||
|
||||
// Check if keychain authentication is pending - if so, wait for it
|
||||
ensurePasswordLoadedThenConnect(false);
|
||||
}
|
||||
|
||||
void dlgConnectionProfiles::ensurePasswordLoadedThenConnect(bool alsoConnect)
|
||||
{
|
||||
const QString profile_name = profile_name_entry->text().trimmed();
|
||||
|
||||
if (profile_name.isEmpty()) {
|
||||
QDialog::accept();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have any pending keychain operations for this profile
|
||||
if (hasPendingKeychainOperation(profile_name)) {
|
||||
// Queue the profile loading until keychain completes
|
||||
mPendingConnect = alsoConnect;
|
||||
mPendingProfileLoad = profile_name;
|
||||
return; // Will be handled by keychain callback
|
||||
}
|
||||
|
||||
// No pending keychain operations, proceed immediately
|
||||
loadProfile(alsoConnect);
|
||||
QDialog::accept();
|
||||
}
|
||||
|
||||
bool dlgConnectionProfiles::hasPendingKeychainOperation(const QString& profile_name) const
|
||||
{
|
||||
Q_UNUSED(profile_name)
|
||||
// Simply check if we have a keychain operation in progress
|
||||
return mKeychainOperationInProgress;
|
||||
}
|
||||
|
||||
void dlgConnectionProfiles::slot_updateDescription()
|
||||
{
|
||||
QListWidgetItem* pItem = profiles_tree_widget->currentItem();
|
||||
|
|
@ -356,40 +416,61 @@ void dlgConnectionProfiles::slot_updatePassword(const QString& pass)
|
|||
return;
|
||||
}
|
||||
|
||||
const QString profileName = pItem->data(csmNameRole).toString();
|
||||
|
||||
if (mudlet::self()->storingPasswordsSecurely()) {
|
||||
writeSecurePassword(pItem->data(csmNameRole).toString(), pass);
|
||||
if (pass.trimmed().isEmpty()) {
|
||||
// If password is empty, remove it from secure storage
|
||||
deleteSecurePassword(profileName);
|
||||
} else {
|
||||
// Store the password securely
|
||||
writeSecurePassword(profileName, pass);
|
||||
}
|
||||
} else {
|
||||
writeProfileData(pItem->data(csmNameRole).toString(), qsl("password"), pass);
|
||||
writeProfileData(profileName, qsl("password"), pass);
|
||||
}
|
||||
}
|
||||
|
||||
void dlgConnectionProfiles::writeSecurePassword(const QString& profile, const QString& pass) const
|
||||
{
|
||||
auto* job = new QKeychain::WritePasswordJob(qsl("Mudlet profile"));
|
||||
job->setAutoDelete(false);
|
||||
job->setInsecureFallback(false);
|
||||
|
||||
job->setKey(profile);
|
||||
job->setTextData(pass);
|
||||
job->setProperty("profile", profile);
|
||||
|
||||
connect(job, &QKeychain::WritePasswordJob::finished, this, &dlgConnectionProfiles::slot_passwordSaved);
|
||||
|
||||
job->start();
|
||||
// Validate that we have a password to store
|
||||
if (pass.trimmed().isEmpty()) {
|
||||
qDebug() << "dlgConnectionProfiles: Skipping storage of empty password for profile" << profile;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use async API for QtKeychain integration with file fallback
|
||||
auto* credManager = new CredentialManager();
|
||||
|
||||
credManager->storeCredential(profile, "character", pass,
|
||||
[credManager, profile](bool success, const QString& errorMessage) {
|
||||
if (success) {
|
||||
qDebug() << "dlgConnectionProfiles: Successfully stored password for profile" << profile;
|
||||
} else {
|
||||
qWarning() << "dlgConnectionProfiles: Failed to store password for profile" << profile << ":" << errorMessage;
|
||||
}
|
||||
|
||||
// Clean up the credential manager
|
||||
credManager->deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
void dlgConnectionProfiles::deleteSecurePassword(const QString& profile) const
|
||||
{
|
||||
auto* job = new QKeychain::DeletePasswordJob(qsl("Mudlet profile"));
|
||||
job->setAutoDelete(false);
|
||||
job->setInsecureFallback(false);
|
||||
|
||||
job->setKey(profile);
|
||||
job->setProperty("profile", profile);
|
||||
|
||||
connect(job, &QKeychain::WritePasswordJob::finished, this, &dlgConnectionProfiles::slot_passwordDeleted);
|
||||
|
||||
job->start();
|
||||
// Use async API for QtKeychain integration with file fallback
|
||||
auto* credManager = new CredentialManager();
|
||||
|
||||
credManager->removeCredential(profile, "character",
|
||||
[credManager, profile](bool success, const QString& errorMessage) {
|
||||
if (success) {
|
||||
qDebug() << "dlgConnectionProfiles: Successfully removed password for profile" << profile;
|
||||
} else {
|
||||
qWarning() << "dlgConnectionProfiles: Failed to remove password for profile" << profile << ":" << errorMessage;
|
||||
}
|
||||
|
||||
// Clean up the credential manager
|
||||
credManager->deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
void dlgConnectionProfiles::slot_updateLogin(const QString& login)
|
||||
|
|
@ -521,13 +602,6 @@ void dlgConnectionProfiles::slot_saveName()
|
|||
}
|
||||
|
||||
const QString currentProfileEditName = pItem->data(csmNameRole).toString();
|
||||
const int row = mProfileList.indexOf(currentProfileEditName);
|
||||
if ((row >= 0) && (row < mProfileList.size())) {
|
||||
mProfileList[row] = newProfileName;
|
||||
} else {
|
||||
mProfileList << newProfileName;
|
||||
}
|
||||
|
||||
// don't do anything if this was just a normal click, and not an edit of any sort
|
||||
if (currentProfileEditName == newProfileName) {
|
||||
return;
|
||||
|
|
@ -829,18 +903,12 @@ void dlgConnectionProfiles::slot_itemClicked(QListWidgetItem* pItem)
|
|||
// by the copy method
|
||||
if (!mCopyingProfile) {
|
||||
character_password_entry->setText(QString());
|
||||
if (mudlet::self()->storingPasswordsSecurely()) {
|
||||
loadSecuredPassword(profile_name, [this, profile_name](const QString& password) {
|
||||
if (!password.isEmpty()) {
|
||||
character_password_entry->setText(password);
|
||||
} else {
|
||||
character_password_entry->setText(readProfileData(profile_name, qsl("password")));
|
||||
}
|
||||
});
|
||||
|
||||
} else {
|
||||
character_password_entry->setText(readProfileData(profile_name, qsl("password")));
|
||||
}
|
||||
// Schedule password loading asynchronously to avoid event loop issues
|
||||
auto* timer = new QTimer(this);
|
||||
timer->setSingleShot(true);
|
||||
timer->setProperty("profileName", profile_name);
|
||||
connect(timer, &QTimer::timeout, this, &dlgConnectionProfiles::slot_loadPasswordAsync);
|
||||
timer->start(0);
|
||||
}
|
||||
|
||||
val = readProfileData(profile_name, qsl("login"));
|
||||
|
|
@ -918,7 +986,6 @@ void dlgConnectionProfiles::slot_itemClicked(QListWidgetItem* pItem)
|
|||
year = match.captured(3);
|
||||
}
|
||||
|
||||
|
||||
QDateTime datetime;
|
||||
datetime.setTime(QTime(hour.toInt(), minute.toInt(), second.toInt()));
|
||||
datetime.setDate(QDate(year.toInt(), month.toInt(), day.toInt()));
|
||||
|
|
@ -1186,36 +1253,35 @@ void dlgConnectionProfiles::migrateSecuredPassword(const QString& oldProfile, co
|
|||
const auto& password = character_password_entry->text().trimmed();
|
||||
|
||||
deleteSecurePassword(oldProfile);
|
||||
writeSecurePassword(newProfile, password);
|
||||
|
||||
// Only store the password if it's not empty
|
||||
if (!password.isEmpty()) {
|
||||
writeSecurePassword(newProfile, password);
|
||||
}
|
||||
}
|
||||
|
||||
template <typename L>
|
||||
void dlgConnectionProfiles::loadSecuredPassword(const QString& profile, L callback)
|
||||
{
|
||||
// character_password_entry
|
||||
|
||||
auto* job = new QKeychain::ReadPasswordJob(qsl("Mudlet profile"));
|
||||
job->setAutoDelete(false);
|
||||
job->setInsecureFallback(false);
|
||||
|
||||
job->setKey(profile);
|
||||
|
||||
connect(job, &QKeychain::ReadPasswordJob::finished, this, [=](QKeychain::Job* task) {
|
||||
if (task->error()) {
|
||||
const auto error = task->errorString();
|
||||
if (error != qsl("Entry not found") && error != qsl("No match")) {
|
||||
qDebug().nospace().noquote() << "dlgConnectionProfiles::loadSecuredPassword() ERROR - could not retrieve secure password for \"" << profile << "\", error is: " << error << ".";
|
||||
// Use async API for QtKeychain integration with file fallback
|
||||
auto* credManager = new CredentialManager();
|
||||
|
||||
credManager->retrieveCredential(profile, "character",
|
||||
[credManager, callback = std::move(callback)](bool success, const QString& password, const QString& errorMessage) {
|
||||
if (success) {
|
||||
callback(password);
|
||||
QString passwordCopy = password; // Make a copy for secure clearing
|
||||
SecureStringUtils::secureStringClear(passwordCopy);
|
||||
} else {
|
||||
if (!errorMessage.isEmpty()) {
|
||||
qDebug() << "dlgConnectionProfiles: Failed to retrieve password:" << errorMessage;
|
||||
}
|
||||
callback(QString()); // Call with empty string on failure
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
auto readJob = static_cast<QKeychain::ReadPasswordJob*>(task);
|
||||
callback(readJob->textData());
|
||||
|
||||
task->deleteLater();
|
||||
});
|
||||
|
||||
job->start();
|
||||
|
||||
// Clean up the credential manager
|
||||
credManager->deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
std::optional<QColor> getCustomColor(const QString& profileName)
|
||||
|
|
@ -1324,24 +1390,6 @@ void dlgConnectionProfiles::slot_resetCustomIcon()
|
|||
profiles_tree_widget->setCurrentRow(currentRow);
|
||||
}
|
||||
|
||||
void dlgConnectionProfiles::slot_passwordSaved(QKeychain::Job* job)
|
||||
{
|
||||
if (job->error()) {
|
||||
qWarning().nospace().noquote() << "dlgslot_passwordSaved:slot_passwordSaved(...) ERROR - could not save password for \"" << job->property("profile").toString() << "\"; error was: \"" << job->errorString() << "\".";
|
||||
}
|
||||
|
||||
job->deleteLater();
|
||||
}
|
||||
|
||||
void dlgConnectionProfiles::slot_passwordDeleted(QKeychain::Job* job)
|
||||
{
|
||||
if (job->error()) {
|
||||
qWarning() << "dlgConnectionProfiles::slot_passwordDeleted(...) ERROR - could not delete password for: \"" << job->property("profile").toString() << "\"; error was: \"" << job->errorString() << "\".";
|
||||
}
|
||||
|
||||
job->deleteLater();
|
||||
}
|
||||
|
||||
void dlgConnectionProfiles::slot_cancel()
|
||||
{
|
||||
// QDialog::Rejected is the enum value (= 0) return value for a "cancelled"
|
||||
|
|
@ -1385,7 +1433,7 @@ void dlgConnectionProfiles::slot_copyProfile()
|
|||
|
||||
// restore the password, which won't be copied by the disk copy if stored in the credential manager
|
||||
character_password_entry->setText(oldPassword);
|
||||
if (mudlet::self()->storingPasswordsSecurely()) {
|
||||
if (mudlet::self()->storingPasswordsSecurely() && !oldPassword.trimmed().isEmpty()) {
|
||||
writeSecurePassword(profile_name, oldPassword);
|
||||
}
|
||||
mCopyingProfile = false;
|
||||
|
|
@ -1545,15 +1593,6 @@ void dlgConnectionProfiles::saveProfileCopy(const QDir& newProfiledir, const pug
|
|||
}
|
||||
}
|
||||
|
||||
void dlgConnectionProfiles::slot_load()
|
||||
{
|
||||
setVisible(false);
|
||||
// This is needed to make the above take effect as fast as possible:
|
||||
qApp->processEvents();
|
||||
loadProfile(false);
|
||||
QDialog::accept();
|
||||
}
|
||||
|
||||
void dlgConnectionProfiles::loadProfile(bool alsoConnect)
|
||||
{
|
||||
const QString profile_name = profile_name_entry->text().trimmed();
|
||||
|
|
@ -2066,3 +2105,141 @@ void dlgConnectionProfiles::addLetterToProfileSearch(const int key)
|
|||
|
||||
profiles_tree_widget->setCurrentRow(indexes.first());
|
||||
}
|
||||
|
||||
void dlgConnectionProfiles::slot_loadPasswordAsync()
|
||||
{
|
||||
if (!sender()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the profile name from the timer's property
|
||||
QTimer* timer = qobject_cast<QTimer*>(sender());
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QString profile_name = timer->property("profileName").toString();
|
||||
|
||||
if (profile_name.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up the timer
|
||||
timer->deleteLater();
|
||||
|
||||
// Check if this dialog is still valid and the profile is still selected
|
||||
if (profiles_tree_widget->currentItem() == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QString currentProfileName = profiles_tree_widget->currentItem()->data(csmNameRole).toString();
|
||||
|
||||
if (currentProfileName != profile_name) {
|
||||
// Selection has changed, ignore this async load
|
||||
return;
|
||||
}
|
||||
|
||||
// If secure storage is enabled, try keychain first, then fallback to QSettings
|
||||
if (mudlet::self()->storingPasswordsSecurely()) {
|
||||
mKeychainOperationInProgress = true;
|
||||
auto* credManager = new CredentialManager(this);
|
||||
credManager->retrieveCredential(profile_name, "character",
|
||||
[this, credManager, profile_name](bool success, const QString& retrievedPassword, const QString& errorMessage) {
|
||||
// Clear the operation flag first
|
||||
mKeychainOperationInProgress = false;
|
||||
|
||||
// Check if profile selection has changed while we were waiting
|
||||
if (profiles_tree_widget->currentItem() &&
|
||||
profiles_tree_widget->currentItem()->data(csmNameRole).toString() == profile_name) {
|
||||
|
||||
if (success) {
|
||||
// Keychain operation succeeded - set the password (even if empty)
|
||||
character_password_entry->setText(retrievedPassword);
|
||||
if (retrievedPassword.isEmpty()) {
|
||||
qDebug() << "dlgConnectionProfiles: Keychain returned empty password for" << profile_name;
|
||||
}
|
||||
} else {
|
||||
// Fallback to QSettings only if keychain operation failed
|
||||
loadPasswordFromSettings(profile_name);
|
||||
qDebug() << "dlgConnectionProfiles: Keychain failed for" << profile_name << ", using file fallback:" << errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there's a pending connection waiting for this password load
|
||||
// (do this regardless of profile selection state to avoid hanging)
|
||||
if (!mPendingProfileLoad.isEmpty() && mPendingProfileLoad == profile_name) {
|
||||
qDebug() << "dlgConnectionProfiles: Password load completed, proceeding with pending connection for" << profile_name;
|
||||
|
||||
// Clear pending state
|
||||
QString profileToLoad = mPendingProfileLoad;
|
||||
bool shouldConnect = mPendingConnect;
|
||||
mPendingProfileLoad.clear();
|
||||
|
||||
// Proceed with the connection
|
||||
loadProfile(shouldConnect);
|
||||
QDialog::accept();
|
||||
}
|
||||
|
||||
credManager->deleteLater();
|
||||
});
|
||||
} else {
|
||||
// Secure storage disabled, use QSettings directly
|
||||
loadPasswordFromSettings(profile_name);
|
||||
|
||||
// Check if there's a pending connection waiting
|
||||
if (!mPendingProfileLoad.isEmpty() && mPendingProfileLoad == profile_name) {
|
||||
qDebug() << "dlgConnectionProfiles: Password loaded from settings, proceeding with pending connection for" << profile_name;
|
||||
|
||||
// Clear pending state
|
||||
QString profileToLoad = mPendingProfileLoad;
|
||||
bool shouldConnect = mPendingConnect;
|
||||
mPendingProfileLoad.clear();
|
||||
|
||||
// Proceed with the connection
|
||||
loadProfile(shouldConnect);
|
||||
QDialog::accept();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void dlgConnectionProfiles::loadPasswordFromSettings(const QString& profile_name)
|
||||
{
|
||||
auto& settings = *mudlet::self()->mpSettings;
|
||||
settings.beginGroup(qsl("profiles/%1").arg(profile_name));
|
||||
|
||||
// Get password and handle migration
|
||||
const QString password = settings.value(qsl("password"), QString()).toString();
|
||||
const QString oldPassword = settings.value(qsl("login"), QString()).toString();
|
||||
|
||||
if (!password.isEmpty()) {
|
||||
character_password_entry->setText(password);
|
||||
} else if (!oldPassword.isEmpty()) {
|
||||
// Migrate old password
|
||||
character_password_entry->setText(oldPassword);
|
||||
settings.setValue(qsl("password"), oldPassword);
|
||||
settings.remove(qsl("login"));
|
||||
} else {
|
||||
character_password_entry->setText(QString());
|
||||
}
|
||||
|
||||
settings.endGroup();
|
||||
}
|
||||
|
||||
void dlgConnectionProfiles::slot_passwordTextChanged()
|
||||
{
|
||||
// Cancel any pending password save
|
||||
if (mPasswordSaveTimer) {
|
||||
mPasswordSaveTimer->stop();
|
||||
} else {
|
||||
mPasswordSaveTimer = new QTimer(this);
|
||||
mPasswordSaveTimer->setSingleShot(true);
|
||||
mPasswordSaveTimer->setInterval(500); // 500ms debounce
|
||||
connect(mPasswordSaveTimer, &QTimer::timeout, this, [this]() {
|
||||
QListWidgetItem* pItem = profiles_tree_widget->currentItem();
|
||||
if (pItem) {
|
||||
slot_updatePassword(character_password_entry->text());
|
||||
}
|
||||
});
|
||||
}
|
||||
mPasswordSaveTimer->start();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,11 +29,6 @@
|
|||
#include <QTimer>
|
||||
#include <QKeyEvent>
|
||||
#include <pugixml.hpp>
|
||||
#if defined(INCLUDE_OWN_QT6_KEYCHAIN)
|
||||
#include <../3rdparty/qtkeychain/keychain.h>
|
||||
#else
|
||||
#include <qt6keychain/keychain.h>
|
||||
#endif
|
||||
#include "post_guard.h"
|
||||
|
||||
class QDir;
|
||||
|
|
@ -93,6 +88,9 @@ public slots:
|
|||
|
||||
protected:
|
||||
bool eventFilter(QObject*, QEvent*) override;
|
||||
void loadPasswordFromSettings(const QString& profile_name);
|
||||
void ensurePasswordLoadedThenConnect(bool alsoConnect);
|
||||
bool hasPendingKeychainOperation(const QString& profile_name) const;
|
||||
|
||||
|
||||
private:
|
||||
|
|
@ -122,6 +120,7 @@ private:
|
|||
QIcon customIcon(const QString&, const std::optional<QColor>&) const;
|
||||
void addLetterToProfileSearch(const int);
|
||||
inline void clearNotificationArea();
|
||||
void loadPasswordAsync(const QString& profileName);
|
||||
|
||||
// split into 3 properties so each one can be checked individually
|
||||
// important for creation of a folder on disk, for example: name has
|
||||
|
|
@ -149,6 +148,12 @@ private:
|
|||
QVector<QColor> mCustomIconColors;
|
||||
QTimer mSearchTextTimer;
|
||||
QString mSearchText;
|
||||
QTimer* mPasswordSaveTimer = nullptr;
|
||||
|
||||
// Async connection handling
|
||||
QString mPendingProfileLoad; // Profile name waiting for password load
|
||||
bool mPendingConnect = false; // Whether to connect (true) or just load (false)
|
||||
bool mKeychainOperationInProgress = false; // Track if keychain op is active
|
||||
|
||||
|
||||
private slots:
|
||||
|
|
@ -157,9 +162,9 @@ private slots:
|
|||
void slot_setCustomColor();
|
||||
void slot_resetCustomIcon();
|
||||
void slot_togglePasswordVisibility(const bool);
|
||||
void slot_passwordSaved(QKeychain::Job* job);
|
||||
void slot_passwordDeleted(QKeychain::Job* job);
|
||||
void slot_reenableAllProfileItems();
|
||||
void slot_loadPasswordAsync();
|
||||
void slot_passwordTextChanged();
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
185
src/mudlet.cpp
185
src/mudlet.cpp
|
|
@ -29,6 +29,7 @@
|
|||
#include "mudlet.h"
|
||||
|
||||
#include "AltFocusMenuBarDisable.h"
|
||||
#include "CredentialManager.h"
|
||||
#include "EAction.h"
|
||||
#include "LuaInterface.h"
|
||||
#include "TCommandLine.h"
|
||||
|
|
@ -2859,6 +2860,7 @@ void mudlet::slot_showConnectionDialog()
|
|||
mpConnectionDialog->indicatePackagesInstallOnConnect(packagesToInstall);
|
||||
|
||||
connect(mpConnectionDialog, &QDialog::accepted, this, [=, this]() { enableToolbarButtons(); });
|
||||
connect(mpConnectionDialog, &QObject::destroyed, this, [=, this]() { mpConnectionDialog = nullptr; });
|
||||
mpConnectionDialog->setAttribute(Qt::WA_DeleteOnClose);
|
||||
|
||||
// Use a timer to ensure the main window is ready before showing the dialog
|
||||
|
|
@ -5086,65 +5088,89 @@ std::string mudlet::replaceString(std::string subject, const std::string& search
|
|||
return subject;
|
||||
}
|
||||
|
||||
// Helper function to check if current version is >= specified version
|
||||
// Returns true if current version is >= minVersion, false otherwise
|
||||
bool mudlet::isVersionAtLeast(const QString& minVersion)
|
||||
{
|
||||
const QString currentVersion = QString(APP_VERSION);
|
||||
|
||||
// Parse version strings (format: major.minor.patch)
|
||||
const QStringList currentParts = currentVersion.split('.');
|
||||
const QStringList minParts = minVersion.split('.');
|
||||
|
||||
// Ensure we have at least 3 parts for comparison
|
||||
auto getCurrentPart = [¤tParts](int index) -> int {
|
||||
return (index < currentParts.size()) ? currentParts[index].toInt() : 0;
|
||||
};
|
||||
|
||||
auto getMinPart = [&minParts](int index) -> int {
|
||||
return (index < minParts.size()) ? minParts[index].toInt() : 0;
|
||||
};
|
||||
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const int currentPart = getCurrentPart(i);
|
||||
const int minPart = getMinPart(i);
|
||||
|
||||
if (currentPart > minPart) {
|
||||
return true;
|
||||
} else if (currentPart < minPart) {
|
||||
return false;
|
||||
}
|
||||
// If equal, continue to next part
|
||||
}
|
||||
|
||||
return true; // Versions are equal
|
||||
}
|
||||
|
||||
bool mudlet::migratePasswordsToSecureStorage()
|
||||
{
|
||||
if (!mProfilePasswordsToMigrate.isEmpty()) {
|
||||
qWarning() << "mudlet::migratePasswordsToSecureStorage() warning: password migration is already in progress, won't start another.";
|
||||
return false;
|
||||
}
|
||||
|
||||
mStorePasswordsSecurely = true;
|
||||
|
||||
const QStringList profiles = QDir(mudlet::getMudletPath(enums::profilesPath))
|
||||
.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
|
||||
|
||||
bool anyMigrationNeeded = false;
|
||||
|
||||
for (const auto& profile : profiles) {
|
||||
const auto password = readProfileData(profile, qsl("password"));
|
||||
if (password.isEmpty()) {
|
||||
continue;
|
||||
if (!password.isEmpty()) {
|
||||
// Use CredentialManager to store the password securely
|
||||
if (CredentialManager::storeCredential(profile, "character", password)) {
|
||||
// Only remove from profile data if this version is >= 4.20.0
|
||||
// This prevents breaking compatibility with older Mudlet versions
|
||||
// that users may still have installed alongside development builds
|
||||
if (isVersionAtLeast(qsl("4.20.0"))) {
|
||||
deleteProfileData(profile, qsl("password"));
|
||||
qDebug().nospace().noquote() << "mudlet::migratePasswordsToSecureStorage() INFO - migrated password for profile \"" << profile << "\" from old format and cleaned up legacy storage.";
|
||||
} else {
|
||||
qDebug().nospace().noquote() << "mudlet::migratePasswordsToSecureStorage() INFO - migrated password for profile \"" << profile << "\" from old format (legacy storage preserved for compatibility).";
|
||||
}
|
||||
anyMigrationNeeded = true;
|
||||
} else {
|
||||
qWarning().nospace().noquote() << "mudlet::migratePasswordsToSecureStorage() ERROR - could not migrate password for profile \"" << profile << "\".";
|
||||
}
|
||||
}
|
||||
|
||||
auto *job = new QKeychain::WritePasswordJob(qsl("Mudlet profile"));
|
||||
job->setAutoDelete(false);
|
||||
job->setInsecureFallback(false);
|
||||
|
||||
job->setKey(profile);
|
||||
job->setTextData(password);
|
||||
job->setProperty("profile", profile);
|
||||
|
||||
mProfilePasswordsToMigrate.append(profile);
|
||||
|
||||
connect(job, &QKeychain::WritePasswordJob::finished, this, &mudlet::slot_passwordMigratedToSecureStorage);
|
||||
|
||||
job->start();
|
||||
}
|
||||
|
||||
if (mProfilePasswordsToMigrate.isEmpty()) {
|
||||
QTimer::singleShot(0, this, [this]() {
|
||||
emit signal_passwordsMigratedToProfiles();
|
||||
});
|
||||
if (!anyMigrationNeeded) {
|
||||
qDebug() << "mudlet::migratePasswordsToSecureStorage() INFO - no migration needed.";
|
||||
}
|
||||
|
||||
// Always emit the signal (either immediately or after migrations complete)
|
||||
QTimer::singleShot(0, this, [this]() {
|
||||
emit signal_passwordsMigratedToSecure();
|
||||
});
|
||||
|
||||
return anyMigrationNeeded;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void mudlet::slot_passwordMigratedToSecureStorage(QKeychain::Job* job)
|
||||
{
|
||||
const auto profileName = job->property("profile").toString();
|
||||
if (job->error()) {
|
||||
qWarning().nospace().noquote() << "mudlet::slot_passwordMigratedToSecureStorage(...) ERROR - could not migrate for \"" << profileName << "\"; error was: \"" << job->errorString() << "\".";
|
||||
} else {
|
||||
deleteProfileData(profileName, qsl("password"));
|
||||
}
|
||||
mProfilePasswordsToMigrate.removeAll(profileName);
|
||||
job->deleteLater();
|
||||
|
||||
if (mProfilePasswordsToMigrate.isEmpty()) {
|
||||
emit signal_passwordsMigratedToSecure();
|
||||
} else {
|
||||
emit signal_passwordMigratedToSecure(profileName);
|
||||
}
|
||||
}
|
||||
|
||||
bool mudlet::migratePasswordsToProfileStorage()
|
||||
{
|
||||
if (!mProfilePasswordsToMigrate.isEmpty()) {
|
||||
|
|
@ -5155,7 +5181,29 @@ bool mudlet::migratePasswordsToProfileStorage()
|
|||
|
||||
const QStringList profiles = QDir(mudlet::getMudletPath(enums::profilesPath)).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
|
||||
|
||||
bool anyMigrationNeeded = false;
|
||||
|
||||
for (const auto& profile : profiles) {
|
||||
// Try to retrieve password from CredentialManager
|
||||
QString password = CredentialManager::retrieveCredential(profile, "character");
|
||||
|
||||
if (!password.isEmpty()) {
|
||||
// Store in profile data
|
||||
writeProfileData(profile, qsl("password"), password);
|
||||
|
||||
// Only remove from secure storage if this version is >= 4.20.0
|
||||
// This prevents breaking compatibility with older Mudlet versions
|
||||
if (isVersionAtLeast(qsl("4.20.0"))) {
|
||||
CredentialManager::removeCredential(profile, "character");
|
||||
qDebug().nospace().noquote() << "mudlet::migratePasswordsToProfileStorage() INFO - migrated password for profile \"" << profile << "\" to profile storage and cleaned up secure storage.";
|
||||
} else {
|
||||
qDebug().nospace().noquote() << "mudlet::migratePasswordsToProfileStorage() INFO - migrated password for profile \"" << profile << "\" to profile storage (secure storage preserved for compatibility).";
|
||||
}
|
||||
anyMigrationNeeded = true;
|
||||
}
|
||||
|
||||
// Also check for old-format keychain entries (service: "Mudlet profile", key: profile name)
|
||||
// and migrate them to profile storage
|
||||
auto* job = new QKeychain::ReadPasswordJob(qsl("Mudlet profile"));
|
||||
job->setAutoDelete(false);
|
||||
job->setInsecureFallback(false);
|
||||
|
|
@ -5167,11 +5215,13 @@ bool mudlet::migratePasswordsToProfileStorage()
|
|||
job->start();
|
||||
}
|
||||
|
||||
// If no old-format entries need to be checked, emit signal immediately
|
||||
if (mProfilePasswordsToMigrate.isEmpty()) {
|
||||
QTimer::singleShot(0, this, [this]() {
|
||||
emit signal_passwordsMigratedToProfiles();
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -5189,18 +5239,65 @@ void mudlet::slot_passwordMigratedToPortableStorage(QKeychain::Job* job)
|
|||
auto readJob = static_cast<QKeychain::ReadPasswordJob*>(job);
|
||||
writeProfileData(profileName, qsl("password"), readJob->textData());
|
||||
|
||||
// delete from secure storage
|
||||
auto *deleteJob = new QKeychain::DeletePasswordJob(qsl("Mudlet profile"));
|
||||
deleteJob->setAutoDelete(true);
|
||||
deleteJob->setKey(profileName);
|
||||
deleteJob->setProperty("profile", profileName);
|
||||
deleteJob->start();
|
||||
// Only delete from secure storage if this version is >= 4.20.0
|
||||
// This prevents breaking compatibility with older Mudlet versions
|
||||
if (isVersionAtLeast(qsl("4.20.0"))) {
|
||||
auto *deleteJob = new QKeychain::DeletePasswordJob(qsl("Mudlet profile"));
|
||||
deleteJob->setAutoDelete(true);
|
||||
deleteJob->setKey(profileName);
|
||||
deleteJob->setProperty("profile", profileName);
|
||||
deleteJob->start();
|
||||
qDebug().nospace().noquote() << "mudlet::slot_passwordMigratedToPortableStorage() INFO - migrated password for profile \"" << profileName << "\" and cleaned up legacy keychain storage.";
|
||||
} else {
|
||||
qDebug().nospace().noquote() << "mudlet::slot_passwordMigratedToPortableStorage() INFO - migrated password for profile \"" << profileName << "\" (legacy keychain storage preserved for compatibility).";
|
||||
}
|
||||
}
|
||||
mProfilePasswordsToMigrate.removeAll(profileName);
|
||||
job->deleteLater();
|
||||
|
||||
if (mProfilePasswordsToMigrate.isEmpty()) {
|
||||
emit signal_passwordsMigratedToProfiles();
|
||||
emit signal_passwordsMigratedToSecure(); // Also emit this for the connection profiles dialog
|
||||
}
|
||||
}
|
||||
|
||||
void mudlet::slot_passwordMigratedToSecureStorage(QKeychain::Job* job)
|
||||
{
|
||||
const auto profileName = job->property("profile").toString();
|
||||
const auto characterName = job->property("character").toString();
|
||||
|
||||
if (job->error()) {
|
||||
const auto error = job->errorString();
|
||||
if (error != qsl("Entry not found") && error != qsl("No match")) {
|
||||
qWarning().nospace().noquote() << "mudlet::slot_passwordMigratedToSecureStorage(...) ERROR - could not migrate character password for \"" << characterName << "\" in profile \"" << profileName << "\"; error was: " << error << ".";
|
||||
}
|
||||
} else {
|
||||
auto readJob = static_cast<QKeychain::ReadPasswordJob*>(job);
|
||||
const auto password = readJob->textData();
|
||||
|
||||
// Store the password using CredentialManager
|
||||
CredentialManager::storeCredential(profileName, characterName, password);
|
||||
|
||||
// Only delete from QtKeychain if this version is >= 4.20.0
|
||||
// This prevents breaking compatibility with older Mudlet versions
|
||||
if (isVersionAtLeast(qsl("4.20.0"))) {
|
||||
auto *deleteJob = new QKeychain::DeletePasswordJob(qsl("Mudlet profile"));
|
||||
deleteJob->setAutoDelete(true);
|
||||
deleteJob->setKey(characterName);
|
||||
deleteJob->setProperty("profile", profileName);
|
||||
deleteJob->setProperty("character", characterName);
|
||||
deleteJob->start();
|
||||
qDebug().nospace().noquote() << "mudlet::slot_passwordMigratedToSecureStorage() INFO - migrated character password for \"" << characterName << "\" in profile \"" << profileName << "\" and cleaned up legacy keychain storage.";
|
||||
} else {
|
||||
qDebug().nospace().noquote() << "mudlet::slot_passwordMigratedToSecureStorage() INFO - migrated character password for \"" << characterName << "\" in profile \"" << profileName << "\" (legacy keychain storage preserved for compatibility).";
|
||||
}
|
||||
}
|
||||
|
||||
mCharacterPasswordsToMigrate.removeAll(qMakePair(profileName, characterName));
|
||||
job->deleteLater();
|
||||
|
||||
if (mCharacterPasswordsToMigrate.isEmpty()) {
|
||||
emit signal_characterPasswordsMigrated();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -264,6 +264,8 @@ public:
|
|||
enums::controlsVisibility menuBarVisibility() const { return mMenuBarVisibility; }
|
||||
bool migratePasswordsToProfileStorage();
|
||||
bool migratePasswordsToSecureStorage();
|
||||
// Helper function to check if current version is >= specified version for backward compatibility
|
||||
bool isVersionAtLeast(const QString& minVersion);
|
||||
void onlyShowProfiles(const QStringList&);
|
||||
bool openWebPage(const QString&);
|
||||
|
||||
|
|
@ -526,6 +528,7 @@ signals:
|
|||
void signal_passwordMigratedToSecure(const QString&);
|
||||
void signal_passwordsMigratedToProfiles();
|
||||
void signal_passwordsMigratedToSecure();
|
||||
void signal_characterPasswordsMigrated();
|
||||
void signal_profileActivated(Host *, quint8);
|
||||
void signal_profileMapReloadRequested(QList<QString>);
|
||||
void signal_setToolBarIconSize(int);
|
||||
|
|
@ -689,6 +692,8 @@ private:
|
|||
QPointer<QLabel> mpLabelReplayTime;
|
||||
// a list of profiles currently being migrated to secure or profile storage
|
||||
QStringList mProfilePasswordsToMigrate;
|
||||
// a list of character passwords currently being migrated to secure storage
|
||||
QList<QPair<QString, QString>> mCharacterPasswordsToMigrate;
|
||||
QPointer<QShortcut> mpShortcutCloseProfile;
|
||||
QPointer<QShortcut> mpShortcutConnect;
|
||||
QPointer<QShortcut> mpShortcutDisconnect;
|
||||
|
|
|
|||
|
|
@ -637,6 +637,8 @@ SOURCES += \
|
|||
MudletInstanceCoordinator.cpp \
|
||||
MxpTag.cpp \
|
||||
ScriptUnit.cpp \
|
||||
SecureStringUtils.cpp \
|
||||
CredentialManager.cpp \
|
||||
ShortcutsManager.cpp \
|
||||
SingleLineTextEdit.cpp \
|
||||
T2DMap.cpp \
|
||||
|
|
@ -771,6 +773,8 @@ HEADERS += \
|
|||
pre_guard.h \
|
||||
post_guard.h \
|
||||
ScriptUnit.h \
|
||||
SecureStringUtils.h \
|
||||
CredentialManager.h \
|
||||
ShortcutsManager.h \
|
||||
SingleLineTextEdit.h \
|
||||
T2DMap.h \
|
||||
|
|
@ -1687,6 +1691,8 @@ OTHER_FILES += \
|
|||
../docker/Dockerfile \
|
||||
../test/CMakeLists.txt \
|
||||
../test/GUIConsoleTests.mpackage \
|
||||
../test/CredentialManagerTest.cpp \
|
||||
../test/SecureStringUtilsTest.cpp \
|
||||
../test/TEntityHandlerTest.cpp \
|
||||
../test/TEntityResolverTest.cpp \
|
||||
../test/TLinkStoreTest.cpp \
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ CMAKE_MINIMUM_REQUIRED(VERSION 3.25.1)
|
|||
|
||||
SET(CMAKE_AUTOMOC ON)
|
||||
SET(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||
SET(CMAKE_CXX_STANDARD 17)
|
||||
SET(CMAKE_CXX_STANDARD 20)
|
||||
SET(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
|
||||
|
|
@ -34,6 +34,29 @@ link_libraries(
|
|||
|
||||
set(CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/../cmake ${CMAKE_MODULE_PATH})
|
||||
|
||||
# Include the same keychain configuration as main project
|
||||
include(IncludeOptionalModule)
|
||||
include_optional_module(ENVIRONMENT_VARIABLE WITH_OWN_QTKEYCHAIN
|
||||
OPTION_VARIABLE USE_OWN_QTKEYCHAIN
|
||||
READABLE_NAME "own QtKeychain library")
|
||||
|
||||
if(NOT DEFINED USE_OWN_QTKEYCHAIN)
|
||||
# Check the parent project's setting
|
||||
get_directory_property(parent_USE_OWN_QTKEYCHAIN PARENT_DIRECTORY USE_OWN_QTKEYCHAIN)
|
||||
if(DEFINED parent_USE_OWN_QTKEYCHAIN)
|
||||
set(USE_OWN_QTKEYCHAIN ${parent_USE_OWN_QTKEYCHAIN})
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Configure QtKeychain linking
|
||||
if(USE_OWN_QTKEYCHAIN)
|
||||
# Link to the already built qt6keychain target from parent
|
||||
set(KEYCHAIN_LIBRARY qt6keychain)
|
||||
else()
|
||||
find_package(Qt6Keychain REQUIRED)
|
||||
set(KEYCHAIN_LIBRARY Qt6::Keychain)
|
||||
endif()
|
||||
|
||||
|
||||
include_directories("${CMAKE_CURRENT_SOURCE_DIR}/../src")
|
||||
|
||||
|
|
@ -76,3 +99,17 @@ find_package(Lua51 REQUIRED)
|
|||
target_link_libraries(
|
||||
TLuaInterfaceTest
|
||||
LUA51::LUA51)
|
||||
|
||||
add_executable(SecureStringUtilsTest SecureStringUtilsTest.cpp ../src/SecureStringUtils.cpp)
|
||||
target_link_libraries(SecureStringUtilsTest ${KEYCHAIN_LIBRARY})
|
||||
if(USE_OWN_QTKEYCHAIN)
|
||||
target_compile_definitions(SecureStringUtilsTest PRIVATE INCLUDE_OWN_QT6_KEYCHAIN QTKEYCHAIN_NO_EXPORT)
|
||||
endif()
|
||||
add_test(NAME SecureStringUtilsTest COMMAND SecureStringUtilsTest)
|
||||
|
||||
add_executable(CredentialManagerTest CredentialManagerTest.cpp ../src/CredentialManager.cpp ../src/SecureStringUtils.cpp)
|
||||
target_link_libraries(CredentialManagerTest ${KEYCHAIN_LIBRARY})
|
||||
if(USE_OWN_QTKEYCHAIN)
|
||||
target_compile_definitions(CredentialManagerTest PRIVATE INCLUDE_OWN_QT6_KEYCHAIN QTKEYCHAIN_NO_EXPORT)
|
||||
endif()
|
||||
add_test(NAME CredentialManagerTest COMMAND CredentialManagerTest)
|
||||
|
|
|
|||
242
test/CredentialManagerTest.cpp
Normal file
242
test/CredentialManagerTest.cpp
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/***************************************************************************
|
||||
* Copyright (C) 2025 by Mike Conley - mike.conley@stickmud.com *
|
||||
* *
|
||||
* 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. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program; if not, write to the *
|
||||
* Free Software Foundation, Inc., *
|
||||
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
||||
***************************************************************************/
|
||||
|
||||
#include <CredentialManager.h>
|
||||
#include <QtTest/QtTest>
|
||||
|
||||
class CredentialManagerTest : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void initTestCase();
|
||||
void testStoreAndRetrieve();
|
||||
void testProfileIsolation();
|
||||
void testKeyIsolation();
|
||||
void testEmptyPassword();
|
||||
void testRemovePassword();
|
||||
void testInputSanitization();
|
||||
void testPathTraversalPrevention();
|
||||
void testConcurrentAccess();
|
||||
void cleanupTestCase();
|
||||
};
|
||||
|
||||
void CredentialManagerTest::initTestCase()
|
||||
{
|
||||
// Set environment variable to indicate we're in test mode
|
||||
// This prevents keychain access that would require user password input
|
||||
qputenv("MUDLET_TEST_MODE", "1");
|
||||
}
|
||||
|
||||
void CredentialManagerTest::testStoreAndRetrieve()
|
||||
{
|
||||
QString profile = "TestProfile";
|
||||
QString key = "test_password";
|
||||
QString password = "secret123";
|
||||
|
||||
// Store password
|
||||
QVERIFY(CredentialManager::storeCredential(profile, key, password));
|
||||
|
||||
// Retrieve password
|
||||
QString retrieved = CredentialManager::retrieveCredential(profile, key);
|
||||
QCOMPARE(retrieved, password);
|
||||
}
|
||||
|
||||
void CredentialManagerTest::testProfileIsolation()
|
||||
{
|
||||
QString profile1 = "Profile1";
|
||||
QString profile2 = "Profile2";
|
||||
QString key = "shared_key";
|
||||
QString password1 = "password1";
|
||||
QString password2 = "password2";
|
||||
|
||||
// Store different passwords for different profiles
|
||||
QVERIFY(CredentialManager::storeCredential(profile1, key, password1));
|
||||
QVERIFY(CredentialManager::storeCredential(profile2, key, password2));
|
||||
|
||||
// Verify isolation
|
||||
QCOMPARE(CredentialManager::retrieveCredential(profile1, key), password1);
|
||||
QCOMPARE(CredentialManager::retrieveCredential(profile2, key), password2);
|
||||
}
|
||||
|
||||
void CredentialManagerTest::testKeyIsolation()
|
||||
{
|
||||
QString profile = "TestProfile";
|
||||
QString key1 = "proxy";
|
||||
QString key2 = "database";
|
||||
QString password1 = "proxy_pass";
|
||||
QString password2 = "db_pass";
|
||||
|
||||
// Store different passwords for different keys
|
||||
QVERIFY(CredentialManager::storeCredential(profile, key1, password1));
|
||||
QVERIFY(CredentialManager::storeCredential(profile, key2, password2));
|
||||
|
||||
// Verify isolation
|
||||
QCOMPARE(CredentialManager::retrieveCredential(profile, key1), password1);
|
||||
QCOMPARE(CredentialManager::retrieveCredential(profile, key2), password2);
|
||||
}
|
||||
|
||||
void CredentialManagerTest::testEmptyPassword()
|
||||
{
|
||||
QString profile = "TestProfile";
|
||||
QString key = "empty_test";
|
||||
|
||||
// Store empty password (should remove any existing password)
|
||||
QVERIFY(CredentialManager::storeCredential(profile, key, ""));
|
||||
|
||||
// Should return empty string
|
||||
QString retrieved = CredentialManager::retrieveCredential(profile, key);
|
||||
QVERIFY(retrieved.isEmpty());
|
||||
}
|
||||
|
||||
void CredentialManagerTest::testRemovePassword()
|
||||
{
|
||||
QString profile = "TestProfile";
|
||||
QString key = "remove_test";
|
||||
QString password = "temp_password";
|
||||
|
||||
// Store password
|
||||
QVERIFY(CredentialManager::storeCredential(profile, key, password));
|
||||
QCOMPARE(CredentialManager::retrieveCredential(profile, key), password);
|
||||
|
||||
// Remove password
|
||||
QVERIFY(CredentialManager::removeCredential(profile, key));
|
||||
|
||||
// Should return empty string after removal
|
||||
QString retrieved = CredentialManager::retrieveCredential(profile, key);
|
||||
QVERIFY(retrieved.isEmpty());
|
||||
}
|
||||
|
||||
void CredentialManagerTest::testInputSanitization()
|
||||
{
|
||||
QString profile = "SanitizationTestProfile";
|
||||
QString normalKey = "normal_key";
|
||||
QString password = "test_password";
|
||||
|
||||
// Test normal key works
|
||||
QVERIFY(CredentialManager::storeCredential(profile, normalKey, password));
|
||||
QString retrieved = CredentialManager::retrieveCredential(profile, normalKey);
|
||||
QCOMPARE(retrieved, password);
|
||||
|
||||
// Test with special characters in key names - should be rejected
|
||||
QString specialKey = "key/with\\special:chars<>|?*";
|
||||
bool specialStored = CredentialManager::storeCredential(profile, specialKey, password);
|
||||
QVERIFY(!specialStored); // Should fail due to invalid characters
|
||||
|
||||
// Test with Unicode characters in keys
|
||||
QString unicodeKey = "key_with_unicode_αβγ_δεζ";
|
||||
bool unicodeStored = CredentialManager::storeCredential(profile, unicodeKey, password);
|
||||
|
||||
if (unicodeStored) {
|
||||
QString unicodeRetrieved = CredentialManager::retrieveCredential(profile, unicodeKey);
|
||||
QCOMPARE(unicodeRetrieved, password);
|
||||
CredentialManager::removeCredential(profile, unicodeKey);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
CredentialManager::removeCredential(profile, normalKey);
|
||||
}
|
||||
|
||||
void CredentialManagerTest::testPathTraversalPrevention()
|
||||
{
|
||||
QString profile = "PathTraversalTestProfile";
|
||||
QString password = "test_password";
|
||||
|
||||
// Test various path traversal attempts in profile names
|
||||
QStringList maliciousProfiles = {
|
||||
"../../../etc/passwd",
|
||||
"..\\..\\windows\\system32",
|
||||
"/etc/shadow",
|
||||
"C:\\Windows\\System32\\config\\SAM",
|
||||
"profile/../../../sensitive",
|
||||
"profile\\..\\..\\sensitive"
|
||||
};
|
||||
|
||||
for (const QString& maliciousProfile : maliciousProfiles) {
|
||||
QString key = "test_key";
|
||||
|
||||
// These should be rejected or sanitized by the security measures
|
||||
bool stored = CredentialManager::storeCredential(maliciousProfile, key, password);
|
||||
|
||||
// Even if storage fails, this demonstrates that path traversal is prevented
|
||||
if (stored) {
|
||||
QString retrieved = CredentialManager::retrieveCredential(maliciousProfile, key);
|
||||
// If storage succeeded, retrieval should work with same profile name
|
||||
QCOMPARE(retrieved, password);
|
||||
|
||||
// Cleanup
|
||||
CredentialManager::removeCredential(maliciousProfile, key);
|
||||
}
|
||||
// If storage failed, that's also a valid security response
|
||||
}
|
||||
|
||||
// Test that a normal profile still works
|
||||
QString normalProfile = "NormalProfile";
|
||||
QVERIFY(CredentialManager::storeCredential(normalProfile, "test_key", password));
|
||||
QString normalRetrieved = CredentialManager::retrieveCredential(normalProfile, "test_key");
|
||||
QCOMPARE(normalRetrieved, password);
|
||||
CredentialManager::removeCredential(normalProfile, "test_key");
|
||||
}
|
||||
|
||||
void CredentialManagerTest::testConcurrentAccess()
|
||||
{
|
||||
QString profile = "ConcurrentTestProfile";
|
||||
QString key = "concurrent_key";
|
||||
QString password = "concurrent_password";
|
||||
|
||||
// Store initial credential
|
||||
QVERIFY(CredentialManager::storeCredential(profile, key, password));
|
||||
|
||||
// Simulate concurrent operations (basic test)
|
||||
// In a real concurrent test, we'd use threads, but for simplicity:
|
||||
|
||||
// Multiple rapid store/retrieve operations
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
QString testPassword = QString("password_%1").arg(i);
|
||||
QVERIFY(CredentialManager::storeCredential(profile, key, testPassword));
|
||||
QString retrieved = CredentialManager::retrieveCredential(profile, key);
|
||||
QCOMPARE(retrieved, testPassword);
|
||||
}
|
||||
|
||||
// Verify final state
|
||||
QString finalPassword = "final_password";
|
||||
QVERIFY(CredentialManager::storeCredential(profile, key, finalPassword));
|
||||
QString finalRetrieved = CredentialManager::retrieveCredential(profile, key);
|
||||
QCOMPARE(finalRetrieved, finalPassword);
|
||||
|
||||
// Cleanup
|
||||
CredentialManager::removeCredential(profile, key);
|
||||
}
|
||||
|
||||
void CredentialManagerTest::cleanupTestCase()
|
||||
{
|
||||
// Clean up test passwords
|
||||
CredentialManager::removeCredential("TestProfile", "test_password");
|
||||
CredentialManager::removeCredential("Profile1", "shared_key");
|
||||
CredentialManager::removeCredential("Profile2", "shared_key");
|
||||
CredentialManager::removeCredential("TestProfile", "proxy");
|
||||
CredentialManager::removeCredential("TestProfile", "database");
|
||||
CredentialManager::removeCredential("TestProfile", "empty_test");
|
||||
CredentialManager::removeCredential("TestProfile", "remove_test");
|
||||
CredentialManager::removeCredential("SanitizationTestProfile", "normal_key");
|
||||
CredentialManager::removeCredential("PathTraversalTestProfile", "test_key");
|
||||
CredentialManager::removeCredential("ConcurrentTestProfile", "concurrent_key");
|
||||
}
|
||||
|
||||
#include "CredentialManagerTest.moc"
|
||||
QTEST_MAIN(CredentialManagerTest)
|
||||
404
test/SecureStringUtilsTest.cpp
Normal file
404
test/SecureStringUtilsTest.cpp
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
/***************************************************************************
|
||||
* Copyright (C) 2025 by Mike Conley - mike.conley@stickmud.com *
|
||||
* *
|
||||
* 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. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program; if not, write to the *
|
||||
* Free Software Foundation, Inc., *
|
||||
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
||||
***************************************************************************/
|
||||
|
||||
#include <SecureStringUtils.h>
|
||||
#include <QtTest/QtTest>
|
||||
#include <QVersionNumber>
|
||||
|
||||
class SecureStringUtilsTest : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void initTestCase();
|
||||
void testProfileBasedEncryption();
|
||||
void testDifferentProfilesUseDifferentKeys();
|
||||
void testEncryptedFormatDetection();
|
||||
void testEmptyStrings();
|
||||
void testNonDeterministicEncryption();
|
||||
void testSpecialCharacters();
|
||||
void testSecureMemoryClearing();
|
||||
void testProfileKeyPersistence();
|
||||
void testPortableModeFileStorage();
|
||||
void testInvalidInputHandling();
|
||||
void testLargeDataEncryption();
|
||||
void testCorruptedDataDecryption();
|
||||
void testVersionCompatibility();
|
||||
void testXMLImportProxyPasswordLogic();
|
||||
void testConveniencePasswordMethods();
|
||||
void cleanupTestCase();
|
||||
};
|
||||
|
||||
void SecureStringUtilsTest::initTestCase()
|
||||
{
|
||||
}
|
||||
|
||||
void SecureStringUtilsTest::testProfileBasedEncryption()
|
||||
{
|
||||
QString plaintext = "mypassword";
|
||||
QString profileName = "TestProfile";
|
||||
|
||||
QString encrypted = SecureStringUtils::encryptStringForProfile(plaintext, profileName);
|
||||
QVERIFY(!encrypted.isEmpty());
|
||||
QVERIFY(encrypted != plaintext);
|
||||
|
||||
QString decrypted = SecureStringUtils::decryptStringForProfile(encrypted, profileName);
|
||||
QCOMPARE(decrypted, plaintext);
|
||||
}
|
||||
|
||||
void SecureStringUtilsTest::testDifferentProfilesUseDifferentKeys()
|
||||
{
|
||||
QString plaintext = "samepassword";
|
||||
QString profile1 = "Profile1";
|
||||
QString profile2 = "Profile2";
|
||||
|
||||
QString encrypted1 = SecureStringUtils::encryptStringForProfile(plaintext, profile1);
|
||||
QString encrypted2 = SecureStringUtils::encryptStringForProfile(plaintext, profile2);
|
||||
|
||||
// Should be different due to different profile keys
|
||||
QVERIFY(encrypted1 != encrypted2);
|
||||
|
||||
// Each should decrypt correctly with its own profile
|
||||
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted1, profile1), plaintext);
|
||||
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted2, profile2), plaintext);
|
||||
|
||||
// Cross-profile decryption should fail
|
||||
QVERIFY(SecureStringUtils::decryptStringForProfile(encrypted1, profile2) != plaintext);
|
||||
QVERIFY(SecureStringUtils::decryptStringForProfile(encrypted2, profile1) != plaintext);
|
||||
}
|
||||
|
||||
void SecureStringUtilsTest::testEncryptedFormatDetection()
|
||||
{
|
||||
// Test plaintext passwords (should NOT be detected as encrypted)
|
||||
QVERIFY(!SecureStringUtils::isEncryptedFormat("mypassword"));
|
||||
QVERIFY(!SecureStringUtils::isEncryptedFormat("secret123!@#"));
|
||||
QVERIFY(!SecureStringUtils::isEncryptedFormat(""));
|
||||
|
||||
// Test actual encrypted passwords (should be detected as encrypted)
|
||||
QString plaintext = "testpassword";
|
||||
QString encrypted = SecureStringUtils::encryptStringForProfile(plaintext, "TestProfile");
|
||||
QVERIFY(SecureStringUtils::isEncryptedFormat(encrypted));
|
||||
|
||||
// Test invalid formats
|
||||
QVERIFY(!SecureStringUtils::isEncryptedFormat("not-base64!"));
|
||||
QVERIFY(!SecureStringUtils::isEncryptedFormat("invalid=base64="));
|
||||
}
|
||||
|
||||
void SecureStringUtilsTest::testEmptyStrings()
|
||||
{
|
||||
// Test empty string handling
|
||||
QCOMPARE(SecureStringUtils::encryptStringForProfile("", "Profile"), QString());
|
||||
QCOMPARE(SecureStringUtils::encryptStringForProfile("password", ""), QString());
|
||||
QCOMPARE(SecureStringUtils::decryptStringForProfile("", "Profile"), QString());
|
||||
QCOMPARE(SecureStringUtils::decryptStringForProfile("encrypted", ""), QString());
|
||||
QVERIFY(!SecureStringUtils::isEncryptedFormat(""));
|
||||
}
|
||||
|
||||
void SecureStringUtilsTest::testNonDeterministicEncryption()
|
||||
{
|
||||
// Same plaintext should produce DIFFERENT encrypted results each time (due to random nonces)
|
||||
QString plaintext = "consistent_password";
|
||||
QString profile = "TestProfile";
|
||||
QString encrypted1 = SecureStringUtils::encryptStringForProfile(plaintext, profile);
|
||||
QString encrypted2 = SecureStringUtils::encryptStringForProfile(plaintext, profile);
|
||||
|
||||
// Should be different due to random nonces
|
||||
QVERIFY(encrypted1 != encrypted2);
|
||||
|
||||
// But both should decrypt to the same plaintext
|
||||
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted1, profile), plaintext);
|
||||
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted2, profile), plaintext);
|
||||
}
|
||||
|
||||
void SecureStringUtilsTest::testSpecialCharacters()
|
||||
{
|
||||
// Test passwords with special characters
|
||||
QString specialPassword = "pássw0rd!@#$%^&*()";
|
||||
QString profile = "TestProfile";
|
||||
QString encrypted = SecureStringUtils::encryptStringForProfile(specialPassword, profile);
|
||||
QString decrypted = SecureStringUtils::decryptStringForProfile(encrypted, profile);
|
||||
|
||||
QCOMPARE(decrypted, specialPassword);
|
||||
QVERIFY(SecureStringUtils::isEncryptedFormat(encrypted));
|
||||
}
|
||||
|
||||
void SecureStringUtilsTest::testSecureMemoryClearing()
|
||||
{
|
||||
QString testString = "sensitive_data";
|
||||
QString originalContent = testString;
|
||||
|
||||
// Clear the string
|
||||
SecureStringUtils::secureStringClear(testString);
|
||||
|
||||
// String should be empty after clearing
|
||||
QVERIFY(testString.isEmpty());
|
||||
QVERIFY(testString != originalContent);
|
||||
|
||||
// Test QByteArray clearing
|
||||
QByteArray testArray = "sensitive_bytes";
|
||||
QByteArray originalArray = testArray;
|
||||
|
||||
SecureStringUtils::secureByteArrayClear(testArray);
|
||||
QVERIFY(testArray.isEmpty());
|
||||
QVERIFY(testArray != originalArray);
|
||||
}
|
||||
|
||||
void SecureStringUtilsTest::testProfileKeyPersistence()
|
||||
{
|
||||
// Test that the same profile uses consistent keys
|
||||
QString password = "testpassword";
|
||||
QString profile = "PersistentProfile";
|
||||
|
||||
QString encrypted1 = SecureStringUtils::encryptStringForProfile(password, profile);
|
||||
QString encrypted2 = SecureStringUtils::encryptStringForProfile(password, profile);
|
||||
|
||||
// Both should decrypt correctly (proving key consistency)
|
||||
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted1, profile), password);
|
||||
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted2, profile), password);
|
||||
}
|
||||
|
||||
void SecureStringUtilsTest::testPortableModeFileStorage()
|
||||
{
|
||||
// Test that profile-specific encryption/decryption works consistently
|
||||
// This exercises the file-based key storage in portable mode
|
||||
QString profileName = "PortableTestProfile";
|
||||
QString plaintext = "portable_test_password";
|
||||
|
||||
// First encryption - this will trigger key generation and file storage
|
||||
QString encrypted1 = SecureStringUtils::encryptStringForProfile(plaintext, profileName);
|
||||
QVERIFY(SecureStringUtils::isEncryptedFormat(encrypted1));
|
||||
|
||||
// Second encryption with same profile - should use same key from file
|
||||
QString encrypted2 = SecureStringUtils::encryptStringForProfile(plaintext, profileName);
|
||||
QVERIFY(SecureStringUtils::isEncryptedFormat(encrypted2));
|
||||
|
||||
// Both should decrypt correctly
|
||||
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted1, profileName), plaintext);
|
||||
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted2, profileName), plaintext);
|
||||
|
||||
// Test that different profiles use different keys
|
||||
QString otherProfile = "AnotherPortableProfile";
|
||||
QString encrypted3 = SecureStringUtils::encryptStringForProfile(plaintext, otherProfile);
|
||||
QVERIFY(SecureStringUtils::isEncryptedFormat(encrypted3));
|
||||
|
||||
// Should decrypt correctly with its own profile
|
||||
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted3, otherProfile), plaintext);
|
||||
|
||||
// Cross-profile decryption should fail (different keys)
|
||||
QString crossDecrypt = SecureStringUtils::decryptStringForProfile(encrypted3, profileName);
|
||||
QVERIFY(crossDecrypt.isEmpty() || crossDecrypt != plaintext);
|
||||
}
|
||||
|
||||
void SecureStringUtilsTest::testInvalidInputHandling()
|
||||
{
|
||||
// Test null/empty profile names
|
||||
QString password = "testpassword";
|
||||
QString encrypted1 = SecureStringUtils::encryptStringForProfile(password, "");
|
||||
QVERIFY(encrypted1.isEmpty()); // Should return empty for empty profile
|
||||
|
||||
QString encrypted2 = SecureStringUtils::encryptStringForProfile(password, QString());
|
||||
QVERIFY(encrypted2.isEmpty()); // Should return empty for null profile
|
||||
|
||||
// Test with empty password
|
||||
QString validProfile = "ValidProfile";
|
||||
QString encrypted3 = SecureStringUtils::encryptStringForProfile("", validProfile);
|
||||
QVERIFY(encrypted3.isEmpty()); // Should return empty for empty password
|
||||
|
||||
// Test decryption with mismatched profiles
|
||||
QString profile1 = "Profile1";
|
||||
QString profile2 = "Profile2";
|
||||
QString encrypted = SecureStringUtils::encryptStringForProfile(password, profile1);
|
||||
|
||||
QString decrypted = SecureStringUtils::decryptStringForProfile(encrypted, profile2);
|
||||
QVERIFY(decrypted.isEmpty() || decrypted != password); // Should fail or return wrong data
|
||||
}
|
||||
|
||||
void SecureStringUtilsTest::testLargeDataEncryption()
|
||||
{
|
||||
// Test with larger strings to ensure robustness
|
||||
QString largeString = QString("A").repeated(10000); // 10KB string
|
||||
QString profile = "LargeDataProfile";
|
||||
|
||||
QString encrypted = SecureStringUtils::encryptStringForProfile(largeString, profile);
|
||||
QVERIFY(!encrypted.isEmpty());
|
||||
QVERIFY(SecureStringUtils::isEncryptedFormat(encrypted));
|
||||
|
||||
QString decrypted = SecureStringUtils::decryptStringForProfile(encrypted, profile);
|
||||
QCOMPARE(decrypted, largeString);
|
||||
}
|
||||
|
||||
void SecureStringUtilsTest::testCorruptedDataDecryption()
|
||||
{
|
||||
QString password = "testpassword";
|
||||
QString profile = "CorruptionTestProfile";
|
||||
|
||||
QString encrypted = SecureStringUtils::encryptStringForProfile(password, profile);
|
||||
QVERIFY(SecureStringUtils::isEncryptedFormat(encrypted));
|
||||
|
||||
// Test with completely invalid format
|
||||
QString invalid1 = "notencrypted";
|
||||
QVERIFY(!SecureStringUtils::isEncryptedFormat(invalid1));
|
||||
QString decrypted1 = SecureStringUtils::decryptStringForProfile(invalid1, profile);
|
||||
QVERIFY(decrypted1.isEmpty()); // Should return empty for invalid format
|
||||
|
||||
// Test with corrupted encrypted data (modify a character in the middle)
|
||||
if (encrypted.length() > 10) {
|
||||
QString corrupted = encrypted;
|
||||
corrupted[encrypted.length() / 2] = 'X'; // Corrupt middle character
|
||||
QString decrypted2 = SecureStringUtils::decryptStringForProfile(corrupted, profile);
|
||||
QVERIFY(decrypted2.isEmpty() || decrypted2 != password); // Should fail due to corruption
|
||||
}
|
||||
|
||||
// Test with truncated encrypted data
|
||||
if (encrypted.length() > 5) {
|
||||
QString truncated = encrypted.left(encrypted.length() - 5);
|
||||
QString decrypted3 = SecureStringUtils::decryptStringForProfile(truncated, profile);
|
||||
QVERIFY(decrypted3.isEmpty() || decrypted3 != password); // Should fail due to truncation
|
||||
}
|
||||
}
|
||||
|
||||
void SecureStringUtilsTest::testVersionCompatibility()
|
||||
{
|
||||
// Test that version-based compatibility logic works correctly
|
||||
|
||||
// Test version comparison for major version differences
|
||||
QVERIFY(4 > 3); // Version 4.x should be newer than 3.x
|
||||
QVERIFY(5 > 4); // Version 5.x should be newer than 4.x
|
||||
|
||||
// Test version comparison for minor version differences within major version 4
|
||||
int majorVersion = 4;
|
||||
|
||||
// Test cases for version 4.x.x
|
||||
struct {
|
||||
int minorVersion;
|
||||
bool shouldUseSecureStorage;
|
||||
QString description;
|
||||
} testCases[] = {
|
||||
{19, false, "Version 4.19.x should use legacy mode"},
|
||||
{20, true, "Version 4.20.x should use secure storage"},
|
||||
{21, true, "Version 4.21.x should use secure storage"},
|
||||
{50, true, "Version 4.50.x should use secure storage"}
|
||||
};
|
||||
|
||||
for (const auto& testCase : testCases) {
|
||||
// Simulate the version check logic from XMLimport
|
||||
bool useSecureStorage = (majorVersion > 4) || (majorVersion == 4 && testCase.minorVersion >= 20);
|
||||
|
||||
if (useSecureStorage != testCase.shouldUseSecureStorage) {
|
||||
QFAIL(qPrintable(QString("Version compatibility test failed for %1: expected %2, got %3")
|
||||
.arg(testCase.description)
|
||||
.arg(testCase.shouldUseSecureStorage ? "true" : "false")
|
||||
.arg(useSecureStorage ? "true" : "false")));
|
||||
}
|
||||
QCOMPARE(useSecureStorage, testCase.shouldUseSecureStorage);
|
||||
}
|
||||
|
||||
// Test major version transitions
|
||||
QVERIFY((5 > 4) || (5 == 4 && 0 >= 20)); // Version 5.0.x should use secure storage
|
||||
QVERIFY((6 > 4) || (6 == 4 && 0 >= 20)); // Version 6.0.x should use secure storage
|
||||
|
||||
qDebug() << "Version compatibility tests passed";
|
||||
}
|
||||
|
||||
void SecureStringUtilsTest::testXMLImportProxyPasswordLogic()
|
||||
{
|
||||
// Test that XMLimport now uses application version, not profile version
|
||||
// This simulates the fixed logic in XMLimport.cpp
|
||||
|
||||
// Simulate different APP_VERSION values
|
||||
struct TestCase {
|
||||
QString appVersion;
|
||||
bool shouldUseSecureStorage;
|
||||
QString description;
|
||||
};
|
||||
|
||||
const QList<TestCase> testCases = {
|
||||
{"4.19.0", false, "App version 4.19.0 (before secure storage)"},
|
||||
{"4.20.0", true, "App version 4.20.0 (secure storage introduced)"},
|
||||
{"4.21.0", true, "App version 4.21.0 (after secure storage)"},
|
||||
{"5.0.0", true, "App version 5.0.0 (major version after secure storage)"},
|
||||
{"3.15.0", false, "App version 3.15.0 (old version)"}
|
||||
};
|
||||
|
||||
for (const auto& testCase : testCases) {
|
||||
// Simulate the new XMLimport logic
|
||||
const QVersionNumber appVersion = QVersionNumber::fromString(testCase.appVersion);
|
||||
const QVersionNumber secureStorageVersion = QVersionNumber(4, 20, 0);
|
||||
const bool useSecureStorage = appVersion >= secureStorageVersion;
|
||||
|
||||
QCOMPARE(useSecureStorage, testCase.shouldUseSecureStorage);
|
||||
|
||||
if (useSecureStorage != testCase.shouldUseSecureStorage) {
|
||||
QFAIL(qPrintable(QString("XMLimport proxy password test failed for %1: expected %2, got %3")
|
||||
.arg(testCase.description)
|
||||
.arg(testCase.shouldUseSecureStorage ? "true" : "false")
|
||||
.arg(useSecureStorage ? "true" : "false")));
|
||||
}
|
||||
}
|
||||
|
||||
qDebug() << "XMLimport proxy password logic tests passed";
|
||||
}
|
||||
|
||||
void SecureStringUtilsTest::testConveniencePasswordMethods()
|
||||
{
|
||||
QString testProfile = "ConvenienceTestProfile";
|
||||
QString testKey = "test_password";
|
||||
QString testPassword = "MyConvenienceTestPassword123!";
|
||||
|
||||
// Ensure clean state
|
||||
SecureStringUtils::removePassword(testProfile, testKey);
|
||||
QVERIFY(!SecureStringUtils::hasPassword(testProfile, testKey));
|
||||
|
||||
// Test storing password
|
||||
bool stored = SecureStringUtils::storePassword(testProfile, testKey, testPassword);
|
||||
QVERIFY(stored);
|
||||
|
||||
// Test password exists
|
||||
bool exists = SecureStringUtils::hasPassword(testProfile, testKey);
|
||||
QVERIFY(exists);
|
||||
|
||||
// Test retrieving password
|
||||
QString retrieved = SecureStringUtils::retrievePassword(testProfile, testKey);
|
||||
QCOMPARE(retrieved, testPassword);
|
||||
|
||||
// Test removing password
|
||||
bool removed = SecureStringUtils::removePassword(testProfile, testKey);
|
||||
QVERIFY(removed);
|
||||
|
||||
// Test password no longer exists
|
||||
bool existsAfterRemoval = SecureStringUtils::hasPassword(testProfile, testKey);
|
||||
QVERIFY(!existsAfterRemoval);
|
||||
|
||||
// Test retrieving non-existent password
|
||||
QString nonExistent = SecureStringUtils::retrievePassword(testProfile, testKey);
|
||||
QVERIFY(nonExistent.isEmpty());
|
||||
|
||||
// Test invalid inputs
|
||||
QVERIFY(!SecureStringUtils::storePassword("", testKey, testPassword));
|
||||
QVERIFY(!SecureStringUtils::storePassword(testProfile, "", testPassword));
|
||||
QVERIFY(!SecureStringUtils::storePassword(testProfile, "invalid/key", testPassword));
|
||||
|
||||
qDebug() << "Convenience password methods tests passed";
|
||||
}
|
||||
|
||||
void SecureStringUtilsTest::cleanupTestCase()
|
||||
{
|
||||
}
|
||||
|
||||
#include "SecureStringUtilsTest.moc"
|
||||
QTEST_MAIN(SecureStringUtilsTest)
|
||||
Loading…
Add table
Add a link
Reference in a new issue