mudlet/test/CredentialManagerTest.cpp
Mike Conley 79ab7be4e2
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>
2025-08-17 12:44:38 +02:00

242 lines
9.4 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/***************************************************************************
* 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)