mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
#### 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>
242 lines
9.4 KiB
C++
242 lines
9.4 KiB
C++
/***************************************************************************
|
||
* 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)
|