Cockatrice/servatrice/src/servatrice.h
BruebachL a139084ad6
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
[Security] Add challenge-response auth with scrypt verifiers and stop storing plaintext passwords (#7193)
* [Security] Add challenge-response auth with scrypt verifiers and stop storing plaintext passwords

Challenge-response authentication: the client derives a scrypt verifier
(RFC 7914, EVP_PBE_scrypt, N=32768, r=8, p=1) and authenticates with
HMAC-SHA256(key, nonce), so neither the password nor its hash is
transmitted. The stored format becomes
"$scrypt$<n>$<r>$<p>$<salt>$<verifier>" and Response_PasswordSalt
now carries the cost parameters. Strict servers only accept scrypt
verifiers; legacy accounts are migrated after a successful login.

Fix #344 for challenge-response servers: a saved profile stores the
derived verifier under the password key instead of the plaintext password.
The connect dialog loads it without revealing it, autoconnect passes it
through, the change-password dialog no longer prefills the old password
field with it, and the client only persists the verifier when
"Save password" is checked.

Took 3 minutes

Took 1 minute

Took 10 seconds

Took 7 minutes

* [Security] Harden credential format acceptance and challenge nonce validation

* [Security] Refuse KDF downgrade; trim profile name and warn on silent credential drop

* [Security] Add SupportsAll ServerOptions enumerator to fix enum validation assert

The server advertised both SupportsPasswordHash (1) and
SupportsChallengeResponseAuth (2) via a bitwise-OR that produced the
value 3, which no enumerator declared. Protobuf's proto2 enum
validation rejects the value in _internal_set_server_options, crashing
servatrice on the debug assertion. Add a SupportsAll = 3 enumerator with
an identical wire value so the bitmask reads unchanged while the value
is now a documented, valid member of the enum.

* [Security] Normalize username when issuing challenge nonce

cmdRequestPasswordSalt bound the challenge nonce to the raw, un-simplified
username while cmdLogin verifies it against the simplified name. A user
who typed leading or trailing whitespace in the username box would be
silently rejected during challenge-response login even with the correct
password. Simplify at the nonce-issuing site so both commands agree on
the normalized name.

Also drive-by formatting: collapse the now single-expression
serverOptions initializer onto one line.

* [Security] Keep an encrypted legacy password backup for old-client logins

Migrating an account to scrypt stranded pre-challenge clients, which can
only present the legacy salt+hash: after migration the server served them
a scrypt salt they cannot use, so the only recovery was a password reset.

When security/legacy_backup_key is configured (64 hex chars,
openssl rand -hex 32), submitPasswordVerifier now seals the legacy
credential with AES-256-GCM under that key before overwriting the row,
storing it in password_legacy_backup with the time it was last used. Old
clients keep working: cmdRequestPasswordSalt serves the backup's 16-char
salt in password_salt so they can derive the legacy hash they were
always able to compute, while new clients derive the verifier from the
new scrypt_salt field, which always carries the verifier salt. The
non-challenge login path accepts the plaintext or legacy hash against
the decrypted backup and refreshes its window; a migrated row with no
usable backup fails closed.

Backups age out so a database dump taken after the window closes never
carries a crackable legacy hash: getUsableLegacyBackup purges on first
use past security/legacy_backup_ttl_days (default 90, clamped 1..3650),
and a six-hourly sweep drops backups idle accounts never touch.
changeUserPassword clears the backup since a new credential invalidates
it. Without a configured key nothing is sealed or purged, so a
temporarily missing key cannot destroy existing backups.

The AAD is the simplified user name on both sides, sealing the blob to
its row so it cannot be moved between accounts. Sealing failures
degrade to the previous lockout behaviour rather than blocking
migration. The columns extend the unmerged 0036->0037 migration, so the
schema version stays 37.

* [Security] Address review: proto compat, plaintext verify, AAD case, strict scope

Fix the two blockers and follow-ups from the 2026-10-16 review:

- Advertise challenge-response in a new supports_challenge_response
  field instead of the ServerOptions enum and drop SupportsAll:
  proto2 enums are closed, so already-released clients parsed
  server_options = 3 as NoOptions and fell back to plaintext
  passwords. server_options stays exactly SupportsPasswordHash
  on the wire; old clients ignore the new field.
- Verify a plaintext password directly against a migrated scrypt
  row with verifyPassword() so email changes (cmdAccountEdit)
  and old clients sending password keep working without a
  legacy backup key. Pre-hashed values are still checked
  against the backup, and its TTL window is refreshed only
  when the backup is what actually matched.
- Use the stored row name as the AES-GCM AAD on both seal
  and open so a login whose case differs from the stored
  name (utf8mb4_unicode_ci) still opens its backup.
- Require challenge-response in strict mode only for
  registered SQL accounts (userExists gate), leaving
  guest/unregistered logins and none/password auth
  methods unaffected.
- Document in servatrice.ini.example that a captured
  legacy hash can take over a not-yet-migrated account
  and that strict only constrains registered SQL
  accounts. Schema version is 38.

* [Security] Raise legacy backup TTL default to 180 days

Gives operators and slow-to-upgrade users a longer downgrade
window for pre-challenge clients before unused encrypted
legacy password backups age out.

* [Security] Fix servatrice.cpp formatting per clang-format-18

* [Security] Add missing forward declaration for Command_SubmitPasswordVerifier

The cmdSubmitPasswordVerifier declaration references the proto-generated
type, which otherwise does not name a type in this header and breaks
every C++ build.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: BruebachL <bruebachl@cockatrice.example>
2026-10-16 22:52:26 +02:00

417 lines
15 KiB
C++

/***************************************************************************
* Copyright (C) 2008 by Max-Wilhelm Bruker *
* brukie@laptop *
* *
* 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. *
***************************************************************************/
#ifndef SERVATRICE_H
#define SERVATRICE_H
#include "metrics_registry.h"
#include "ratelimiter.h"
#include "smtpclient.h"
#include <QByteArray>
#include <QDateTime>
#include <QHostAddress>
#include <QList>
#include <QMap>
#include <QMetaType>
#include <QMutex>
#include <QMutexLocker>
#include <QReadWriteLock>
#include <QSslCertificate>
#include <QSslKey>
#include <QString>
// IWYU pragma: keep
// Q_DECLARE_METATYPE below needs the complete type.
#include <QSqlDatabase>
#include <QTcpServer>
#include <QWebSocketServer>
#include <QtGlobal>
#include <atomic>
#include <libcockatrice/protocol/pb/response_report_stats.pb.h>
#include <memory>
#include <qtmetamacros.h>
#include <server.h>
#include <utility>
class QObject;
class QThread;
class SessionEvent;
Q_DECLARE_METATYPE(QSqlDatabase)
class QTimer;
class Servatrice;
class Servatrice_ConnectionPool;
class Servatrice_DatabaseInterface;
class AbstractServerSocketInterface;
class IslInterface;
class Servatrice_GameServer : public QTcpServer
{
Q_OBJECT
private:
Servatrice *server;
QList<Servatrice_ConnectionPool *> connectionPools;
public:
Servatrice_GameServer(Servatrice *_server,
int _numberPools,
const QSqlDatabase &_sqlDatabase,
QObject *parent = nullptr);
~Servatrice_GameServer() override;
protected:
void incomingConnection(qintptr socketDescriptor) override;
Servatrice_ConnectionPool *findLeastUsedConnectionPool();
};
class Servatrice_WebsocketGameServer : public QWebSocketServer
{
Q_OBJECT
private:
Servatrice *server;
QList<Servatrice_ConnectionPool *> connectionPools;
public:
Servatrice_WebsocketGameServer(Servatrice *_server,
int _numberPools,
const QSqlDatabase &_sqlDatabase,
QObject *parent = nullptr);
~Servatrice_WebsocketGameServer() override;
protected:
Servatrice_ConnectionPool *findLeastUsedConnectionPool();
protected slots:
void onNewConnection();
};
class Servatrice_IslServer : public QTcpServer
{
Q_OBJECT
private:
Servatrice *server;
QSslCertificate cert;
QSslKey privateKey;
public:
Servatrice_IslServer(Servatrice *_server,
const QSslCertificate &_cert,
QSslKey _privateKey,
QObject *parent = nullptr)
: QTcpServer(parent), server(_server), cert(_cert), privateKey(std::move(_privateKey))
{
}
protected:
void incomingConnection(qintptr socketDescriptor) override;
};
class ServerProperties
{
public:
int id;
QSslCertificate cert;
QString hostname;
QHostAddress address;
int gamePort;
int controlPort;
ServerProperties(int _id,
const QSslCertificate &_cert,
QString _hostname,
const QHostAddress &_address,
int _gamePort,
int _controlPort)
: id(_id), cert(_cert), hostname(std::move(_hostname)), address(_address), gamePort(_gamePort),
controlPort(_controlPort)
{
}
};
class Servatrice : public Server
{
Q_OBJECT
public:
enum AuthenticationMethod
{
AuthenticationNone,
AuthenticationSql,
AuthenticationPassword
};
enum AuthenticationStrictness
{
AuthenticationLegacy,
AuthenticationMixed,
AuthenticationStrict
};
private slots:
void statusUpdate();
void sweepLegacyBackups();
void shutdownTimeout();
void cleanupExpiredDeckShares();
protected:
void doSendIslMessage(const IslMessage &msg, int _serverId) override;
private:
enum DatabaseType
{
DatabaseNone,
DatabaseMySql
};
AuthenticationMethod authenticationMethod;
DatabaseType databaseType;
QTimer *pingClock, *statusUpdateClock;
QTimer *deckShareCleanupClock;
QTimer *legacyBackupSweepClock;
Servatrice_GameServer *gameServer;
Servatrice_WebsocketGameServer *websocketGameServer;
Servatrice_IslServer *islServer;
mutable QMutex loginMessageMutex;
QString loginMessage;
mutable QMutex shutdownStateMutex;
QString dbPrefix;
QMap<QString, bool> serverRequiredFeatureList;
QString officialWarnings;
Servatrice_DatabaseInterface *servatriceDatabaseInterface;
bool cleanupDatabaseForEmail(const QString &userName, EmailType type, const QString &token, qint64 rowId);
int serverId;
int uptime;
QMutex txBytesMutex, rxBytesMutex;
quint64 txBytes, rxBytes;
MetricsRegistry metricsRegistry;
int metricsSlowCommandMs = 500;
int metricsStallWarnMs = 2000;
std::atomic<qint64> eventLoopStallsTotal{0}; ///< heartbeat overshoots past the warn threshold
std::atomic<qint64> eventLoopLastStallMs{0}; ///< overshoot of the most recent stall
std::atomic<qint64> eventLoopMaxStallMs{0}; ///< worst overshoot seen since process start
QString shutdownReason;
int shutdownMinutes;
int nextShutdownMessageMinutes;
QTimer *shutdownTimer;
mutable QMutex reportStatsMutex;
QDateTime reportStatsTimestamp;
std::shared_ptr<const Response_ReportStats> reportStatsCache;
static constexpr int reportStatsCacheTtlSeconds = 60;
RateLimiter rateLimiter;
mutable QMutex serverListMutex;
QList<ServerProperties> serverList;
void updateServerList();
QMap<int, IslInterface *> islInterfaces;
QString getDBPrefixString() const;
QString getDBHostNameString() const;
QString getDBDatabaseNameString() const;
QString getDBUserNameString() const;
QString getDBPasswordString() const;
QString getRoomsMethodString() const;
QString getISLNetworkSSLCertFile() const;
QString getISLNetworkSSLKeyFile() const;
int getServerStatusUpdateTime() const;
int getNumberOfTCPPools() const;
int getServerTCPPort() const;
int getNumberOfWebSocketPools() const;
int getServerWebSocketPort() const;
int getISLNetworkPort() const;
bool getISLNetworkEnabled() const;
bool getEnableInternalSMTPClient() const;
QHostAddress getServerTCPHost() const;
QHostAddress getServerWebSocketHost() const;
public slots:
void scheduleShutdown(const QString &reason, int minutes);
void updateLoginMessage();
void setRequiredFeatures(const QString &featureList);
void onEmailDelivered(const QString &userName, EmailType type, const QString &token, qint64 rowId);
void onEmailPermanentlyFailed(const QString &userName,
EmailType type,
FailureReason reason,
const QString &token,
qint64 rowId);
public:
explicit Servatrice(QObject *parent = nullptr);
~Servatrice() override;
bool initServer();
QMap<QString, bool> getServerRequiredFeatureList() const override
{
return serverRequiredFeatureList;
}
bool requiresChallengeResponseAuth() const override
{
return getAuthenticationStrictness() == AuthenticationStrict;
}
/** @brief Key sealing legacy password backups, or empty when they are disabled. */
QByteArray getLegacyBackupKey() const;
/** @brief Days a legacy password backup may go unused before it is purged. */
int getLegacyBackupTtlDays() const;
QString getServerName() const;
QString getLoginMessage() const override
{
QMutexLocker locker(&loginMessageMutex);
return loginMessage;
}
SessionEvent *getLoginSessionEvent() const override;
SessionEvent *makeShutdownEvent() const;
QString getRequiredFeatures() const override;
QString getAuthenticationMethodString() const;
QString getDBTypeString() const;
QString getDbPrefix() const
{
return dbPrefix;
}
QString getEmailBlackList() const;
QString getEmailWhiteList() const;
AuthenticationMethod getAuthenticationMethod() const
{
return authenticationMethod;
}
AuthenticationStrictness getAuthenticationStrictness() const;
bool permitUnregisteredUsers() const override
{
return authenticationMethod != AuthenticationNone;
}
bool getGameShouldPing() const override
{
return true;
}
bool getClientIDRequiredEnabled() const override;
bool getRegOnlyServerEnabled() const override;
bool getMaxUserLimitEnabled() const override;
bool getStoreReplaysEnabled() const override;
bool getRegistrationEnabled() const;
bool getRequireEmailForRegistrationEnabled() const;
bool getRequireEmailActivationEnabled() const;
bool getEnableLogQuery() const override;
bool getEnableForgotPassword() const;
bool getEnableForgotPasswordChallenge() const;
bool getEnableAudit() const;
bool getEnableRegistrationAudit() const;
bool getEnableForgotPasswordAudit() const;
int getMinPasswordLength() const;
int getIdleClientTimeout() const override;
int getServerID() const override;
int getMaxGameInactivityTime() const override;
int getMaxPlayerInactivityTime() const override;
int getClientKeepAlive() const override;
int getDeckShareExpiryDays() const;
int getDeckShareCleanupInterval() const;
int getDeckShareMaxDecksPerShare() const;
int getDeckShareMaxSharesPerDay() const;
int getMaxUsersPerAddress() const;
int getMessageCountingInterval() const override;
int getMaxMessageCountPerInterval() const override;
int getMaxMessageSizePerInterval() const override;
int getMaxGamesPerUser() const override;
int getCommandCountingInterval() const override;
int getMaxCommandCountPerInterval() const override;
int getMaxUserTotal() const override;
bool permitCreateGameAsJudge() const override;
int getMaxTcpUserLimit() const;
int getMaxWebSocketUserLimit() const;
int getUsersWithAddress(const QHostAddress &address) const;
int getMaxAccountsPerEmail() const;
int getForgotPasswordTokenLife() const;
QList<AbstractServerSocketInterface *> getUsersWithAddressAsList(const QHostAddress &address) const;
void incTxBytes(quint64 num);
void incRxBytes(quint64 num);
void addDatabaseInterface(QThread *thread, Servatrice_DatabaseInterface *databaseInterface);
// Metrics (see [metrics] section in servatrice.ini.example)
MetricsRegistry &getMetricsRegistry()
{
return metricsRegistry;
}
/**
* Sums cards across all zones of all running games. Each game takes its
* own gameMutex -- the hot per-game lock every game action contends on --
* and then iterates every player's zones, so the scrape cost is really
* O(total cards in play) plus one mutex acquisition per live game. Keep
* scrapes infrequent in big multiplayer rooms.
*/
qint64 getCardsInGamesTotal() const;
int getMetricsSlowCommandMs() const
{
return metricsSlowCommandMs;
}
/// Heartbeat overshoot that counts as a stall. A value of 0 disables the watchdogs.
int getMetricsStallWarnMs() const
{
return metricsStallWarnMs;
}
void observeGameStartDurationMs(qint64 elapsedMs) override;
qint64 getEventLoopStallsTotal() const
{
return eventLoopStallsTotal.load(std::memory_order_relaxed);
}
qint64 getEventLoopLastStallMs() const
{
return eventLoopLastStallMs.load(std::memory_order_relaxed);
}
qint64 getEventLoopMaxStallMs() const
{
return eventLoopMaxStallMs.load(std::memory_order_relaxed);
}
/// Records one heartbeat overshoot and logs a single warning for it.
void observeEventLoopStall(const QString &threadName, qint64 overshootMs);
/**
* Installs an EventLoopWatchdog in @p thread. Called once per socket pool
* thread right after it starts.
*/
void watchWorkerThread(QThread *thread);
RateLimiter *getRateLimiter()
{
return &rateLimiter;
}
bool recordFailedLogin(const QString &ipAddress, const QString &userName) override;
bool isLoginRateLimited(const QString &ipAddress, const QString &userName) override;
int loginRateLimitRemainingSeconds(const QString &ipAddress, const QString &userName) override;
void clearFailedLogins(const QString &ipAddress, const QString &userName) override;
bool recordRegistrationAttempt(const QString &ipAddress);
bool recordForgotPasswordAttempt(const QString &ipAddress);
/// True when the address is in security/trusted_sources and therefore exempt from per-address limits.
bool isRateLimitExempt(const QString &ipAddress) const;
int getMaxLoginAttemptsPerIp() const;
int getLoginAttemptWindowSeconds() const;
int getMaxRegistrationsPerIp() const;
int getRegistrationWindowSeconds() const;
int getMaxForgotPasswordRequestsPerIp() const;
int getForgotPasswordWindowSeconds() const;
bool islConnectionExists(int _serverId) const;
void addIslInterface(int _serverId, IslInterface *interface);
void removeIslInterface(int _serverId);
QReadWriteLock islLock;
// The moderation queue statistics are shared between all connected moderators and
// cached briefly to avoid re-running several full-table queries on every refresh.
std::shared_ptr<const Response_ReportStats> getCachedReportStats() const;
void cacheReportStats(const Response_ReportStats &stats);
QList<ServerProperties> getServerList() const;
};
#endif