Compare commits

..

3 commits

Author SHA1 Message Date
Lukas Brübach
fb7f7dde55
[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
2026-08-29 15:58:57 +02:00
Lukas Brübach
45d97cb8d2
Lint.
Took 4 minutes

Took 36 seconds
2026-08-29 15:58:56 +02:00
Lukas Brübach
a01dca4f6c
[Security] Use a CSPRNG for salts, tokens, and RNG seeding
Password salts and activation tokens were generated with the global SFMT
RNG, which was seeded from a 32-bit timestamp, making registration
salts and activation tokens predictable. The game RNG used the same
timestamp seed across restarts.

Add CryptoUtil backed by OpenSSL RAND_bytes and use it for salt/token
generation and to seed RNG_SFMT with a 64-bit CSPRNG value in both the
client and server. Link libcockatrice_utility against OpenSSL::Crypto.

Took 30 seconds

Took 25 minutes
2026-08-29 15:58:56 +02:00
37 changed files with 135 additions and 353 deletions

View file

@ -8,7 +8,6 @@ RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \
gtest \
mariadb-libs \
ninja \
openssl \
protobuf \
qt6-base \
qt6-declarative \

View file

@ -15,7 +15,6 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
libssl-dev \
ninja-build \
protobuf-compiler \
qt6-image-formats-plugins \

View file

@ -16,7 +16,6 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
libssl-dev \
ninja-build \
protobuf-compiler \
qt6-image-formats-plugins \

View file

@ -7,7 +7,6 @@ RUN dnf install -y \
git \
mariadb-devel \
ninja-build \
openssl-devel \
protobuf-devel \
qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \
qt6-qtimageformats \

View file

@ -7,7 +7,6 @@ RUN dnf install -y \
git \
mariadb-devel \
ninja-build \
openssl-devel \
protobuf-devel \
qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \
qt6-qtimageformats \

View file

@ -12,7 +12,6 @@ RUN apt-get update && \
libmariadb-dev-compat \
libprotobuf-dev \
libqt6sql6-mysql \
libssl-dev \
ninja-build \
protobuf-compiler \
qt6-tools-dev \

View file

@ -15,7 +15,6 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
libssl-dev \
ninja-build \
protobuf-compiler \
qt6-image-formats-plugins \

View file

@ -16,7 +16,6 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
libssl-dev \
ninja-build \
protobuf-compiler \
qt6-image-formats-plugins \

View file

@ -40,7 +40,7 @@ jobs:
steps:
- name: "Checkout repository"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Initialize CodeQL"
uses: github/codeql-action/init@v4

View file

@ -127,7 +127,7 @@ jobs:
steps:
- name: "Download digests"
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
path: ${{ runner.temp }}/digests
pattern: digest-*

View file

@ -239,6 +239,11 @@ if(WIN32)
find_package(OpenSSL REQUIRED)
if(OPENSSL_FOUND)
include_directories(${OPENSSL_INCLUDE_DIRS})
else()
message(
WARNING
"Could not find OpenSSL runtime libraries. They are not required for compiling, but needs to be available at runtime."
)
endif()
endif()

View file

@ -14,7 +14,6 @@ RUN apt-get update \
libmariadb-dev-compat \
libprotobuf-dev \
libqt6sql6-mysql \
libssl-dev \
qt6-websockets-dev \
protobuf-compiler \
qt6-tools-dev \
@ -43,7 +42,6 @@ RUN apt-get update \
libprotobuf32t64 \
libqt6sql6-mysql \
libqt6websockets6 \
libssl3 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

View file

@ -102,8 +102,12 @@ void ConnectionController::connectToServer()
}
}
void ConnectionController::onPasswordVerifierReady(const QString &verifier)
void ConnectionController::onPasswordVerifierReady(const QString &hostname,
const QString &userName,
const QString &verifier)
{
Q_UNUSED(hostname);
Q_UNUSED(userName);
if (pendingSavePassword) {
SettingsCache::instance().servers().setServerPassword(pendingSaveName, verifier);
}

View file

@ -87,7 +87,7 @@ private slots:
void onPromptForgotPasswordChallenge();
// Persists the derived scrypt verifier after a successful challenge-response login
void onPasswordVerifierReady(const QString &verifier);
void onPasswordVerifierReady(const QString &hostname, const QString &userName, const QString &verifier);
private:
void wireClientSignals();

View file

@ -333,11 +333,6 @@ void DlgConnect::actOk()
{
ServersSettings &servers = SettingsCache::instance().servers();
// Never write a newly typed plaintext password to disk when a verifier is already stored:
// the typed value is used for this connection and a fresh verifier is persisted after a
// successful login. Without a stored verifier we keep the previous (plaintext legacy) behavior.
const QString passwordToSave = storedVerifier.isEmpty() ? passwordEdit->text() : storedVerifier;
if (newHostButton->isChecked()) {
if (saveEdit->text().isEmpty()) {
QMessageBox::critical(this, tr("Connection Warning"), tr("You need to name your new connection profile."));
@ -345,10 +340,13 @@ void DlgConnect::actOk()
}
servers.addNewServer(saveEdit->text().trimmed(), hostEdit->text().trimmed(), portEdit->text().trimmed(),
playernameEdit->text().trimmed(), passwordToSave, savePasswordCheckBox->isChecked());
playernameEdit->text().trimmed(),
passwordEdit->text().isEmpty() ? storedVerifier : passwordEdit->text(),
savePasswordCheckBox->isChecked());
} else {
servers.updateExistingServer(saveEdit->text().trimmed(), hostEdit->text().trimmed(), portEdit->text().trimmed(),
playernameEdit->text().trimmed(), passwordToSave,
playernameEdit->text().trimmed(),
passwordEdit->text().isEmpty() ? storedVerifier : passwordEdit->text(),
savePasswordCheckBox->isChecked());
}

View file

@ -1,17 +1,9 @@
#include "dlg_convert_deck_to_cod_format.h"
#include "../../../client/settings/cache_settings.h"
#include "../../deck_loader/deck_loader.h"
#include <QCheckBox>
#include <QDialogButtonBox>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QLabel>
#include <QMessageBox>
#include <QVBoxLayout>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
DialogConvertDeckToCodFormat::DialogConvertDeckToCodFormat(QWidget *parent) : QDialog(parent)
{
@ -46,71 +38,3 @@ bool DialogConvertDeckToCodFormat::dontAskAgain() const
{
return dontAskAgainCheckbox->isChecked();
}
namespace
{
bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath)
{
QFileInfo fileInfo(filePath);
QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod");
if (QFile::exists(newFileName)) {
QMessageBox::StandardButton reply =
QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"),
QObject::tr("A .cod version of this deck already exists. Overwrite it?"),
QMessageBox::Yes | QMessageBox::No);
return reply == QMessageBox::Yes;
}
return true; // Safe to proceed
}
} // namespace
bool DialogConvertDeckToCodFormat::promptIfRequired(QWidget *parent,
const QString &filePath,
const std::function<bool()> &convert)
{
if (DeckFileFormat::getFormatFromName(filePath) == DeckFileFormat::Cockatrice) {
return true;
}
// Retrieve saved preference if the prompt is disabled
if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) {
if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) {
return false;
}
if (!confirmOverwriteIfExists(parent, filePath)) {
return false;
}
return convert();
}
// Show the dialog to the user
DialogConvertDeckToCodFormat conversionDialog(parent);
if (conversionDialog.exec() != QDialog::Accepted) {
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(
!conversionDialog.dontAskAgain());
SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false);
return false;
}
// Try to convert file
if (!confirmOverwriteIfExists(parent, filePath)) {
return false;
}
if (!convert()) {
return false;
}
if (conversionDialog.dontAskAgain()) {
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false);
SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true);
}
return true;
}

View file

@ -13,9 +13,6 @@
#include <QDialogButtonBox>
#include <QLabel>
#include <QVBoxLayout>
#include <functional>
class QWidget;
class DialogConvertDeckToCodFormat : public QDialog
{
@ -27,21 +24,6 @@ public:
[[nodiscard]] bool dontAskAgain() const;
/**
* @brief Checks whether the deck file at \a filePath can store tags.
*
* If the file is not a .cod deck, prompts the user for conversion to the
* Cockatrice format, honoring the saved "always convert / don't ask again"
* preference. On acceptance \a convert is called to perform the conversion.
*
* @param parent The widget to parent the prompt to.
* @param filePath The path of the deck file to check.
* @param convert Called to convert the deck once the user agrees.
* @return true if tags can be stored (no conversion needed, or the conversion
* was performed), false if the user declined to convert.
*/
static bool promptIfRequired(QWidget *parent, const QString &filePath, const std::function<bool()> &convert);
private:
QVBoxLayout *layout;
QLabel *label;

View file

@ -525,13 +525,6 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o
connect(games, &QPushButton::clicked, this, [this, name] { emit showGamesRequested(name); });
add(games);
// ── Invite (only while the inviter has a joinable game for this user) ────
if (!isSelf && online && gameInviteAvailable && gameInviteAvailable(name)) {
auto *invite = makeBtn(tr("Invite"), tr("Invite to your game"), actionArea, theme);
connect(invite, &QPushButton::clicked, this, [this, name] { emit inviteRequested(name); });
add(invite);
}
// ── Buddy / ignore (registered users only) ────────────────────────────────
if (!isSelf && isReg) {
if (isBuddy) {

View file

@ -9,7 +9,6 @@
#include <QMap>
#include <QPixmap>
#include <QStandardItemModel>
#include <functional>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
@ -150,17 +149,6 @@ public:
/** Re-pulls the avatar/card art for the currently shown user (e.g. after it loads). */
void refreshHeader();
/**
* Sets a predicate evaluated on every action-button rebuild. It receives
* the name of the user the popup currently shows; when it returns true an
* "Invite" button is shown. The popup itself never resolves the invite
* link, it just forwards the request.
*/
void setGameInviteAvailable(std::function<bool(const QString &userName)> available)
{
gameInviteAvailable = std::move(available);
}
signals:
void mouseEnteredPopup();
void mouseLeftPopup();
@ -171,7 +159,6 @@ signals:
// ── Action signals — connect to UserContextMenu::exec*() ──────────────────
void chatRequested(const QString &userName);
void inviteRequested(const QString &userName);
void detailsRequested(const QString &userName);
void showGamesRequested(const QString &userName);
void addBuddyRequested(const QString &userName);
@ -213,7 +200,6 @@ private:
QString currentUser;
ServerInfo_User currentUserInfo;
bool currentOnline = false;
std::function<bool(const QString &userName)> gameInviteAvailable;
UserInfoHeaderWidget *header;
QWidget *actionArea; ///< rebuilt per user

View file

@ -345,11 +345,6 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
&cardArtProvider->cache(), &cardArtParamsMap,
window()); // parented to main window so it floats above siblings
// The invite availability is scoped to the room this list belongs to,
// and gated on the room's buddy-only setting for the hovered user.
userInfoPopup->setGameInviteAvailable(
[this](const QString &userName) { return userContextMenu->hasGameInviteLink(userName); });
userInfoPopup->hide();
userInfoPopup->setWindowOpacity(0.0);
userInfoPopup->installEventFilter(this);
@ -667,8 +662,6 @@ void UserListWidget::connectPopupSignals()
// Wire all action signals to UserContextMenu::exec*()
connect(userInfoPopup, &UserInfoPopup::chatRequested, userContextMenu, &UserContextMenu::execChat);
connect(userInfoPopup, &UserInfoPopup::inviteRequested, this,
[this](const QString &userName) { userContextMenu->execInvite(userName); });
connect(userInfoPopup, &UserInfoPopup::detailsRequested, userContextMenu, &UserContextMenu::execDetails);
connect(userInfoPopup, &UserInfoPopup::showGamesRequested, userContextMenu, &UserContextMenu::execShowGames);
connect(userInfoPopup, &UserInfoPopup::addBuddyRequested, userContextMenu, &UserContextMenu::execAddToBuddy);

View file

@ -22,7 +22,6 @@
#include <QTextEdit>
#include <QTreeWidgetItem>
#include <functional>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
class QTreeWidget;

View file

@ -1091,8 +1091,7 @@ QList<GameInviteOption> TabSupervisor::getGameInviteLinksForRoom(int roomId) con
// The inviter may be in several games of the same room (hosting one and
// spectating another, for example). Return every game so the caller can
// let the user choose which one to invite to.
for (auto it = gameTabs.cbegin(); it != gameTabs.cend(); ++it) {
TabGame *tab = it.value();
for (TabGame *tab : gameTabs) {
GameMetaInfo *metaInfo = tab->getGame()->getGameMetaInfo();
if (metaInfo->proto().room_id() != roomId) {
continue;

View file

@ -10,6 +10,8 @@
#include "../visual_deck_storage_widget.h"
#include "deck_preview_deck_tags_display_widget.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QInputDialog>
#include <QLabel>
@ -497,6 +499,21 @@ void DeckPreviewWidget::actDeleteFile()
// The folder widget removes this preview once the row is gone.
}
static bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath)
{
QFileInfo fileInfo(filePath);
QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod");
if (QFile::exists(newFileName)) {
QMessageBox::StandardButton reply =
QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"),
QObject::tr("A .cod version of this deck already exists. Overwrite it?"),
QMessageBox::Yes | QMessageBox::No);
return reply == QMessageBox::Yes;
}
return true; // Safe to proceed
}
/**
* Checks if the deck's file format supports tags.
* If not, then prompt the user for file conversion.
@ -504,8 +521,45 @@ void DeckPreviewWidget::actDeleteFile()
*/
bool DeckPreviewWidget::promptFileConversionIfRequired()
{
return DialogConvertDeckToCodFormat::promptIfRequired(this, filePath, [this] {
if (DeckFileFormat::getFormatFromName(filePath) == DeckFileFormat::Cockatrice) {
return true;
}
// Retrieve saved preference if the prompt is disabled
if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) {
if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) {
return false;
}
if (!confirmOverwriteIfExists(this, filePath)) {
return false;
}
model->convertToCockatriceFormat(row());
return true;
});
}
// Show the dialog to the user
DialogConvertDeckToCodFormat conversionDialog(this);
if (conversionDialog.exec() != QDialog::Accepted) {
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(
!conversionDialog.dontAskAgain());
SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false);
return false;
}
// Try to convert file
if (!confirmOverwriteIfExists(this, filePath)) {
return false;
}
model->convertToCockatriceFormat(row());
if (conversionDialog.dontAskAgain()) {
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false);
SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true);
}
return true;
}

