mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 09:33:25 -08:00
[Security] Add challenge-response auth with scrypt verifiers and stop storing plaintext passwords (#7193)
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
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 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>
This commit is contained in:
parent
155f7b0143
commit
a139084ad6
35 changed files with 1385 additions and 31 deletions
|
|
@ -45,6 +45,7 @@
|
|||
#include <QObject>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QReadLocker>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
#include <QSharedPointer>
|
||||
#include <QSqlDatabase>
|
||||
|
|
@ -76,6 +77,10 @@
|
|||
|
||||
inline Q_LOGGING_CATEGORY(ServatriceLog, "servatrice");
|
||||
|
||||
// Expired legacy password backups are also purged on first use, but idle accounts
|
||||
// need this sweep so credentials that are never touched still age out of the database.
|
||||
static constexpr qint64 kLegacyBackupSweepIntervalMs = 6 * 60 * 60 * 1000;
|
||||
|
||||
Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server,
|
||||
int _numberPools,
|
||||
const QSqlDatabase &_sqlDatabase,
|
||||
|
|
@ -613,6 +618,10 @@ bool Servatrice::initServer()
|
|||
deckShareCleanupClock->start(deckShareCleanupInterval);
|
||||
}
|
||||
|
||||
legacyBackupSweepClock = new QTimer(this);
|
||||
connect(legacyBackupSweepClock, &QTimer::timeout, this, &Servatrice::sweepLegacyBackups);
|
||||
legacyBackupSweepClock->start(kLegacyBackupSweepIntervalMs);
|
||||
|
||||
// SOCKET SERVER
|
||||
if (getNumberOfTCPPools() > 0) {
|
||||
gameServer =
|
||||
|
|
@ -1002,6 +1011,14 @@ SessionEvent *Servatrice::getLoginSessionEvent() const
|
|||
return nullptr;
|
||||
}
|
||||
|
||||
void Servatrice::sweepLegacyBackups()
|
||||
{
|
||||
if (!servatriceDatabaseInterface->checkSql()) {
|
||||
return;
|
||||
}
|
||||
servatriceDatabaseInterface->purgeExpiredLegacyBackups();
|
||||
}
|
||||
|
||||
void Servatrice::scheduleShutdown(const QString &reason, int minutes)
|
||||
{
|
||||
shutdownStateMutex.lock();
|
||||
|
|
@ -1194,6 +1211,42 @@ QString Servatrice::getRequiredFeatures() const
|
|||
return settingsCache->value("server/requiredfeatures", "").toString();
|
||||
}
|
||||
|
||||
Servatrice::AuthenticationStrictness Servatrice::getAuthenticationStrictness() const
|
||||
{
|
||||
const QString strictness = settingsCache->value("security/authentication_strictness", "mixed").toString();
|
||||
if (strictness == "strict") {
|
||||
return AuthenticationStrict;
|
||||
}
|
||||
if (strictness == "legacy") {
|
||||
return AuthenticationLegacy;
|
||||
}
|
||||
return AuthenticationMixed;
|
||||
}
|
||||
|
||||
QByteArray Servatrice::getLegacyBackupKey() const
|
||||
{
|
||||
const QString keyHex = settingsCache->value("security/legacy_backup_key").toString().trimmed();
|
||||
if (keyHex.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
static const QRegularExpression validHexKey(QStringLiteral("^[0-9a-fA-F]{64}$"));
|
||||
if (!validHexKey.match(keyHex).hasMatch()) {
|
||||
qDebug() << "security/legacy_backup_key is not 64 hex characters;" << "legacy password backups are disabled";
|
||||
return {};
|
||||
}
|
||||
return QByteArray::fromHex(keyHex.toUtf8());
|
||||
}
|
||||
|
||||
int Servatrice::getLegacyBackupTtlDays() const
|
||||
{
|
||||
bool ok = false;
|
||||
const int days = settingsCache->value("security/legacy_backup_ttl_days", 180).toInt(&ok);
|
||||
if (!ok || days < 1 || days > 3650) {
|
||||
return 180;
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
QString Servatrice::getDBTypeString() const
|
||||
{
|
||||
if (QProcessEnvironment::systemEnvironment().contains("DATABASE_URL")) {
|
||||
|
|
|
|||
|
|
@ -155,8 +155,15 @@ public:
|
|||
AuthenticationSql,
|
||||
AuthenticationPassword
|
||||
};
|
||||
enum AuthenticationStrictness
|
||||
{
|
||||
AuthenticationLegacy,
|
||||
AuthenticationMixed,
|
||||
AuthenticationStrict
|
||||
};
|
||||
private slots:
|
||||
void statusUpdate();
|
||||
void sweepLegacyBackups();
|
||||
void shutdownTimeout();
|
||||
void cleanupExpiredDeckShares();
|
||||
|
||||
|
|
@ -173,6 +180,7 @@ private:
|
|||
DatabaseType databaseType;
|
||||
QTimer *pingClock, *statusUpdateClock;
|
||||
QTimer *deckShareCleanupClock;
|
||||
QTimer *legacyBackupSweepClock;
|
||||
Servatrice_GameServer *gameServer;
|
||||
Servatrice_WebsocketGameServer *websocketGameServer;
|
||||
Servatrice_IslServer *islServer;
|
||||
|
|
@ -251,6 +259,14 @@ public:
|
|||
{
|
||||
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
|
||||
{
|
||||
|
|
@ -272,6 +288,7 @@ public:
|
|||
{
|
||||
return authenticationMethod;
|
||||
}
|
||||
AuthenticationStrictness getAuthenticationStrictness() const;
|
||||
bool permitUnregisteredUsers() const override
|
||||
{
|
||||
return authenticationMethod != AuthenticationNone;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/protocol/pb/game_replay.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
|
||||
#include <libcockatrice/utility/cryptoutil.h>
|
||||
#include <libcockatrice/utility/passwordhasher.h>
|
||||
#include <qalgorithms.h>
|
||||
#include <qnamespace.h>
|
||||
|
|
@ -44,6 +45,17 @@
|
|||
|
||||
inline Q_LOGGING_CATEGORY(DatabaseInterfaceLog, "database_interface");
|
||||
|
||||
// Oldest password_legacy_backup_used_at that is still inside the TTL. The value is
|
||||
// compared against (and stored as) a UTC "yyyy-MM-dd HH:mm:ss" string, which sorts
|
||||
// lexicographically, so no timezone-aware parsing is needed on either side.
|
||||
static QString legacyBackupExpiryCutoff(int ttlDays)
|
||||
{
|
||||
return QDateTime::currentDateTimeUtc().addDays(-ttlDays).toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
|
||||
}
|
||||
|
||||
// Width of the password_legacy_backup column; a sealed backup must fit inside it.
|
||||
static constexpr int MAX_LEGACY_BACKUP_LENGTH = 255;
|
||||
|
||||
Servatrice_DatabaseInterface::Servatrice_DatabaseInterface(int _instanceId, Servatrice *_server)
|
||||
: instanceId(_instanceId), sqlDatabase(QSqlDatabase()), server(_server)
|
||||
{
|
||||
|
|
@ -429,13 +441,104 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot
|
|||
qCWarning(DatabaseInterfaceLog) << "Login denied: user not active";
|
||||
return UserIsInactive;
|
||||
}
|
||||
|
||||
// Fail closed on an absent stored credential: an empty key would
|
||||
// otherwise authenticate anyone who can compute HMAC("", nonce).
|
||||
if (correctPasswordSha512.isEmpty()) {
|
||||
qCWarning(DatabaseInterfaceLog) << "Login denied: empty stored credential";
|
||||
return NotLoggedIn;
|
||||
}
|
||||
|
||||
if (password.startsWith("$challenge$")) {
|
||||
// Challenge-response login: verify HMAC(stored_key, nonce) without
|
||||
// ever transmitting the stored credential or password hash.
|
||||
const QStringList parts = password.split("$");
|
||||
if (parts.size() != 4) {
|
||||
return NotLoggedIn;
|
||||
}
|
||||
const QByteArray nonce = QByteArray::fromBase64(parts.at(2).toUtf8());
|
||||
const QByteArray response = QByteArray::fromBase64(parts.at(3).toUtf8());
|
||||
if (nonce.isEmpty() || response.isEmpty() || !handler->isAuthNonceValid(nonce, user)) {
|
||||
return NotLoggedIn;
|
||||
}
|
||||
|
||||
QByteArray key;
|
||||
if (PasswordHasher::isLegacyFormat(correctPasswordSha512)) {
|
||||
key = correctPasswordSha512.toUtf8();
|
||||
} else {
|
||||
// Design note: the stored scrypt verifier IS the challenge-response key, so a
|
||||
// database dump yields credentials that can answer a login challenge directly.
|
||||
// We deliberately accept this trade: it removes plaintext passwords from the
|
||||
// client config and from the wire, but does not protect against DB compromise.
|
||||
// A password-equivalent proof scheme (e.g. SRP-6a/OPAQUE) would be the proper
|
||||
// escalation and is out of scope here.
|
||||
const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(correctPasswordSha512);
|
||||
if (!verifier.isValid) {
|
||||
return NotLoggedIn;
|
||||
}
|
||||
key = verifier.verifier;
|
||||
}
|
||||
|
||||
const QByteArray expected = PasswordHasher::computeResponse(key, nonce);
|
||||
handler->clearAuthNonce();
|
||||
if (PasswordHasher::constantTimeEquals(expected, response)) {
|
||||
qCDebug(DatabaseInterfaceLog) << "Login accepted: challenge-response password right";
|
||||
return PasswordRight;
|
||||
}
|
||||
qCDebug(DatabaseInterfaceLog) << "Login denied: challenge-response password wrong";
|
||||
return NotLoggedIn;
|
||||
}
|
||||
|
||||
// A migrated (scrypt) row distinguishes how the credential arrived:
|
||||
// a plaintext password (passwordNeedsHash) is verified directly against
|
||||
// the scrypt verifier, so email changes (cmdAccountEdit) and old clients
|
||||
// that still send `password` keep working without a legacy backup key.
|
||||
// A pre-hashed legacy value is accepted against the encrypted legacy
|
||||
// backup while that backup is fresh. Without one there is no downgrade
|
||||
// path. The backup window is refreshed only when the backup is what
|
||||
// actually matched, so a plaintext proof never keeps the
|
||||
// fast-crackable hash alive.
|
||||
if (!PasswordHasher::isLegacyFormat(correctPasswordSha512)) {
|
||||
if (passwordNeedsHash) {
|
||||
// Cost is bounded by costParamsAreSane inside parsePasswordVerifier.
|
||||
if (PasswordHasher::verifyPassword(password, correctPasswordSha512)) {
|
||||
if (forceChange) {
|
||||
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
|
||||
return PasswordChangeRequired;
|
||||
}
|
||||
qCDebug(DatabaseInterfaceLog) << "Login accepted: password right";
|
||||
return PasswordRight;
|
||||
}
|
||||
qCDebug(DatabaseInterfaceLog) << "Login denied: password wrong";
|
||||
return NotLoggedIn;
|
||||
}
|
||||
const QString backupCredential = getUsableLegacyBackup(user);
|
||||
if (backupCredential.isEmpty()) {
|
||||
qCDebug(DatabaseInterfaceLog)
|
||||
<< "Login denied: migrated account without a usable legacy backup";
|
||||
return NotLoggedIn;
|
||||
}
|
||||
if (PasswordHasher::constantTimeEquals(backupCredential.toUtf8(), password.toUtf8())) {
|
||||
touchLegacyBackup(user);
|
||||
if (forceChange) {
|
||||
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
|
||||
return PasswordChangeRequired;
|
||||
}
|
||||
qCDebug(DatabaseInterfaceLog) << "Login accepted: password right";
|
||||
return PasswordRight;
|
||||
}
|
||||
qCDebug(DatabaseInterfaceLog) << "Login denied: password wrong";
|
||||
return NotLoggedIn;
|
||||
}
|
||||
|
||||
QString storedCredential = correctPasswordSha512;
|
||||
QString hashedPassword;
|
||||
if (passwordNeedsHash) {
|
||||
hashedPassword = PasswordHasher::computeHash(password, correctPasswordSha512.left(16));
|
||||
hashedPassword = PasswordHasher::computeHash(password, storedCredential.left(16));
|
||||
} else {
|
||||
hashedPassword = password;
|
||||
}
|
||||
if (correctPasswordSha512 == hashedPassword) {
|
||||
if (PasswordHasher::constantTimeEquals(storedCredential.toUtf8(), hashedPassword.toUtf8())) {
|
||||
if (forceChange) {
|
||||
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
|
||||
return PasswordChangeRequired;
|
||||
|
|
@ -631,6 +734,185 @@ QString Servatrice_DatabaseInterface::getUserSalt(const QString &user)
|
|||
return {};
|
||||
}
|
||||
|
||||
QString Servatrice_DatabaseInterface::getUserPasswordData(const QString &user)
|
||||
{
|
||||
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
||||
return {};
|
||||
}
|
||||
|
||||
checkSql();
|
||||
|
||||
QSqlQuery *query = prepareQuery("SELECT password_sha512 FROM {prefix}_users WHERE name = :name");
|
||||
query->bindValue(":name", user);
|
||||
if (!execSqlQuery(query)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!query->next()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return query->value(0).toString();
|
||||
}
|
||||
|
||||
bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, const QString &passwordVerifier)
|
||||
{
|
||||
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
||||
return false;
|
||||
}
|
||||
|
||||
checkSql();
|
||||
|
||||
// Read the credential that is about to be replaced: while it is still in the
|
||||
// legacy format it becomes the encrypted downgrade backup, sealed before the
|
||||
// row is overwritten with the scrypt verifier.
|
||||
QSqlQuery *currentQuery = prepareQuery("SELECT name, password_sha512 FROM {prefix}_users WHERE name = :name");
|
||||
currentQuery->bindValue(":name", user);
|
||||
if (!execSqlQuery(currentQuery) || !currentQuery->next()) {
|
||||
return false;
|
||||
}
|
||||
// Use the stored row name (not the caller-supplied one) as the AAD: name is
|
||||
// utf8mb4_unicode_ci, so `alice` logs in as `Alice` and the SELECT still finds
|
||||
// the row while a client-typed name would fail the GCM tag check on open.
|
||||
const QString storedName = currentQuery->value(0).toString();
|
||||
const QString currentCredential = currentQuery->value(1).toString();
|
||||
|
||||
QString sealedBackup;
|
||||
QDateTime backupUsedAt;
|
||||
if (PasswordHasher::isLegacyFormat(currentCredential)) {
|
||||
const QByteArray key = server->getLegacyBackupKey();
|
||||
if (!key.isEmpty()) {
|
||||
const QByteArray aad = storedName.simplified().toUtf8();
|
||||
sealedBackup = CryptoUtil::encryptSecret(currentCredential.toUtf8(), key, aad);
|
||||
if (sealedBackup.isEmpty() || sealedBackup.length() > MAX_LEGACY_BACKUP_LENGTH) {
|
||||
qCWarning(DatabaseInterfaceLog)
|
||||
<< "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path";
|
||||
sealedBackup.clear();
|
||||
} else {
|
||||
backupUsedAt = QDateTime::currentDateTimeUtc();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only migrate accounts that still use the credential this backup was taken from;
|
||||
// the guard makes a re-migration (or a raced second submission) a no-op.
|
||||
QSqlQuery *query = prepareQuery("update {prefix}_users set password_sha512 = :verifier, "
|
||||
"password_legacy_backup = :backup, password_legacy_backup_used_at = :used_at "
|
||||
"where name = :user and password_sha512 = :expected and password_sha512 not like "
|
||||
"'$%'");
|
||||
query->bindValue(":verifier", passwordVerifier);
|
||||
query->bindValue(":backup", sealedBackup);
|
||||
query->bindValue(":used_at", backupUsedAt.isValid()
|
||||
? QVariant(backupUsedAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")))
|
||||
: QVariant());
|
||||
query->bindValue(":user", user);
|
||||
query->bindValue(":expected", currentCredential);
|
||||
if (!execSqlQuery(query)) {
|
||||
qCWarning(DatabaseInterfaceLog) << "Failed to submit password verifier for user" << user << query->lastError();
|
||||
return false;
|
||||
}
|
||||
// The guard makes a re-migration a no-op; only report success when a row was actually updated.
|
||||
return query->numRowsAffected() > 0;
|
||||
}
|
||||
|
||||
QString Servatrice_DatabaseInterface::getUsableLegacyBackup(const QString &user)
|
||||
{
|
||||
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Without a configured key the backup cannot be sealed or opened; do not touch
|
||||
// (and never purge on this path) so a temporarily missing key cannot destroy it.
|
||||
const QByteArray key = server->getLegacyBackupKey();
|
||||
if (key.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
checkSql();
|
||||
|
||||
QSqlQuery *query = prepareQuery(
|
||||
"SELECT name, password_legacy_backup, password_legacy_backup_used_at FROM {prefix}_users WHERE name = :name");
|
||||
query->bindValue(":name", user);
|
||||
if (!execSqlQuery(query) || !query->next()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Open with the stored row name as the AAD: sealing used the canonical DB name
|
||||
// while the caller passes the client-typed name, and the two differ whenever
|
||||
// the login case does not match the stored case (utf8mb4_unicode_ci).
|
||||
const QString storedName = query->value(0).toString();
|
||||
const QString sealed = query->value(1).toString();
|
||||
const QString usedAt = query->value(2).toString();
|
||||
if (sealed.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Age out: a backup unused for the whole TTL is purged on sight, so a database
|
||||
// dump taken after the window closes never carries a crackable legacy hash.
|
||||
if (usedAt.isEmpty() || usedAt < legacyBackupExpiryCutoff(server->getLegacyBackupTtlDays())) {
|
||||
qCDebug(DatabaseInterfaceLog) << "Purging expired legacy backup for user" << user;
|
||||
clearLegacyBackup(user);
|
||||
return {};
|
||||
}
|
||||
|
||||
const QByteArray legacyCredential = CryptoUtil::decryptSecret(sealed, key, storedName.simplified().toUtf8());
|
||||
if (legacyCredential.isEmpty()) {
|
||||
qCWarning(DatabaseInterfaceLog) << "Legacy backup for user" << user << "failed to decrypt"
|
||||
<< "- check security/legacy_backup_key";
|
||||
return {};
|
||||
}
|
||||
return QString::fromUtf8(legacyCredential);
|
||||
}
|
||||
|
||||
void Servatrice_DatabaseInterface::touchLegacyBackup(const QString &user)
|
||||
{
|
||||
if (!checkSql()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup_used_at = UTC_TIMESTAMP() "
|
||||
"WHERE name = :name AND password_legacy_backup <> ''");
|
||||
query->bindValue(":name", user);
|
||||
execSqlQuery(query);
|
||||
}
|
||||
|
||||
void Servatrice_DatabaseInterface::clearLegacyBackup(const QString &user)
|
||||
{
|
||||
if (!checkSql()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup = '', "
|
||||
"password_legacy_backup_used_at = NULL WHERE name = :name");
|
||||
query->bindValue(":name", user);
|
||||
execSqlQuery(query);
|
||||
}
|
||||
|
||||
void Servatrice_DatabaseInterface::purgeExpiredLegacyBackups()
|
||||
{
|
||||
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!checkSql()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup = '', "
|
||||
"password_legacy_backup_used_at = NULL "
|
||||
"WHERE password_legacy_backup <> '' AND "
|
||||
"(password_legacy_backup_used_at IS NULL OR password_legacy_backup_used_at < "
|
||||
":cutoff)");
|
||||
query->bindValue(":cutoff", legacyBackupExpiryCutoff(server->getLegacyBackupTtlDays()));
|
||||
if (!execSqlQuery(query)) {
|
||||
qCWarning(DatabaseInterfaceLog) << "Legacy backup sweep failed:" << query->lastError();
|
||||
return;
|
||||
}
|
||||
if (query->numRowsAffected() > 0) {
|
||||
qCDebug(DatabaseInterfaceLog) << "Purged" << query->numRowsAffected() << "expired legacy password backups";
|
||||
}
|
||||
}
|
||||
|
||||
int Servatrice_DatabaseInterface::getUserIdInDB(const QString &name)
|
||||
{
|
||||
if (server->getAuthenticationMethod() == Servatrice::AuthenticationSql) {
|
||||
|
|
@ -1349,7 +1631,10 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user,
|
|||
passwordSha512 = PasswordHasher::computeHash(password, PasswordHasher::generateRandomSalt());
|
||||
}
|
||||
|
||||
// The backup authenticates the credential it was taken from; a new credential
|
||||
// invalidates it (the old password is dead, and the new one has no legacy form).
|
||||
QSqlQuery *passwordQuery = prepareQuery("update {prefix}_users set password_sha512=:password, "
|
||||
"password_legacy_backup='', password_legacy_backup_used_at=NULL, "
|
||||
"passwordLastChangedDate = NOW() where name = :name");
|
||||
passwordQuery->bindValue(":password", passwordSha512);
|
||||
passwordQuery->bindValue(":name", user);
|
||||
|
|
@ -1402,13 +1687,13 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user,
|
|||
return false;
|
||||
}
|
||||
|
||||
const QString correctPasswordSha512 = passwordQuery->value(0).toString();
|
||||
QString oldPasswordSha512 = oldPassword;
|
||||
if (oldPasswordNeedsHash) {
|
||||
QString salt = correctPasswordSha512.left(16);
|
||||
oldPasswordSha512 = PasswordHasher::computeHash(oldPassword, salt);
|
||||
}
|
||||
if (correctPasswordSha512 != oldPasswordSha512) {
|
||||
const QString storedPassword = passwordQuery->value(0).toString();
|
||||
// oldPasswordNeedsHash means the client sent the old password in plaintext. Verify it
|
||||
// against whatever is stored: legacy salt+hash rows or already-migrated scrypt rows
|
||||
// (which must NOT be re-hashed with a salt torn out of the "$scrypt$..." string).
|
||||
const bool oldPasswordMatches = oldPasswordNeedsHash ? PasswordHasher::verifyPassword(oldPassword, storedPassword)
|
||||
: (oldPassword == storedPassword);
|
||||
if (!oldPasswordMatches) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
class QSqlQuery;
|
||||
|
||||
#define DATABASE_SCHEMA_VERSION 39
|
||||
#define DATABASE_SCHEMA_VERSION 40
|
||||
|
||||
class Servatrice;
|
||||
|
||||
|
|
@ -96,6 +96,18 @@ public:
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@
|
|||
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_user_alt.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_user_session.pb.h>
|
||||
#include <libcockatrice/utility/cryptoutil.h>
|
||||
#include <libcockatrice/utility/passwordhasher.h>
|
||||
#include <libcockatrice/utility/report_categories.h>
|
||||
#include <libcockatrice/utility/string_limits.h>
|
||||
|
|
@ -204,7 +205,15 @@ bool AbstractServerSocketInterface::initSession()
|
|||
identEvent.set_server_version(VERSION_STRING);
|
||||
identEvent.set_protocol_version(protocolVersion);
|
||||
if (servatrice->getAuthenticationMethod() == Servatrice::AuthenticationSql) {
|
||||
// Challenge-response is advertised in every strictness mode: legacy accounts keep
|
||||
// logging in with the legacy hash, but already-migrated scrypt rows are always
|
||||
// served challenge-response (authentication_strictness only governs NEW credentials).
|
||||
// server_options stays exactly SupportsPasswordHash on the wire: already-released
|
||||
// clients parse an unknown proto2 enum value as NoOptions and would fall back to
|
||||
// plaintext passwords. Challenge support is advertised in the separate
|
||||
// supports_challenge_response field, which old clients simply ignore.
|
||||
identEvent.set_server_options(Event_ServerIdentification::SupportsPasswordHash);
|
||||
identEvent.set_supports_challenge_response(true);
|
||||
}
|
||||
SessionEvent *identSe = prepareSessionEvent(identEvent);
|
||||
sendProtocolItem(*identSe);
|
||||
|
|
@ -409,6 +418,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm
|
|||
return cmdReportAddComment(cmd.GetExtension(Command_ReportAddComment::ext), rc);
|
||||
case SessionCommand::REPORT_DETAILS:
|
||||
return cmdReportDetails(cmd.GetExtension(Command_ReportDetails::ext), rc);
|
||||
case SessionCommand::SUBMIT_PASSWORD_VERIFIER:
|
||||
return cmdSubmitPasswordVerifier(cmd.GetExtension(Command_SubmitPasswordVerifier::ext), rc);
|
||||
default:
|
||||
return Response::RespFunctionNotAllowed;
|
||||
}
|
||||
|
|
@ -3298,6 +3309,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const C
|
|||
password = QString::fromStdString(cmd.hashed_password());
|
||||
}
|
||||
|
||||
// Reject credential formats the configured authentication strictness does not accept.
|
||||
if (!acceptsCredentialFormat(passwordNeedsHash, password)) {
|
||||
return Response::RespClientUpdateRequired;
|
||||
}
|
||||
|
||||
bool requireEmailActivation = settingsCache->value("registration/requireemailactivation", true).toBool();
|
||||
bool regSucceeded = sqlInterface->registerUser(userName, realName, password, passwordNeedsHash, parsedEmailAddress,
|
||||
country, !requireEmailActivation);
|
||||
|
|
@ -3342,6 +3358,30 @@ bool AbstractServerSocketInterface::tooManyRegistrationAttempts(const QString &i
|
|||
return servatrice->recordRegistrationAttempt(ipAddress);
|
||||
}
|
||||
|
||||
bool AbstractServerSocketInterface::acceptsCredentialFormat(bool passwordNeedsHash, const QString &password) const
|
||||
{
|
||||
// An empty credential must never reach the database: it would be accepted
|
||||
// as a legacy format and stored as '' (fail-open on login, see the empty
|
||||
// stored-credential guard in Servatrice_DatabaseInterface).
|
||||
if (password.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// "scryptFormat" means the client sent a derived verifier rather than a
|
||||
// password to hash ourselves. parsePasswordVerifier enforces the sane-cost
|
||||
// clamp, so nothing starting with '$' reaches the database unparsed.
|
||||
const bool scryptFormat = !passwordNeedsHash && PasswordHasher::parsePasswordVerifier(password).isValid;
|
||||
|
||||
// The strictness mode governs how existing legacy accounts are served, not which new-credential
|
||||
// formats are tolerated: a legacy-mode server must still accept scrypt verifiers, because clients
|
||||
// derive them whenever challenge-response is advertised (and it must be, so already-migrated
|
||||
// scrypt rows keep logging in). strict is the only mode that rejects legacy formats.
|
||||
if (servatrice->getAuthenticationStrictness() == Servatrice::AuthenticationStrict) {
|
||||
return scryptFormat;
|
||||
}
|
||||
// legacy and mixed accept a valid scrypt verifier or a genuine legacy salt+hash.
|
||||
return scryptFormat || PasswordHasher::isLegacyFormat(password);
|
||||
}
|
||||
|
||||
Response::ResponseCode AbstractServerSocketInterface::cmdActivateAccount(const Command_Activate &cmd,
|
||||
ResponseContainer & /*rc*/)
|
||||
{
|
||||
|
|
@ -3656,6 +3696,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const C
|
|||
newPassword = QString::fromStdString(cmd.hashed_new_password());
|
||||
}
|
||||
|
||||
// Reject new credential formats the configured authentication strictness does not accept.
|
||||
if (!acceptsCredentialFormat(newPasswordNeedsHash, newPassword)) {
|
||||
return Response::RespClientUpdateRequired;
|
||||
}
|
||||
|
||||
QString userName = QString::fromStdString(userInfo->name());
|
||||
if (!databaseInterface->changeUserPassword(userName, oldPassword, true, newPassword, newPasswordNeedsHash)) {
|
||||
return Response::RespWrongPassword;
|
||||
|
|
@ -3802,6 +3847,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordReset(con
|
|||
password = QString::fromStdString(cmd.hashed_new_password());
|
||||
}
|
||||
|
||||
// Reject new credential formats the configured authentication strictness does not accept.
|
||||
if (!acceptsCredentialFormat(passwordNeedsHash, password)) {
|
||||
return Response::RespClientUpdateRequired;
|
||||
}
|
||||
|
||||
if (sqlInterface->changeUserPassword(nameFromStdString(cmd.user_name()), password, passwordNeedsHash)) {
|
||||
if (servatrice->getEnableForgotPasswordAudit()) {
|
||||
sqlInterface->addAuditRecord(userName.simplified(), this->getAddress(), clientId.simplified(),
|
||||
|
|
@ -3869,9 +3919,11 @@ AbstractServerSocketInterface::cmdForgotPasswordChallenge(const Command_ForgotPa
|
|||
Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(const Command_RequestPasswordSalt &cmd,
|
||||
ResponseContainer &rc)
|
||||
{
|
||||
const QString userName = nameFromStdString(cmd.user_name());
|
||||
QString passwordSalt = sqlInterface->getUserSalt(userName);
|
||||
if (passwordSalt.isEmpty()) {
|
||||
// Simplified exactly like cmdLogin, so the nonce bound to userName here validates
|
||||
// against the same normalized name during the login's challenge-response check.
|
||||
const QString userName = nameFromStdString(cmd.user_name()).simplified();
|
||||
const QString storedPasswordData = sqlInterface->getUserPasswordData(userName);
|
||||
if (storedPasswordData.isEmpty()) {
|
||||
if (server->getRegOnlyServerEnabled()) {
|
||||
return Response::RespRegistrationRequired;
|
||||
} else {
|
||||
|
|
@ -3879,8 +3931,46 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(con
|
|||
return Response::RespOk;
|
||||
}
|
||||
}
|
||||
|
||||
auto *re = new Response_PasswordSalt;
|
||||
re->set_password_salt(passwordSalt.toStdString());
|
||||
if (PasswordHasher::isLegacyFormat(storedPasswordData)) {
|
||||
re->set_password_salt(storedPasswordData.left(16).toStdString());
|
||||
re->set_needs_migration(true);
|
||||
// Legacy rows get a challenge-response nonce only outside legacy mode (there the client
|
||||
// logs in with the legacy hash and the account is migrated). In legacy mode the row is
|
||||
// served the legacy salt, since legacy mode only governs what NEW credentials are accepted.
|
||||
if (servatrice->getAuthenticationStrictness() != Servatrice::AuthenticationLegacy) {
|
||||
const QByteArray nonce = CryptoUtil::randomBytes(32);
|
||||
setAuthNonce(nonce, userName);
|
||||
re->set_nonce(nonce.constData(), nonce.size());
|
||||
}
|
||||
} else {
|
||||
const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(storedPasswordData);
|
||||
if (!verifier.isValid) {
|
||||
delete re;
|
||||
return Response::RespContextError;
|
||||
}
|
||||
const QString scryptSalt = QString(verifier.salt.toBase64());
|
||||
re->set_password_salt(scryptSalt.toStdString());
|
||||
re->set_scrypt_salt(scryptSalt.toStdString());
|
||||
re->set_n(verifier.n);
|
||||
re->set_r(verifier.r);
|
||||
re->set_p(verifier.p);
|
||||
re->set_needs_migration(false);
|
||||
// Pre-challenge clients derive the legacy hash from password_salt. While a fresh
|
||||
// encrypted legacy backup exists, serve its salt there so they can still log in;
|
||||
// challenge-response clients must use scrypt_salt, which always carries the
|
||||
// verifier salt.
|
||||
const QString legacyBackup = sqlInterface->getUsableLegacyBackup(userName);
|
||||
if (!legacyBackup.isEmpty()) {
|
||||
re->set_password_salt(legacyBackup.left(16).toStdString());
|
||||
}
|
||||
// scrypt rows are served challenge-response in every mode so migrated accounts never lock out.
|
||||
const QByteArray nonce = CryptoUtil::randomBytes(32);
|
||||
setAuthNonce(nonce, userName);
|
||||
re->set_nonce(nonce.constData(), nonce.size());
|
||||
}
|
||||
|
||||
rc.setResponseExtension(re);
|
||||
return Response::RespOk;
|
||||
}
|
||||
|
|
@ -3978,6 +4068,39 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReport(const Command_Re
|
|||
return Response::RespOk;
|
||||
}
|
||||
|
||||
Response::ResponseCode
|
||||
AbstractServerSocketInterface::cmdSubmitPasswordVerifier(const Command_SubmitPasswordVerifier &cmd,
|
||||
ResponseContainer & /*rc*/)
|
||||
{
|
||||
if (authState != PasswordRight) {
|
||||
return Response::RespLoginNeeded;
|
||||
}
|
||||
|
||||
// Limit to the size of the database column (password_sha512 varchar(255)).
|
||||
constexpr int MAX_PASSWORD_VERIFIER_LENGTH = 255;
|
||||
const QString passwordVerifier = QString::fromStdString(cmd.password_verifier());
|
||||
if (passwordVerifier.isEmpty() || passwordVerifier.length() > MAX_PASSWORD_VERIFIER_LENGTH ||
|
||||
PasswordHasher::isLegacyFormat(passwordVerifier)) {
|
||||
return Response::RespContextError;
|
||||
}
|
||||
|
||||
// Reject unparseable or hostile cost parameters before they reach the database.
|
||||
const PasswordVerifier parsedVerifier = PasswordHasher::parsePasswordVerifier(passwordVerifier);
|
||||
if (!parsedVerifier.isValid) {
|
||||
qCWarning(AbstractServerSocketInterfaceLog)
|
||||
<< "Rejecting password verifier submission with invalid or insane cost parameters";
|
||||
return Response::RespContextError;
|
||||
}
|
||||
|
||||
if (!sqlInterface->submitPasswordVerifier(QString::fromStdString(userInfo->name()), passwordVerifier)) {
|
||||
return Response::RespContextError;
|
||||
}
|
||||
|
||||
qCDebug(AbstractServerSocketInterfaceLog)
|
||||
<< "Password verifier migrated for user" << QString::fromStdString(userInfo->name());
|
||||
return Response::RespOk;
|
||||
}
|
||||
|
||||
// ADMIN FUNCTIONS.
|
||||
// Permission is checked by the calling function.
|
||||
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ class Command_ReportUserInfo;
|
|||
class Command_RequestPasswordSalt;
|
||||
class Command_ResetUserPassword;
|
||||
class Command_SetCardArtParams;
|
||||
class Command_SubmitPasswordVerifier;
|
||||
class Command_UpdateAdminNotes;
|
||||
class Command_ViewLogHistory;
|
||||
class Command_WarnUser;
|
||||
|
|
@ -124,6 +125,7 @@ protected:
|
|||
void logDebugMessage(const QString &message) override;
|
||||
bool tooManyRegistrationAttempts(const QString &ipAddress);
|
||||
void processCommandContainer(const CommandContainer &cont) override;
|
||||
bool acceptsCredentialFormat(bool passwordNeedsHash, const QString &password) const;
|
||||
|
||||
virtual void writeToSocket(QByteArray &data) = 0;
|
||||
virtual void flushSocket() = 0;
|
||||
|
|
@ -205,6 +207,7 @@ private:
|
|||
Response::ResponseCode cmdReportDetails(const Command_ReportDetails &cmd, ResponseContainer &rc);
|
||||
Response::ResponseCode cmdReportAddComment(const Command_ReportAddComment &cmd, ResponseContainer &rc);
|
||||
Response::ResponseCode cmdReplayDownloadByGameId(const Command_ReplayDownloadByGameId &cmd, ResponseContainer &rc);
|
||||
Response::ResponseCode cmdSubmitPasswordVerifier(const Command_SubmitPasswordVerifier &cmd, ResponseContainer &rc);
|
||||
Response::ResponseCode
|
||||
processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc) override;
|
||||
Response::ResponseCode
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue