mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 01:23:24 -08:00
[Server] Store a scrypt verifier when an admin resets a password (#7679)
The temporary password was written as a legacy salt+hash row, which makes the next login look like an authentication downgrade. A client holding a saved verifier refuses that downgrade, so the reset locked the account out of every modern client. Store the verifier and, where security/legacy_backup_key is configured, keep a sealed legacy hash of the same temporary password as the downgrade backup, so pre-challenge clients keep the credential path the reset used to provide. Without the key no row shape serves both: the verifier and the legacy hash cannot share one column, so those clients have no path at all. Warn at reset time that the new password has no pre-challenge path, and say so in servatrice.ini.example instead of promising that a reset gets them back in. Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
parent
e08402ac94
commit
ef4574d2b9
4 changed files with 91 additions and 16 deletions
|
|
@ -455,9 +455,11 @@ authentication_strictness=mixed
|
||||||
; the scrypt verifier, so old clients can still log in during the window below without
|
; the scrypt verifier, so old clients can still log in during the window below without
|
||||||
; the database holding a fast-crackable hash in the clear.
|
; the database holding a fast-crackable hash in the clear.
|
||||||
; Generate with: openssl rand -hex 32
|
; Generate with: openssl rand -hex 32
|
||||||
; If unset, migrated accounts have no downgrade window: old clients must use a password
|
; If unset there is no downgrade path at all: a migrated account keeps only a scrypt
|
||||||
; reset to get back in. Rotating the key invalidates existing backups (they become
|
; verifier, and an administrator's password reset stores a verifier too, so pre-challenge
|
||||||
; unusable immediately; expired ones are still purged).
|
; clients cannot log in with the temporary password either. Set the key if you still serve
|
||||||
|
; them. Rotating the key invalidates existing backups (they become unusable immediately;
|
||||||
|
; expired ones are still purged).
|
||||||
legacy_backup_key=
|
legacy_backup_key=
|
||||||
|
|
||||||
; Days a legacy password backup may go unused before it is purged from the database.
|
; Days a legacy password backup may go unused before it is purged from the database.
|
||||||
|
|
|
||||||
|
|
@ -755,6 +755,20 @@ QString Servatrice_DatabaseInterface::getUserPasswordData(const QString &user)
|
||||||
return query->value(0).toString();
|
return query->value(0).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString Servatrice_DatabaseInterface::sealLegacyBackup(const QString &storedName, const QString &credential)
|
||||||
|
{
|
||||||
|
const QByteArray key = server->getLegacyBackupKey();
|
||||||
|
if (key.isEmpty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const QByteArray aad = storedName.simplified().toUtf8();
|
||||||
|
const QString sealed = CryptoUtil::encryptSecret(credential.toUtf8(), key, aad);
|
||||||
|
if (sealed.isEmpty() || sealed.length() > MAX_LEGACY_BACKUP_LENGTH) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return sealed;
|
||||||
|
}
|
||||||
|
|
||||||
bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, const QString &passwordVerifier)
|
bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, const QString &passwordVerifier)
|
||||||
{
|
{
|
||||||
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
||||||
|
|
@ -779,18 +793,13 @@ bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, c
|
||||||
|
|
||||||
QString sealedBackup;
|
QString sealedBackup;
|
||||||
QDateTime backupUsedAt;
|
QDateTime backupUsedAt;
|
||||||
if (PasswordHasher::isLegacyFormat(currentCredential)) {
|
if (PasswordHasher::isLegacyFormat(currentCredential) && !server->getLegacyBackupKey().isEmpty()) {
|
||||||
const QByteArray key = server->getLegacyBackupKey();
|
sealedBackup = sealLegacyBackup(storedName, currentCredential);
|
||||||
if (!key.isEmpty()) {
|
if (sealedBackup.isEmpty()) {
|
||||||
const QByteArray aad = storedName.simplified().toUtf8();
|
qCWarning(DatabaseInterfaceLog)
|
||||||
sealedBackup = CryptoUtil::encryptSecret(currentCredential.toUtf8(), key, aad);
|
<< "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path";
|
||||||
if (sealedBackup.isEmpty() || sealedBackup.length() > MAX_LEGACY_BACKUP_LENGTH) {
|
} else {
|
||||||
qCWarning(DatabaseInterfaceLog)
|
backupUsedAt = QDateTime::currentDateTimeUtc();
|
||||||
<< "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path";
|
|
||||||
sealedBackup.clear();
|
|
||||||
} else {
|
|
||||||
backupUsedAt = QDateTime::currentDateTimeUtc();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1644,6 +1653,54 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user,
|
||||||
return passwordQuery->numRowsAffected() > 0;
|
return passwordQuery->numRowsAffected() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Servatrice_DatabaseInterface::changeUserPasswordWithLegacyBackup(const QString &user,
|
||||||
|
const QString &passwordVerifier,
|
||||||
|
const QString &legacyCredential)
|
||||||
|
{
|
||||||
|
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkSql()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal against the stored row name, not the caller-supplied one: the name column is
|
||||||
|
// case-insensitive, so a differently-cased name would fail the tag check on open.
|
||||||
|
QSqlQuery *nameQuery = prepareQuery("SELECT name FROM {prefix}_users WHERE name = :name");
|
||||||
|
nameQuery->bindValue(":name", user);
|
||||||
|
if (!execSqlQuery(nameQuery) || !nameQuery->next()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QString storedName = nameQuery->value(0).toString();
|
||||||
|
|
||||||
|
const QString sealedBackup = sealLegacyBackup(storedName, legacyCredential);
|
||||||
|
const QDateTime backupUsedAt = sealedBackup.isEmpty() ? QDateTime() : QDateTime::currentDateTimeUtc();
|
||||||
|
if (sealedBackup.isEmpty()) {
|
||||||
|
// Without a backup a pre-challenge client has nothing to authenticate against: the
|
||||||
|
// row holds a verifier and the legacy hash it would send cannot match it. Say so,
|
||||||
|
// because the symptom - the temporary password appearing not to work - shows up on
|
||||||
|
// a different machine from the one that reset it.
|
||||||
|
qCWarning(DatabaseInterfaceLog) << "Stored a verifier without a legacy backup for user" << user
|
||||||
|
<< "- pre-challenge clients cannot use this password"
|
||||||
|
<< "- check security/legacy_backup_key";
|
||||||
|
}
|
||||||
|
|
||||||
|
QSqlQuery *query = prepareQuery("update {prefix}_users set password_sha512=:password, "
|
||||||
|
"password_legacy_backup=:backup, password_legacy_backup_used_at=:used_at, "
|
||||||
|
"passwordLastChangedDate = NOW() where name = :name");
|
||||||
|
query->bindValue(":password", passwordVerifier);
|
||||||
|
query->bindValue(":backup", sealedBackup);
|
||||||
|
query->bindValue(":used_at", backupUsedAt.isValid()
|
||||||
|
? QVariant(backupUsedAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")))
|
||||||
|
: QVariant());
|
||||||
|
query->bindValue(":name", storedName);
|
||||||
|
if (!execSqlQuery(query)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return query->numRowsAffected() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
void Servatrice_DatabaseInterface::setForcePasswordChange(const QString &user, bool force)
|
void Servatrice_DatabaseInterface::setForcePasswordChange(const QString &user, bool force)
|
||||||
{
|
{
|
||||||
if (!checkSql()) {
|
if (!checkSql()) {
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,9 @@ private:
|
||||||
bool checkUserIsIpBanned(const QString &ipAddress, QString &banReason, int &banSecondsRemaining);
|
bool checkUserIsIpBanned(const QString &ipAddress, QString &banReason, int &banSecondsRemaining);
|
||||||
/** Must be called after checkSql and server is known to be in auth mode. */
|
/** Must be called after checkSql and server is known to be in auth mode. */
|
||||||
bool checkUserIsNameBanned(QString const &userName, QString &banReason, int &banSecondsRemaining);
|
bool checkUserIsNameBanned(QString const &userName, QString &banReason, int &banSecondsRemaining);
|
||||||
|
/** Seal a legacy credential for the account's downgrade backup; empty when the
|
||||||
|
* backup key is unset or the sealed value does not fit the column. */
|
||||||
|
QString sealLegacyBackup(const QString &storedName, const QString &credential);
|
||||||
bool isStrictModeEnabled(bool &ok) const;
|
bool isStrictModeEnabled(bool &ok) const;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
|
|
@ -192,6 +195,11 @@ public:
|
||||||
const QString &targetName,
|
const QString &targetName,
|
||||||
const QString &channel = QString()) override;
|
const QString &channel = QString()) override;
|
||||||
bool changeUserPassword(const QString &user, const QString &password, bool passwordNeedsHash) override;
|
bool changeUserPassword(const QString &user, const QString &password, bool passwordNeedsHash) override;
|
||||||
|
/** Replace a credential with an already-derived verifier, keeping a sealed legacy hash of the
|
||||||
|
* same password as the downgrade backup for pre-challenge clients. */
|
||||||
|
bool changeUserPasswordWithLegacyBackup(const QString &user,
|
||||||
|
const QString &passwordVerifier,
|
||||||
|
const QString &legacyCredential);
|
||||||
bool changeUserPassword(const QString &user,
|
bool changeUserPassword(const QString &user,
|
||||||
const QString &oldPassword,
|
const QString &oldPassword,
|
||||||
bool oldPasswordNeedsHash,
|
bool oldPasswordNeedsHash,
|
||||||
|
|
|
||||||
|
|
@ -2641,7 +2641,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdResetUserPassword(const
|
||||||
}
|
}
|
||||||
|
|
||||||
const QString tempPassword = PasswordHasher::generateRandomSalt();
|
const QString tempPassword = PasswordHasher::generateRandomSalt();
|
||||||
if (!sqlInterface->changeUserPassword(userName, tempPassword, true)) {
|
// Store a scrypt verifier rather than a legacy salt+hash: a legacy row makes the next
|
||||||
|
// login look like an authentication downgrade, which modern clients refuse when they
|
||||||
|
// still hold a verifier for the account. Where security/legacy_backup_key is configured,
|
||||||
|
// keep a sealed legacy hash of the same temporary password as the downgrade backup so
|
||||||
|
// pre-challenge clients keep the path the reset used to provide; without the key they
|
||||||
|
// have none, which changeUserPasswordWithLegacyBackup warns about.
|
||||||
|
const QString verifier = PasswordHasher::generatePasswordVerifier(tempPassword);
|
||||||
|
const QString legacyCredential = PasswordHasher::computeHash(tempPassword, PasswordHasher::generateRandomSalt());
|
||||||
|
if (!sqlInterface->changeUserPasswordWithLegacyBackup(userName, verifier, legacyCredential)) {
|
||||||
return Response::RespInternalError;
|
return Response::RespInternalError;
|
||||||
}
|
}
|
||||||
sqlInterface->setForcePasswordChange(userName, true);
|
sqlInterface->setForcePasswordChange(userName, true);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue