mirror of
https://github.com/brazilofmux/tinymux
synced 2026-08-13 00:23:11 -04:00
Harden credential storage across console and Win32 GUI clients
Console (Unix): worlds.txt saved with mode 0600, warns on load if permissions are loose. Password zeroed from memory via secure_zero() after Hydra authentication succeeds. Win32 GUI: Hydra passwords moved from plaintext worlds.json to Windows Credential Manager (CredWriteW/CredReadW). Transparent migration from existing JSON on first load; passwords stripped from JSON on next save. Username and non-secret fields remain in JSON. iOS (Keychain) and Android (EncryptedSharedPreferences) were already using platform credential stores — no changes needed. Web client localStorage exposure deferred as lower priority (different threat model, not the primary deployment target). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
037922e3ff
commit
c1e63deee3
10 changed files with 189 additions and 18 deletions
|
|
@ -14,9 +14,9 @@ Last refreshed: 2026-04-05.
|
|||
|---|---|---|
|
||||
| [Core Server (`mux/src/`)](mux/src/ISSUES.md) | 1 | Windows console signal-handler TODO in `CPlatform::RegisterSignalHandler`. |
|
||||
| [Engine Module](mux/modules/engine/ISSUES.md) | 2 | `alloc_lbuf`/`free_lbuf` RAII migration ~70% complete (~90 complex sites remain); dynamic-cargs `ulambda` JIT support. |
|
||||
| [Hydra Clients (aggregate)](client/ISSUES.md) | 2 | GMCP handled as raw JSON only; cross-client plaintext credential storage. |
|
||||
| [Hydra Clients (aggregate)](client/ISSUES.md) | 1 | GMCP handled as raw JSON only (structured hooks deferred). |
|
||||
| [Web Client](client/web/ISSUES.md) | 2 | localStorage credentials; no browser-level regression harness. |
|
||||
| [Win32 GUI Client](client/win32gui/ISSUES.md) | 2 | No Linux-side build validation for the VS target; plaintext credentials in world storage. |
|
||||
| [Win32 GUI Client](client/win32gui/ISSUES.md) | 1 | No Linux-side build validation for the VS target. |
|
||||
| [Test Infrastructure](testcases/ISSUES.md) | 6 | SHA1→semantic migration still in progress; edge-case coverage gaps; single-test-per-function norm; no auto-discovery; no parallel/isolation; no orphaned-object cleanup. |
|
||||
|
||||
## Fully Closed Trackers (history preserved)
|
||||
|
|
|
|||
|
|
@ -82,12 +82,12 @@
|
|||
|
||||
## Cross-Client Issues
|
||||
|
||||
### Credential storage in plaintext
|
||||
### ~~Credential storage in plaintext~~ FIXED
|
||||
|
||||
- **Files:** `console/src/hydra_connection.h:96-97`, `console/src/world.h:15-20`
|
||||
- **Issue:** Passwords stored as `std::string` in memory without protection. `worlds.txt` persists credentials in plaintext on disk. No attempt to wipe credentials from memory after use.
|
||||
- **Impact:** Security risk if process memory is dumped or disk is accessed. Applies to Console client; other clients may have similar patterns.
|
||||
- **Fix:** Use secure string wrappers that zero memory on destruction; encrypt credentials on disk.
|
||||
- **Console/Win32 Console:** `worlds.txt` now saved with mode 0600 on Unix; load warns if permissions are loose. `secure_zero()` wipes the password from memory after Hydra authentication succeeds.
|
||||
- **Win32 GUI:** Hydra passwords moved from plaintext `worlds.json` to Windows Credential Manager (`CredWriteW`/`CredReadW`). Transparent migration from existing JSON on first load; passwords stripped from JSON on next save.
|
||||
- **iOS:** Already used Apple Keychain (`SecItem*` APIs) — no change needed.
|
||||
- **Android:** Already used `EncryptedSharedPreferences` with AES-256-GCM — no change needed.
|
||||
|
||||
### ~~Spawn config regex errors silently discarded~~ FIXED
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
#include "hydra_connection.h"
|
||||
#include "connection.h" // for IOCP_KEY_HYDRA
|
||||
#include "secure_util.h"
|
||||
|
||||
#include "hydra.grpc.pb.h"
|
||||
#include <grpcpp/grpcpp.h>
|
||||
|
|
@ -88,6 +89,7 @@ bool HydraConnection::connect() {
|
|||
return false;
|
||||
}
|
||||
sessionId_ = resp.session_id();
|
||||
secure_zero(password_);
|
||||
}
|
||||
|
||||
{
|
||||
|
|
|
|||
22
client/console/src/secure_util.h
Normal file
22
client/console/src/secure_util.h
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
#ifndef SECURE_UTIL_H
|
||||
#define SECURE_UTIL_H
|
||||
|
||||
#include <string>
|
||||
|
||||
// Overwrite a string's data buffer with zeros in a way that the compiler
|
||||
// cannot optimise away, then clear the string.
|
||||
inline void secure_zero(std::string& s) {
|
||||
if (!s.empty()) {
|
||||
#if defined(_WIN32)
|
||||
SecureZeroMemory(s.data(), s.size());
|
||||
#elif defined(__GLIBC__) || defined(__FreeBSD__)
|
||||
explicit_bzero(s.data(), s.size());
|
||||
#else
|
||||
volatile char *p = s.data();
|
||||
for (size_t i = 0; i < s.size(); ++i) p[i] = 0;
|
||||
#endif
|
||||
s.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#endif // SECURE_UTIL_H
|
||||
|
|
@ -4,6 +4,10 @@
|
|||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#ifndef _WIN32
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
const World* WorldDB::find(const std::string& name) const {
|
||||
auto it = worlds_.find(name);
|
||||
|
|
@ -32,6 +36,15 @@ std::vector<std::string> WorldDB::names() const {
|
|||
bool WorldDB::load(const std::string& path) {
|
||||
std::ifstream f(path);
|
||||
if (!f) return false;
|
||||
|
||||
#ifndef _WIN32
|
||||
struct stat st;
|
||||
if (stat(path.c_str(), &st) == 0 && (st.st_mode & 077) != 0) {
|
||||
std::cerr << "WARNING: worlds.txt has insecure permissions."
|
||||
<< " Run: chmod 600 worlds.txt" << std::endl;
|
||||
}
|
||||
#endif
|
||||
|
||||
std::string line;
|
||||
while (std::getline(f, line)) {
|
||||
if (line.empty() || line[0] == '#') continue;
|
||||
|
|
@ -87,5 +100,10 @@ bool WorldDB::save(const std::string& path) const {
|
|||
f << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
chmod(path.c_str(), 0600);
|
||||
#endif
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,12 +32,14 @@ Updated: 2026-03-29
|
|||
|
||||
## Open
|
||||
|
||||
- **Saved world passwords live in browser localStorage**
|
||||
- **Saved world passwords live in browser localStorage** *(deferred)*
|
||||
The settings store persists world passwords directly in localStorage.
|
||||
That is convenient, but it means any script running in the origin can read
|
||||
them. If this client is meant to be production-usable on a shared/public
|
||||
deployment, credentials should move behind a stronger model than raw browser
|
||||
storage.
|
||||
them. The native clients now use platform credential stores (Keychain,
|
||||
EncryptedSharedPreferences, Windows Credential Manager, chmod 0600).
|
||||
The web client's XSS exposure is a different threat model — a future pass
|
||||
could encrypt with a user-derived key via Web Crypto, but the priority is
|
||||
lower since the web client is not the primary deployment target.
|
||||
|
||||
- **No automated browser-level regression coverage**
|
||||
The current audit verified JavaScript syntax and inspected the runtime
|
||||
|
|
|
|||
|
|
@ -37,10 +37,6 @@ Updated: 2026-03-29
|
|||
- **Impact:** The shared-source fix is low-risk, but final production confidence
|
||||
still needs one Windows build/run pass.
|
||||
|
||||
### Credentials remain plaintext in world storage
|
||||
### ~~Credentials remain plaintext in world storage~~ FIXED
|
||||
|
||||
- **Issue:** The Win32 GUI world database follows the same plaintext credential
|
||||
pattern as the console client.
|
||||
- **Impact:** This is acceptable for local development but weak for shared or
|
||||
managed workstation environments.
|
||||
- **Fix:** Move to OS-backed secret storage or encrypted-at-rest credentials.
|
||||
- Hydra passwords moved from plaintext `worlds.json` to Windows Credential Manager via `CredWriteW`/`CredReadW` (`CRED_TYPE_GENERIC`, `CRED_PERSIST_LOCAL_MACHINE`). Existing JSON passwords are transparently migrated to the credential store on first load and stripped from JSON on next save.
|
||||
|
|
|
|||
88
client/win32gui/src/credential_store.cpp
Normal file
88
client/win32gui/src/credential_store.cpp
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// credential_store.cpp -- Windows Credential Manager wrapper for Hydra passwords.
|
||||
#include "credential_store.h"
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#include <wincred.h>
|
||||
#include <vector>
|
||||
|
||||
#pragma comment(lib, "advapi32.lib")
|
||||
|
||||
// Convert a UTF-8 std::string to a wide string.
|
||||
static std::wstring to_wide(const std::string& s) {
|
||||
if (s.empty()) return {};
|
||||
int len = MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0);
|
||||
std::wstring ws(len, L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), ws.data(), len);
|
||||
return ws;
|
||||
}
|
||||
|
||||
// Convert a wide string to a UTF-8 std::string.
|
||||
static std::string to_utf8(const wchar_t* ws, int len = -1) {
|
||||
if (!ws || (len == 0)) return {};
|
||||
int n = WideCharToMultiByte(CP_UTF8, 0, ws, len, nullptr, 0, nullptr, nullptr);
|
||||
std::string s(n, '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, ws, len, s.data(), n, nullptr, nullptr);
|
||||
// WideCharToMultiByte with len=-1 includes a null terminator in the count.
|
||||
if (len == -1 && !s.empty() && s.back() == '\0') {
|
||||
s.pop_back();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Build the credential target name: "Titan:<world_name>"
|
||||
static std::wstring make_target(const std::string& world_name) {
|
||||
return L"Titan:" + to_wide(world_name);
|
||||
}
|
||||
|
||||
bool CredStore::Save(const std::string& world_name, const std::string& username,
|
||||
const std::string& password) {
|
||||
std::wstring target = make_target(world_name);
|
||||
std::wstring wuser = to_wide(username);
|
||||
|
||||
CREDENTIALW cred = {};
|
||||
cred.Type = CRED_TYPE_GENERIC;
|
||||
cred.TargetName = const_cast<LPWSTR>(target.c_str());
|
||||
cred.UserName = const_cast<LPWSTR>(wuser.c_str());
|
||||
cred.CredentialBlobSize = (DWORD)password.size();
|
||||
cred.CredentialBlob = (LPBYTE)password.data();
|
||||
cred.Persist = CRED_PERSIST_LOCAL_MACHINE;
|
||||
|
||||
return CredWriteW(&cred, 0) != FALSE;
|
||||
}
|
||||
|
||||
std::string CredStore::LoadPassword(const std::string& world_name) {
|
||||
std::wstring target = make_target(world_name);
|
||||
PCREDENTIALW pcred = nullptr;
|
||||
if (!CredReadW(target.c_str(), CRED_TYPE_GENERIC, 0, &pcred)) {
|
||||
return {};
|
||||
}
|
||||
std::string password;
|
||||
if (pcred->CredentialBlob && pcred->CredentialBlobSize > 0) {
|
||||
password.assign(reinterpret_cast<const char*>(pcred->CredentialBlob),
|
||||
pcred->CredentialBlobSize);
|
||||
}
|
||||
CredFree(pcred);
|
||||
return password;
|
||||
}
|
||||
|
||||
std::string CredStore::LoadUsername(const std::string& world_name) {
|
||||
std::wstring target = make_target(world_name);
|
||||
PCREDENTIALW pcred = nullptr;
|
||||
if (!CredReadW(target.c_str(), CRED_TYPE_GENERIC, 0, &pcred)) {
|
||||
return {};
|
||||
}
|
||||
std::string username;
|
||||
if (pcred->UserName) {
|
||||
username = to_utf8(pcred->UserName);
|
||||
}
|
||||
CredFree(pcred);
|
||||
return username;
|
||||
}
|
||||
|
||||
void CredStore::Remove(const std::string& world_name) {
|
||||
std::wstring target = make_target(world_name);
|
||||
CredDeleteW(target.c_str(), CRED_TYPE_GENERIC, 0);
|
||||
}
|
||||
20
client/win32gui/src/credential_store.h
Normal file
20
client/win32gui/src/credential_store.h
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// credential_store.h -- Windows Credential Manager wrapper for Hydra passwords.
|
||||
#ifndef CREDENTIAL_STORE_H
|
||||
#define CREDENTIAL_STORE_H
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace CredStore {
|
||||
// Store credentials under "Titan:<world_name>".
|
||||
bool Save(const std::string& world_name, const std::string& username,
|
||||
const std::string& password);
|
||||
|
||||
// Retrieve stored credentials. Returns empty string if not found.
|
||||
std::string LoadPassword(const std::string& world_name);
|
||||
std::string LoadUsername(const std::string& world_name);
|
||||
|
||||
// Remove stored credentials for a world.
|
||||
void Remove(const std::string& world_name);
|
||||
}
|
||||
|
||||
#endif // CREDENTIAL_STORE_H
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// settings.cpp -- JSON config load/save.
|
||||
#include "settings.h"
|
||||
#include "json_mini.h"
|
||||
#include "credential_store.h"
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
|
|
@ -73,6 +74,26 @@ bool Settings::Load(const std::string& dir) {
|
|||
w.hydra_pass = jstr(wobj, "hydra_pass");
|
||||
w.hydra_game = jstr(wobj, "hydra_game");
|
||||
if (!w.name.empty() && !w.host.empty()) {
|
||||
// For Hydra worlds, prefer credentials from Windows Credential
|
||||
// Manager. If the JSON still contains a password (pre-migration),
|
||||
// push it into CredStore and strip it from the in-memory struct
|
||||
// so the next Save() will no longer write it to disk.
|
||||
if (w.use_hydra) {
|
||||
std::string stored_pass = CredStore::LoadPassword(w.name);
|
||||
std::string stored_user = CredStore::LoadUsername(w.name);
|
||||
if (!stored_pass.empty()) {
|
||||
// Credential Manager already has credentials -- use them.
|
||||
w.hydra_pass = stored_pass;
|
||||
if (!stored_user.empty()) {
|
||||
w.hydra_user = stored_user;
|
||||
}
|
||||
} else if (!w.hydra_pass.empty()) {
|
||||
// Migration: password still lives in JSON. Move it to
|
||||
// Credential Manager so the next Save() drops it from
|
||||
// the JSON file.
|
||||
CredStore::Save(w.name, w.hydra_user, w.hydra_pass);
|
||||
}
|
||||
}
|
||||
worlds.push_back(std::move(w));
|
||||
}
|
||||
}
|
||||
|
|
@ -145,8 +166,10 @@ bool Settings::Save(const std::string& dir) const {
|
|||
if (w.use_hydra) {
|
||||
wobj.push_back({"use_hydra", JValue(true)});
|
||||
wobj.push_back({"hydra_user", JValue(w.hydra_user)});
|
||||
wobj.push_back({"hydra_pass", JValue(w.hydra_pass)});
|
||||
// Password is NOT written to JSON -- it lives in Windows
|
||||
// Credential Manager.
|
||||
wobj.push_back({"hydra_game", JValue(w.hydra_game)});
|
||||
CredStore::Save(w.name, w.hydra_user, w.hydra_pass);
|
||||
}
|
||||
warr.push_back(std::move(wobj));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue