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:
Stephen Dennis 2026-04-05 18:56:47 -06:00
parent 037922e3ff
commit c1e63deee3
10 changed files with 189 additions and 18 deletions

View file

@ -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)

View file

@ -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

View file

@ -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_);
}
{

View 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

View file

@ -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;
}

View file

@ -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

View file

@ -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.

View 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);
}

View 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

View file

@ -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));
}