[Security] Harden credential format acceptance and challenge nonce validation

This commit is contained in:
Lukas Brübach 2026-09-02 20:12:44 +02:00 committed by GitHub
parent 709233d977
commit 46be02fbcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 43 additions and 16 deletions

View file

@ -1,8 +1,9 @@
-- 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.
-- legacy base64 hashes, so it grows beyond the old 120-char size. varchar(255) is used because
-- widening a CHAR requires a table rebuild, which ALGORITHM=INSTANT cannot perform — dropping the
-- clause lets the server pick a suitable algorithm (and any row-format change is avoided anyway).
ALTER TABLE `cockatrice_users` MODIFY `password_sha512` varchar(255) NOT NULL;
UPDATE cockatrice_schema_version SET version=37 WHERE version=36;
UPDATE cockatrice_schema_version SET version=37 WHERE version=36;

View file

@ -357,6 +357,13 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot
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.
@ -366,7 +373,7 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot
}
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)) {
if (nonce.isEmpty() || response.isEmpty() || !handler->isAuthNonceValid(nonce, user)) {
return NotLoggedIn;
}

View file

@ -2524,8 +2524,16 @@ bool AbstractServerSocketInterface::tooManyRegistrationAttempts(const QString &i
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);
// 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
@ -2534,7 +2542,8 @@ bool AbstractServerSocketInterface::acceptsCredentialFormat(bool passwordNeedsHa
if (servatrice->getAuthenticationStrictness() == Servatrice::AuthenticationStrict) {
return scryptFormat;
}
return true; // legacy and mixed accept either format
// 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,
@ -3097,7 +3106,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(con
// 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);
setAuthNonce(nonce, userName);
re->set_nonce(nonce.constData(), nonce.size());
}
} else {
@ -3113,7 +3122,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(con
re->set_needs_migration(false);
// scrypt rows are served challenge-response in every mode so migrated accounts never lock out.
const QByteArray nonce = CryptoUtil::randomBytes(32);
setAuthNonce(nonce);
setAuthNonce(nonce, userName);
re->set_nonce(nonce.constData(), nonce.size());
}