mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
#### Brief overview of PR changes/additions
Adds client-side support for the GMCP `Char.Login` v2 sign-in flow. When
a game offers it, Mudlet automates sign-in *around the game's own login
screen* — it renders no sign-in UI of its own:
- Hands off to the game's interactive screen with an empty
`Char.Login.Credentials {}` when nothing is stored, so the player picks
a provider (Google, Discord, GitHub, the game's own account, …) as text
on the game's own screen.
- Opens the sign-in URL the server pushes (`Char.Login.URL`) in the
system browser — but only after the player has acted on this connection,
never unprompted.
- Autofills a stored character name + password when the profile has
them.
- Persists and replays the server's reconnect token (`Char.Login.Token`
/ `Char.Login.Reconnect`) for instant, password-less reconnects, with a
"forget saved sign-in" control in Preferences → Connection.
- Resumes the *remembered* provider's browser sign-in without a menu
when a saved token has expired or been revoked (`Char.Login.Credentials
{account, provider}`), falling back to the interactive hand-off only
when no provider is remembered.
- Handles token rotation and multiple devices safely: overwrites the
saved token when the server rotates it, and if another running Mudlet
instance sharing the profile's keychain rotates the token mid-flight,
replays the fresh token instead of discarding it.
- For a game that is its own OpenID Provider over TLS, optionally runs
the client-driven PKCE flow end to end (`Char.Login.AuthCode`).
#### Motivation for adding to Mudlet
Modern games are moving to browser-based single sign-on; this lets
Mudlet players use those accounts directly, and reconnect without
re-entering anything — while classic character-name/password logins keep
working unchanged.
#### Other info (issues closed, discussion etc)
- Reworks the approach to resolve @vadi2's UX feedback
(https://github.com/Mudlet/Mudlet/pull/9373#issuecomment-4865669499):
there is **no client pop-up and no in-client chooser**. The game owns
the sign-in screen and offers the choice as text; Mudlet only automates
the mechanical hooks around it (autofill, open URL, save token, replay
token).
- Supersedes and closes #9373.
- Implements the revised v2 draft spec, inspired by #9354.
- Covered by new tests: `OAuthClientFlowTest` (OIDC discovery, PKCE,
loopback capture) and the `GMCPCharLoginTest` functional suite driving a
GMCP server stub through the full client flow.
- Try it out on StickMUD.
https://github.com/user-attachments/assets/566947f6-4f43-4bff-b98c-9328b7a40a2d
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
420 lines
18 KiB
C++
420 lines
18 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 <SecureStringUtils.h>
|
|
#include <QtTest/QtTest>
|
|
#include <QVersionNumber>
|
|
#include <string>
|
|
|
|
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);
|
|
|
|
// Test std::string clearing
|
|
std::string testStdString = "sensitive_std_data";
|
|
std::string originalStdString = testStdString;
|
|
|
|
SecureStringUtils::secureStdStringClear(testStdString);
|
|
QVERIFY(testStdString.empty());
|
|
QVERIFY(testStdString != originalStdString);
|
|
}
|
|
|
|
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 (corrupt the raw binary data, not Base64)
|
|
if (encrypted.length() > 10) {
|
|
// Decode to binary, corrupt a byte, re-encode to Base64
|
|
QByteArray binaryData = QByteArray::fromBase64(encrypted.toLatin1());
|
|
if (binaryData.size() > 10) {
|
|
// Corrupt a byte in the middle of the binary data
|
|
int corruptIndex = binaryData.size() / 2;
|
|
char originalByte = binaryData[corruptIndex];
|
|
binaryData[corruptIndex] = static_cast<char>(originalByte ^ 0xFF); // Flip all bits
|
|
QString corrupted = QString::fromLatin1(binaryData.toBase64());
|
|
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)
|