View file

@ -135,9 +135,6 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica
cmdForgotPasswordReset.set_token(token.toStdString());
if (!password.isEmpty() && serverSupportsChallengeResponse) {
hashedPassword = PasswordHasher::generatePasswordVerifier(password);
// Only this branch yields a challenge-response verifier worth persisting; the
// legacy-hash and plaintext branches below must not be saved under the password key.
derivedVerifier = hashedPassword;
cmdForgotPasswordReset.set_hashed_new_password(hashedPassword.toStdString());
} else if (!password.isEmpty() && serverSupportsPasswordHash) {
auto passwordSalt = PasswordHasher::generateRandomSalt();
@ -287,7 +284,7 @@ void RemoteClient::submitPasswordVerifierResponse(const Response &response)
if (response.response_code() == Response::RespOk) {
qCDebug(RemoteClientLog) << "Password verifier migrated successfully";
if (!pendingVerifier.isEmpty()) {
emit sigPasswordVerifierReady(pendingVerifier);
emit sigPasswordVerifierReady(lastHostname, userName, pendingVerifier);
pendingVerifier.clear();
}
} else {
@ -322,22 +319,10 @@ void RemoteClient::passwordSaltResponse(const Response &response)
}
key = PasswordHasher::computeHash(password, passwordSalt).toUtf8();
} else if (!password.isEmpty()) {
// A hostile server must not be able to make us run or allocate for
// unreasonable scrypt parameters.
const int n = resp.has_n() ? resp.n() : SCRYPT_N;
const int r = resp.has_r() ? resp.r() : SCRYPT_R;
const int p = resp.has_p() ? resp.p() : SCRYPT_P;
if (!PasswordHasher::costParamsAreSane(n, r, p)) {
emit loginError(Response::RespClientUpdateRequired,
QStringLiteral("The server requested unreasonable scrypt cost parameters."), 0, {});
return;
}
key = PasswordHasher::deriveKey(password, QByteArray::fromBase64(passwordSalt.toUtf8()), n, r, p);
if (key.isEmpty()) {
emit loginError(Response::RespClientUpdateRequired, QStringLiteral("Unable to derive verifier."), 0,
{});
return;
}
derivedVerifier = QString("$scrypt$%1$%2$%3$%4$%5")
.arg(n)
.arg(r)
@ -388,7 +373,7 @@ void RemoteClient::loginResponse(const Response &response)
// The account still used the legacy password format; upgrade it to scrypt.
doSubmitPasswordVerifier();
} else if (!derivedVerifier.isEmpty()) {
emit sigPasswordVerifierReady(derivedVerifier);
emit sigPasswordVerifierReady(lastHostname, userName, derivedVerifier);
derivedVerifier.clear();
}
@ -812,11 +797,8 @@ void RemoteClient::submitForgotPasswordResetResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
emit sigForgotPasswordSuccess();
// Persist only a real scrypt verifier; a legacy hash must not be stored under
// the password key, where it would break future challenge-response logins.
if (!derivedVerifier.isEmpty()) {
emit sigPasswordVerifierReady(derivedVerifier);
derivedVerifier.clear();
if (!hashedPassword.isEmpty()) {
emit sigPasswordVerifierReady(lastHostname, userName, hashedPassword);
}
} else {
emit sigForgotPasswordError();

View file

@ -55,10 +55,8 @@ signals:
const QString &_userName,
const QString &_email);
//! \brief Emitted once a scrypt verifier for the given account is known and
//! can be persisted instead of the plaintext password. The receiving side
//! should only store it for the connection it is currently negotiating, so
//! the hostname and user name are intentionally not part of the signal.
void sigPasswordVerifierReady(const QString &verifier);
//! can be persisted instead of the plaintext password.
void sigPasswordVerifierReady(const QString &hostname, const QString &userName, const QString &verifier);
private slots:
void slotConnected();
void readData();

