diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index 021f5157f..ac291fd7f 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -455,9 +455,11 @@ authentication_strictness=mixed ; 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. ; Generate with: openssl rand -hex 32 -; If unset, migrated accounts have no downgrade window: old clients must use a password -; reset to get back in. Rotating the key invalidates existing backups (they become -; unusable immediately; expired ones are still purged). +; If unset there is no downgrade path at all: a migrated account keeps only a scrypt +; verifier, and an administrator's password reset stores a verifier too, so pre-challenge +; 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= ; Days a legacy password backup may go unused before it is purged from the database. diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index b5c45065c..1b49c3a82 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -755,6 +755,20 @@ QString Servatrice_DatabaseInterface::getUserPasswordData(const QString &user) 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) { if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) { @@ -779,18 +793,13 @@ bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, c 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(); - } + if (PasswordHasher::isLegacyFormat(currentCredential) && !server->getLegacyBackupKey().isEmpty()) { + sealedBackup = sealLegacyBackup(storedName, currentCredential); + if (sealedBackup.isEmpty()) { + qCWarning(DatabaseInterfaceLog) + << "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path"; + } else { + backupUsedAt = QDateTime::currentDateTimeUtc(); } } @@ -1644,6 +1653,54 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, 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) { if (!checkSql()) { diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 6b38384e4..a27bd55b8 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -62,6 +62,9 @@ private: bool checkUserIsIpBanned(const QString &ipAddress, QString &banReason, int &banSecondsRemaining); /** Must be called after checkSql and server is known to be in auth mode. */ 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; protected: @@ -192,6 +195,11 @@ public: const QString &targetName, const QString &channel = QString()) 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, const QString &oldPassword, bool oldPasswordNeedsHash, diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 78593f6dc..963b98c9c 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -2641,7 +2641,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdResetUserPassword(const } 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; } sqlInterface->setForcePasswordChange(userName, true);