Cockatrice/servatrice/src/servatrice_database_interface.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

251 lines
12 KiB
C++

#ifndef SERVATRICE_DATABASE_INTERFACE_H
#define SERVATRICE_DATABASE_INTERFACE_H
#include "libcockatrice/protocol/pb/serverinfo_user.pb.h"
#include <QHash>
#include <QList>
#include <QSqlDatabase>
#include <QString>
#include <QStringList>
#include <QtGlobal>
#include <libcockatrice/protocol/pb/serverinfo_ban.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_chat_message.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user_alt.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user_session.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_warning.pb.h>
#include <qtmetamacros.h>
#include <server.h>
#include <server_database_interface.h>
class QSqlQuery;
#define DATABASE_SCHEMA_VERSION 40
class Servatrice;
/** @brief Metadata of a single deck inside a temporary deck share bundle. */
struct DeckShareItemRecord
{
int id = -1; ///< Database id, used for downloads.
QString name; ///< Deck name.
QStringList tags; ///< Deck tags.
QString bannerCard; ///< Banner card name (deck image).
QString gameFormat; ///< Game format the deck was built for.
QString colorIdentity; ///< Color identity, e.g. "WUBRG".
QString content; ///< Deck content (native format); empty in list queries.
};
/** @brief Summary of a share bundle owned by a user. */
struct DeckShareSummaryRecord
{
int id = -1; ///< Database id, used for revocation.
QString name; ///< Share name.
qint64 creationTime = 0; ///< Unix timestamp at which the share was created.
qint64 expiresAt = 0; ///< Unix timestamp at which the share expires.
int itemCount = 0; ///< Number of decks in the bundle.
};
class Servatrice_DatabaseInterface : public Server_DatabaseInterface
{
Q_OBJECT
private:
int instanceId;
QSqlDatabase sqlDatabase;
QHash<QString, QSqlQuery *> preparedStatements;
Servatrice *server;
ServerInfo_User evalUserQueryResult(const QSqlQuery *query, bool complete, bool withId = false);
/** Must be called after checkSql and server is known to be in auth mode. */
bool checkUserIsIdBanned(const QString &clientId, QString &banReason, int &banSecondsRemaining);
/** Must be called after checkSql and server is known to be in auth mode. */
bool checkUserIsIpBanned(const QString &ipAddress, QString &banReason, int &banSecondsRemaining);
/** Must be called after checkSql and server is known to be in auth mode. */
bool checkUserIsNameBanned(QString const &userName, QString &banReason, int &banSecondsRemaining);
bool isStrictModeEnabled(bool &ok) const;
protected:
AuthenticationResult checkUserPassword(Server_ProtocolHandler *handler,
const QString &user,
const QString &password,
const QString &clientId,
QString &reasonStr,
int &banSecondsLeft,
bool passwordNeedsHash) override;
public slots:
void initDatabase(const QSqlDatabase &_sqlDatabase);
public:
explicit Servatrice_DatabaseInterface(int _instanceId, Servatrice *_server);
~Servatrice_DatabaseInterface() override;
bool initDatabase(const QString &type,
const QString &hostName,
const QString &databaseName,
const QString &userName,
const QString &password);
bool openDatabase();
bool checkSql();
QSqlQuery *prepareQuery(const QString &queryText);
bool execSqlQuery(QSqlQuery *query);
const QSqlDatabase &getDatabase()
{
return sqlDatabase;
}
bool activeUserExists(const QString &user) override;
bool userExists(const QString &user) override;
QString getUserSalt(const QString &user) override;
QString getUserPasswordData(const QString &user) override;
bool submitPasswordVerifier(const QString &user, const QString &passwordVerifier) override;
/** @brief Decrypt the account's legacy password backup when it exists, is decryptable
* with the configured key and has not aged out; purges it when it has. Returns the
* legacy credential in the stored "salt + hash" format, or an empty string. */
QString getUsableLegacyBackup(const QString &user);
/** @brief Refresh the age window of the account's legacy password backup. */
void touchLegacyBackup(const QString &user);
/** @brief Drop the account's legacy password backup. */
void clearLegacyBackup(const QString &user);
/** @brief Drop every legacy password backup that has not been used within the TTL. */
void purgeExpiredLegacyBackups();
int getUserIdInDB(const QString &name);
QMap<QString, ServerInfo_User> getBuddyList(const QString &name) override;
QMap<QString, ServerInfo_User> getIgnoreList(const QString &name) override;
bool isInBuddyList(const QString &whoseList, const QString &who) override;
bool isInIgnoreList(const QString &whoseList, const QString &who) override;
ServerInfo_User getUserData(const QString &name, bool withId = false) override;
void storeGameInformation(const QString &roomName,
const QStringList &roomGameTypes,
const ServerInfo_Game &gameInfo,
const QSet<QString> &allPlayersEver,
const QSet<QString> &allSpectatorsEver,
const QList<GameReplay *> &replayList) override;
DeckList *getDeckFromDatabase(int deckId, int userId) override;
/**
* @brief Creates a new temporary deck share bundle.
* @param expiresAt Receives the actual expiry read back from the database.
* @return false on failure.
*/
bool createDeckShare(const QString &token,
const QString &name,
int userId,
const QList<DeckShareItemRecord> &items,
int expiryDays,
qint64 &expiresAt);
/** @brief Lists the share bundles created by a user, newest first. */
bool getDeckSharesForUser(int userId, QList<DeckShareSummaryRecord> &shares);
/**
* @brief Deletes one of a user's own share bundles (cascades to its items).
* @return false if no such bundle belongs to the user.
*/
bool deleteDeckShare(int shareId, int userId);
/**
* @brief Looks up a valid (non-expired) share bundle by token.
* @return false if the token is unknown or expired.
*/
bool getDeckShareList(const QString &token, QString &name, qint64 &expiresAt, QList<DeckShareItemRecord> &items);
/**
* @brief Fetches the content of one item of a valid share bundle.
* @return false if the token is unknown/expired or the item does not belong to the bundle.
*/
bool getDeckShareItem(const QString &token, int itemId, QString &content);
/** @brief Deletes all expired share bundles (cascades to their items). */
void cleanupExpiredDeckShares();
int getNextGameId() override;
int getNextReplayId() override;
int getActiveUserCount(QString connectionType = QString()) override;
qint64 startSession(const QString &userName,
const QString &address,
const QString &clientId,
const QString &connectionType) override;
void endSession(qint64 sessionId) override;
void clearSessionTables() override;
void lockSessionTables() override;
void unlockSessionTables() override;
bool userSessionExists(const QString &userName) override;
bool usernameIsValid(const QString &user, QString &error) override;
bool checkUserIsBanned(const QString &ipAddress,
const QString &userName,
const QString &clientId,
QString &banReason,
int &banSecondsRemaining) override;
int checkNumberOfUserAccounts(const QString &email) override;
bool registerUser(const QString &userName,
const QString &realName,
const QString &password,
bool passwordNeedsHash,
const QString &emailAddress,
const QString &country,
bool active = false) override;
bool activateUser(const QString &userName, const QString &token) override;
void updateUsersClientID(const QString &userName, const QString &userClientID) override;
void updateUsersLastLoginData(const QString &userName, const QString &clientVersion) override;
void logMessage(const int senderId,
const QString &senderName,
const QString &senderIp,
const QString &logMessage,
LogMessage_TargetType targetType,
const int targetId,
const QString &targetName,
const QString &channel = QString()) override;
bool changeUserPassword(const QString &user, const QString &password, bool passwordNeedsHash) override;
bool changeUserPassword(const QString &user,
const QString &oldPassword,
bool oldPasswordNeedsHash,
const QString &newPassword,
bool newPasswordNeedsHash) override;
void setForcePasswordChange(const QString &user, bool force) override;
QList<ServerInfo_Ban> getUserBanHistory(const QString userName);
bool
addWarning(const QString userName, const QString adminName, const QString warningReason, const QString clientID);
QList<ServerInfo_Warning> getUserWarnHistory(const QString userName);
QList<ServerInfo_ChatMessage> getMessageLogHistory(const QString &user,
const QString &ipaddress,
const QString &gamename,
const QString &gameid,
const QString &message,
bool &chat,
bool &game,
bool &room,
int &range,
int &maxresults);
QList<ServerInfo_UserSession> getUserSessions(const QString &userName, int limit);
QList<ServerInfo_UserAlt> getUserAlts(const QString &userName);
QList<ServerInfo_ModeratorLogin> getModeratorLastLogins();
// Uptime snapshot as recorded by Servatrice::statusUpdate() into the
// {prefix}_uptime table. valid is false when no snapshot exists yet.
struct UptimeSnapshot
{
bool valid = false;
quint64 usersCount = 0;
quint64 modsCount = 0;
quint64 gamesCount = 0;
quint64 txBytes = 0;
quint64 rxBytes = 0;
quint64 uptimeSecs = 0;
quint64 timest = 0;
};
UptimeSnapshot getLatestUptimeSnapshot(int serverId);
bool removeUserAvatar(const QString &userName);
bool isCardArtAllowed(const QString &cardName, const QString &cardProviderId) override;
bool addForgotPassword(const QString &user);
bool removeForgotPassword(const QString &user) override;
bool doesForgotPasswordExist(const QString &user);
bool updateUserToken(const QString &token, const QString &user);
bool validateTableColumnStringData(const QString &table,
const QString &column,
const QString &_user,
const QString &_datatocheck);
void addAuditRecord(const QString &user,
const QString &ipaddress,
const QString &clientid,
const QString &action,
const QString &details,
const bool &results);
};
#endif