View file

@ -232,6 +232,6 @@ message Command_SubmitPasswordVerifier {
extend SessionCommand {
optional Command_SubmitPasswordVerifier ext = 1026;
}
// Full verifier string to store, e.g. "$scrypt$32768$8$1$<salt>$<verifier>"
// Full verifier string to store, e.g. "$pbkdf2-sha512$210000$<salt>$<verifier>"
required string password_verifier = 1;
}

View file

@ -1,5 +1,6 @@
#include "rng_sfmt.h"
#include <QDateTime>
#include <algorithm>
#include <climits>
#include <stdexcept>
@ -10,6 +11,12 @@
#define UINT64_MAX (~(uint64_t)0)
#endif
RNG_SFMT::RNG_SFMT(QObject *parent) : RNG_Abstract(parent)
{
// initialize the random number generator with a 32bit integer seed (timestamp)
sfmt_init_gen_rand(&sfmt, QDateTime::currentDateTime().toSecsSinceEpoch());
}
RNG_SFMT::RNG_SFMT(uint64_t seed, QObject *parent) : RNG_Abstract(parent)
{
// initialize the random number generator with a 64bit seed, e.g. from a CSPRNG

View file

@ -36,6 +36,7 @@ private:
unsigned int cdf(unsigned int min, unsigned int max);
public:
explicit RNG_SFMT(QObject *parent = nullptr);
explicit RNG_SFMT(uint64_t seed, QObject *parent = nullptr);
unsigned int rand(int min, int max) override;
};

