[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
This commit is contained in:
Lukas Brübach 2026-08-04 11:36:10 +02:00 • committed by GitHub
parent 048fe247f4
commit 709233d977
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 834 additions and 30 deletions

View file

@ -2,6 +2,9 @@
#include <QCryptographicHash>
#include <libcockatrice/utility/cryptoutil.h>
#include <openssl/crypto.h>
#include <openssl/evp.h>
#include <openssl/hmac.h>
QString PasswordHasher::computeHash(const QString &password, const QString &salt)
{
@ -52,3 +55,114 @@ QString PasswordHasher::generateActivationToken()
{
return QString(CryptoUtil::randomBytes(16).toBase64().left(16));
}
QByteArray PasswordHasher::deriveKey(const QString &password, const QByteArray &salt, int n, int r, int p)
{
QByteArray key(SCRYPT_VERIFIER_LENGTH, '\0');
const QByteArray passwordUtf8 = password.toUtf8();
// EVP_PBE_scrypt aborts unless maxmem covers the required working memory,
// which is roughly 128 * n * r bytes (plus the small Salsa20/8 block array).
const auto maxmem = static_cast<quint64>(128) * n * r + static_cast<quint64>(128) * r * p + 4096;
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();
}
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);
const QByteArray verifier = deriveKey(password, salt, SCRYPT_N, SCRYPT_R, SCRYPT_P);
return QString("$scrypt$%1$%2$%3$%4$%5")
.arg(SCRYPT_N)
.arg(SCRYPT_R)
.arg(SCRYPT_P)
.arg(QString(salt.toBase64()))
.arg(QString(verifier.toBase64()));
}
PasswordVerifier PasswordHasher::parsePasswordVerifier(const QString &stored)
{
PasswordVerifier result;
const QStringList parts = stored.split("$");
if (parts.size() != 7 || parts.at(1) != "scrypt") {
return result;
}
bool ok = false;
const int n = parts.at(2).toInt(&ok);
if (!ok) {
return result;
}
const int r = parts.at(3).toInt(&ok);
if (!ok) {
return result;
}
const int p = parts.at(4).toInt(&ok);
if (!ok || !costParamsAreSane(n, r, p)) {
return result;
}
const QByteArray salt = QByteArray::fromBase64(parts.at(5).toUtf8());
const QByteArray verifier = QByteArray::fromBase64(parts.at(6).toUtf8());
if (salt.isEmpty() || verifier.size() != SCRYPT_VERIFIER_LENGTH) {
return result;
}
result.format = PasswordFormat::Scrypt;
result.n = n;
result.r = r;
result.p = p;
result.salt = salt;
result.verifier = verifier;
result.isValid = true;
return result;
}
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');
unsigned int responseLength = 0;
if (HMAC(EVP_sha256(), key.constData(), key.size(), reinterpret_cast<const unsigned char *>(nonce.constData()),
nonce.size(), reinterpret_cast<unsigned char *>(response.data()), &responseLength) == nullptr) {
qFatal("PasswordHasher::computeResponse: HMAC failed");
}
response.resize(responseLength);
return response;
}
bool PasswordHasher::constantTimeEquals(const QByteArray &a, const QByteArray &b)
{
if (a.size() != b.size()) {
return false;
}
return CRYPTO_memcmp(a.constData(), b.constData(), a.size()) == 0;
}

View file

@ -1,14 +1,57 @@
#ifndef PASSWORDHASHER_H
#define PASSWORDHASHER_H
#include <QByteArray>
#include <QObject>
// scrypt cost parameters used for newly created password verifiers. These match
// the RFC 7914 recommended parameters for interactive use.
constexpr int SCRYPT_N = 32768;
constexpr int SCRYPT_R = 8;
constexpr int SCRYPT_P = 1;
constexpr int SCRYPT_SALT_LENGTH = 16;
constexpr int SCRYPT_VERIFIER_LENGTH = 64;
enum class PasswordFormat
{
None = 0,
Scrypt
};
struct PasswordVerifier
{
PasswordFormat format = PasswordFormat::None;
int n = 0;
int r = 0;
int p = 0;
QByteArray salt;
QByteArray verifier;
bool isValid = false;
};
class PasswordHasher
{
public:
static QString computeHash(const QString &password, const QString &salt);
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. */
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. */
static bool constantTimeEquals(const QByteArray &a, const QByteArray &b);
};
#endif