View file

@ -66,18 +66,11 @@ QByteArray PasswordHasher::deriveKey(const QString &password, const QByteArray &
if (EVP_PBE_scrypt(passwordUtf8.constData(), passwordUtf8.size(),
reinterpret_cast<const unsigned char *>(salt.constData()), salt.size(), n, r, p, maxmem,
reinterpret_cast<unsigned char *>(key.data()), key.size()) != 1) {
return QByteArray();
qFatal("PasswordHasher::deriveKey: EVP_PBE_scrypt failed");
}
return key;
}
bool PasswordHasher::costParamsAreSane(int n, int r, int p)
{
// Bounds adopted during review: n in [1024, 2**20] and a power of two, r in [1, 32],
// p in [1, 16]. Anything else is rejected before we allocate or derive for it.
return n >= 1024 && n <= (1 << 20) && (n & (n - 1)) == 0 && r >= 1 && r <= 32 && p >= 1 && p <= 16;
}
QString PasswordHasher::generatePasswordVerifier(const QString &password)
{
const QByteArray salt = CryptoUtil::randomBytes(SCRYPT_SALT_LENGTH);
@ -100,15 +93,15 @@ PasswordVerifier PasswordHasher::parsePasswordVerifier(const QString &stored)
bool ok = false;
const int n = parts.at(2).toInt(&ok);
if (!ok) {
if (!ok || n <= 0) {
return result;
}
const int r = parts.at(3).toInt(&ok);
if (!ok) {
if (!ok || r <= 0) {
return result;
}
const int p = parts.at(4).toInt(&ok);
if (!ok || !costParamsAreSane(n, r, p)) {
if (!ok || p <= 0) {
return result;
}
@ -133,20 +126,6 @@ bool PasswordHasher::isLegacyFormat(const QString &stored)
return !stored.startsWith("$");
}
bool PasswordHasher::verifyPassword(const QString &password, const QString &storedPasswordData)
{
if (isLegacyFormat(storedPasswordData)) {
return storedPasswordData == computeHash(password, storedPasswordData.left(16));
}
const PasswordVerifier verifier = parsePasswordVerifier(storedPasswordData);
if (!verifier.isValid) {
return false;
}
const QByteArray derived = deriveKey(password, verifier.salt, verifier.n, verifier.r, verifier.p);
return !derived.isEmpty() && constantTimeEquals(derived, verifier.verifier);
}
QByteArray PasswordHasher::computeResponse(const QByteArray &key, const QByteArray &nonce)
{
QByteArray response(EVP_MAX_MD_SIZE, '\0');

View file

@ -36,18 +36,14 @@ public:
static QString generateRandomSalt(const int len = 16);
static QString generateActivationToken();
/** @brief Derive the scrypt verifier for the given password, salt and cost parameters. Empty on failure. */
/** @brief Derive the scrypt verifier for the given password, salt and cost parameters. */
static QByteArray deriveKey(const QString &password, const QByteArray &salt, int n, int r, int p);
/** @brief True if the scrypt cost parameters are acceptable for server and client use. */
static bool costParamsAreSane(int n, int r, int p);
/** @brief Build a "$scrypt$<n>$<r>$<p>$<salt>$<verifier>" string with a fresh random salt. */
static QString generatePasswordVerifier(const QString &password);
/** @brief Parse a stored "$scrypt$..." string into its components. */
static PasswordVerifier parsePasswordVerifier(const QString &stored);
/** @brief True if the stored value is not in the scrypt format (legacy salt+hash). */
static bool isLegacyFormat(const QString &stored);
/** @brief True if the password matches the stored credential, whether legacy salt+hash or scrypt. */
static bool verifyPassword(const QString &password, const QString &storedPasswordData);
/** @brief HMAC-SHA256 of nonce keyed with the password verifier, used for challenge-response logins. */
static QByteArray computeResponse(const QByteArray &key, const QByteArray &nonce);
/** @brief Constant-time byte comparison. */

View file

@ -1,8 +1,5 @@
-- Servatrice db migration from version 36 to version 37
-- The column must hold "$scrypt$<n>$<r>$<p>$<salt>$<verifier>" (up to ~255 chars) and arbitrary
-- legacy base64 hashes, so it grows beyond the old 120-char size. varchar(255) is used as the
-- column type is promotion-safe and avoids the row-format change ALGORITHM=INSTANT cannot do.
ALTER TABLE `cockatrice_users` MODIFY `password_sha512` varchar(255) NOT NULL;
ALTER TABLE `cockatrice_users` MODIFY `password_sha512` char(255) NOT NULL, ALGORITHM=INSTANT;
UPDATE cockatrice_schema_version SET version=37 WHERE version=36;

View file

@ -103,6 +103,16 @@ password=123456
; Accept only registered users? default is false (accept unregistered users)
regonly=false
[security]
; How strictly new authentication features are enforced. Possible values:
; * legacy: only accept the legacy 1000-round SHA-512 password hashes;
; * mixed: accept both legacy hashes and challenge-response authentication (default);
; * strict: only accept challenge-response authentication from clients that support it,
; and reject plain password submissions. Legacy accounts are migrated to PBKDF2
; automatically on their next successful login.
authentication_strictness=mixed
[users]
; The minimum length a username can be
@ -350,22 +360,6 @@ max_users_websocket=500
; Maximum number of users that can connect from the same IP address; useful to avoid bots, default is 4
max_users_per_address=4
; How strictly new authentication features are enforced. Possible values:
; * legacy: accounts that still use the legacy 1000-round SHA-512 hash keep logging in with it
; (they are served the legacy salt, never a challenge-response nonce) and are never
; auto-migrated to scrypt. Already-migrated scrypt rows keep logging in via
; challenge-response. New credentials may use either format;
; * mixed: accept both legacy hashes and challenge-response authentication (default);
; * strict: only accept challenge-response authentication from clients that support it,
; and reject plain password submissions. Legacy accounts are migrated to scrypt
; automatically on their next successful login.
;
; Challenge-response verifiers are scrypt (RFC 7914, N=32768, r=8, p=1) stored as
; "$scrypt$<n>$<r>$<p>$<salt>$<verifier>". The stored verifier is password-equivalent:
; plaintext passwords never reach the client configuration and never go over the wire, but
; a database dump yields credentials that can answer a login challenge directly.
authentication_strictness=mixed
; You may want to allow an unlimited number of users from a trusted source. This setting can contain a
; comma-separed list of IP addresses which will allow an unlimited number of connections from each of the
; IP addresses listed (ignoring the max_users_per_address). Default is "127.0.0.1,::1"; example: "192.73.233.244,81.4.100.74"

View file

@ -28,7 +28,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_users` (
`admin` tinyint(1) NOT NULL,
`name` varchar(35) NOT NULL,
`realname` varchar(255) NOT NULL,
`password_sha512` varchar(255) NOT NULL,
`password_sha512` char(255) NOT NULL,
`email` varchar(255) NOT NULL,
`country` char(2) NOT NULL,
`avatar_bmp` mediumblob NOT NULL,

View file

@ -374,12 +374,6 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot
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;
@ -637,8 +631,7 @@ bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, c
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;
return true;
}
int Servatrice_DatabaseInterface::getUserIdInDB(const QString &name)
@ -1222,13 +1215,13 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user,
return false;
}
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) {
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) {
return false;
}

View file

@ -140,13 +140,11 @@ 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).
Event_ServerIdentification::ServerOptions serverOptions =
static_cast<Event_ServerIdentification::ServerOptions>(
Event_ServerIdentification::SupportsPasswordHash |
Event_ServerIdentification::SupportsChallengeResponseAuth);
Event_ServerIdentification::ServerOptions serverOptions = Event_ServerIdentification::SupportsPasswordHash;
if (servatrice->getAuthenticationStrictness() != Servatrice::AuthenticationLegacy) {
serverOptions = static_cast<Event_ServerIdentification::ServerOptions>(
serverOptions | Event_ServerIdentification::SupportsChallengeResponseAuth);
}
identEvent.set_server_options(serverOptions);
}
SessionEvent *identSe = prepareSessionEvent(identEvent);
@ -267,6 +265,7 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm
return cmdReportDetails(cmd.GetExtension(Command_ReportDetails::ext), rc);
case SessionCommand::SUBMIT_PASSWORD_VERIFIER:
return cmdSubmitPasswordVerifier(cmd.GetExtension(Command_SubmitPasswordVerifier::ext), rc);
break;
default:
return Response::RespFunctionNotAllowed;
}
@ -2471,8 +2470,9 @@ 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)) {
// In strict mode only scrypt verifiers are accepted for new accounts.
if (servatrice->requiresChallengeResponseAuth() &&
(passwordNeedsHash || PasswordHasher::isLegacyFormat(password))) {
return Response::RespClientUpdateRequired;
}
@ -2522,21 +2522,6 @@ bool AbstractServerSocketInterface::tooManyRegistrationAttempts(const QString &i
return false;
}
bool AbstractServerSocketInterface::acceptsCredentialFormat(bool passwordNeedsHash, const QString &password) const
{
// "scryptFormat" means the client sent a derived verifier rather than a password to hash ourselves.
const bool scryptFormat = !passwordNeedsHash && !PasswordHasher::isLegacyFormat(password);
// 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;
}
return true; // legacy and mixed accept either format
}
Response::ResponseCode AbstractServerSocketInterface::cmdActivateAccount(const Command_Activate &cmd,
ResponseContainer & /*rc*/)
{
@ -2871,8 +2856,9 @@ 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)) {
// In strict mode only scrypt verifiers are accepted.
if (servatrice->requiresChallengeResponseAuth() &&
(newPasswordNeedsHash || PasswordHasher::isLegacyFormat(newPassword))) {
return Response::RespClientUpdateRequired;
}
@ -3013,8 +2999,9 @@ 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)) {
// In strict mode only scrypt verifiers are accepted.
if (servatrice->requiresChallengeResponseAuth() &&
(passwordNeedsHash || PasswordHasher::isLegacyFormat(password))) {
return Response::RespClientUpdateRequired;
}
@ -3089,17 +3076,10 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(con
}
auto *re = new Response_PasswordSalt;
const bool challengeResponseEnabled = servatrice->getAuthenticationStrictness() != Servatrice::AuthenticationLegacy;
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);
re->set_nonce(nonce.constData(), nonce.size());
}
} else {
const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(storedPasswordData);
if (!verifier.isValid) {
@ -3111,7 +3091,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(con
re->set_r(verifier.r);
re->set_p(verifier.p);
re->set_needs_migration(false);
// scrypt rows are served challenge-response in every mode so migrated accounts never lock out.
}
if (challengeResponseEnabled) {
const QByteArray nonce = CryptoUtil::randomBytes(32);
setAuthNonce(nonce);
re->set_nonce(nonce.constData(), nonce.size());
@ -3223,22 +3205,12 @@ AbstractServerSocketInterface::cmdSubmitPasswordVerifier(const Command_SubmitPas
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 ||
if (passwordVerifier.isEmpty() || passwordVerifier.length() > MAX_NAME_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;
}

View file

@ -80,7 +80,6 @@ signals:
protected:
void logDebugMessage(const QString &message) override;
bool tooManyRegistrationAttempts(const QString &ipAddress);
bool acceptsCredentialFormat(bool passwordNeedsHash, const QString &password) const;
virtual void writeToSocket(QByteArray &data) = 0;
virtual void flushSocket() = 0;

View file

@ -104,69 +104,6 @@ TEST(PasswordHashTest, ConstantTimeEquals)
ASSERT_FALSE(PasswordHasher::constantTimeEquals(QByteArray("short"), QByteArray("longer")));
}
TEST(PasswordHashTest, CostParamsAreSane)
{
// Accept the recommended interactive parameters and the RFC 7914 test vector's.
ASSERT_TRUE(PasswordHasher::costParamsAreSane(SCRYPT_N, SCRYPT_R, SCRYPT_P));
ASSERT_TRUE(PasswordHasher::costParamsAreSane(1024, 8, 16));
// n must be in [1024, 2**20] and a power of two.
ASSERT_FALSE(PasswordHasher::costParamsAreSane(512, 8, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1 << 21, 8, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1025, 8, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(0, 8, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(-1024, 8, 1));
// r in [1, 32], p in [1, 16].
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 0, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 33, 1));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 8, 0));
ASSERT_FALSE(PasswordHasher::costParamsAreSane(1024, 8, 17));
}
TEST(PasswordHashTest, ParsePasswordVerifierRejectsHostileCostParams)
{
// 16 bytes of salt and 64 bytes of verifier, base64 encoded.
const QString saltB64 = QLatin1String("c2FsdHNhbHRzYWx0c2FsdA==");
const QString verifierB64 = QString(QByteArray(SCRYPT_VERIFIER_LENGTH, '\x42').toBase64());
ASSERT_TRUE(
PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid);
// n not a power of two, below 1024, or above 2**20.
ASSERT_FALSE(
PasswordHasher::parsePasswordVerifier(QString("$scrypt$1025$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid);
ASSERT_FALSE(
PasswordHasher::parsePasswordVerifier(QString("$scrypt$512$8$1$%1$%2").arg(saltB64).arg(verifierB64)).isValid);
ASSERT_FALSE(
PasswordHasher::parsePasswordVerifier(QString("$scrypt$1073741824$8$1$%1$%2").arg(saltB64).arg(verifierB64))
.isValid);
// r and p out of range.
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$33$1$%1$%2").arg(saltB64).arg(verifierB64))
.isValid);
ASSERT_FALSE(PasswordHasher::parsePasswordVerifier(QString("$scrypt$1024$8$17$%1$%2").arg(saltB64).arg(verifierB64))
.isValid);
}
TEST(PasswordHashTest, VerifyPasswordLegacyRow)
{
const QString salt = PasswordHasher::generateRandomSalt();
const QString legacyStored = PasswordHasher::computeHash("correct horse", salt);
ASSERT_TRUE(PasswordHasher::verifyPassword("correct horse", legacyStored));
ASSERT_FALSE(PasswordHasher::verifyPassword("battery staple", legacyStored));
}
TEST(PasswordHashTest, VerifyPasswordScryptRow)
{
const QString scryptStored = PasswordHasher::generatePasswordVerifier("correct horse");
// Regression for the changeUserPassword bug that re-hashed the old password with
// a 16-char salt torn out of the "$scrypt$..." string, which could never match.
ASSERT_TRUE(PasswordHasher::verifyPassword("correct horse", scryptStored));
ASSERT_FALSE(PasswordHasher::verifyPassword("battery staple", scryptStored));
ASSERT_FALSE(PasswordHasher::verifyPassword("correct horse", "garbage"));
}
} // namespace
int main(int argc, char **argv)