ソースを参照

Replace bcrypt challenge auth with direct password comparison (#9358)

* Replace bcrypt challenge auth with direct password comparison

Closes #2428

Highlights:

* Replaced the bcrypt challenge authentication flow with direct password comparison against the bcrypt hash stored in the user configuration.
  * Removed the `bcrypt.js` dependency, so authentication no longer depends on JavaScript.
  * Removed the nonce and challenge mechanism and the `nonce` endpoint from the `javascript` controller.
  * Removed the JavaScript and HTML code used for the previous authentication flow.
  * Removed the `<noscript>` blocks that were only needed for the previous authentication flow.
* Updated `FreshRSS_FormAuth::checkCredentials()` to accept the username, stored bcrypt hash, and plain-text password directly.
  * The stored password hash must still use the expected bcrypt format.
  * Empty or whitespace-only passwords are rejected even if the hash would otherwise match.
  * The bcrypt 72-byte limit is enforced during normal authentication.
  * Existing bcrypt passwords are handled correctly when the 72-byte limit cuts through a UTF-8 character.
  * Password input is no longer trimmed before authentication or password changes. Leading and trailing whitespace is therefore kept as part of the password.
* Added `FreshRSS_FormAuth::passwordRequirementsMet()` to replace `FreshRSS_password_Util::check()`.
  * New passwords must be between 7 and 72 bytes by default.
  * The minimum length check can be disabled where needed.
  * Password requirements are now checked server-side for all password creation and update paths.
* Added client-side byte-length validation for password fields using `TextEncoder` and `data-max-bytes`.
  * This gives better feedback for UTF-8 passwords.
  * Server-side validation remains authoritative.
* Updated password handling in authentication, re-authentication, self-deletion, user creation, and user update paths so passwords are not trimmed automatically.
* Added `FormAuthTest` to cover:
  * password length limits in bytes
  * UTF-8 passwords around the 72-byte limit
  * bcrypt truncation behavior during authentication
  * malformed and non-bcrypt hashes
  * empty passwords
  * authentication failures and log messages
* Removed `bcrypt.js` from the documented dependencies and updated the authentication documentation.
* Updated the installation and authentication views and JavaScript to match the new password handling.

This simplifies the authentication flow, removes the client-side bcrypt challenge, makes password validation consistent between the client and server, and adds more test coverage while keeping compatibility with existing bcrypt password hashes.

Because the password is now sent directly to the server instead of being processed by `bcrypt.js`, deployments should use HTTPS. Documentation for HTTPS setup can be handled separately.

* Add test case with password truncated down to 71 characters

* Compare log messages too during form auth tests

* Mark the `getLastLog()` method as impure

* Fix: Check password reqs against new password instead of current

* Fix: Add custom JS validator for UTF-8, handle cut-off UTF-8 during login

* Fix: Enforce password requirements everywhere server-side

* Add TODO for updateUser in CLI

* make readme

* Fix extra.js in install.php

* Don't report validity yet if the password is too short or not filled out

* Simplify checking of whether to report validity

* Fix: Do not trim password from user's input

* Also `trim: false` in some userController paths

* Use the proper argument order for PHPUnit's `assertSame()`

* Remove `maxlength` in favor of `data-max-bytes`

But add `pattern` again for some nojs support

* make readme

* composer run-script translations

* Update i18n string for web form admin setting

* i18n: fr

* Minor whitespace

---------

Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Inverle 17 時間 前
親
コミット
76e8993579
91 ファイル変更、639 行追加、1603 行削除
  1. 7 8
      README.fr.md
  2. 7 8
      README.md
  3. 19 29
      app/Controllers/authController.php
  4. 0 44
      app/Controllers/javascriptController.php
  5. 23 21
      app/Controllers/userController.php
  6. 0 1
      app/FreshRSS.php
  7. 53 10
      app/Models/FormAuth.php
  8. 0 4
      app/Models/ViewJavascript.php
  9. 0 11
      app/Utils/passwordUtil.php
  10. 1 1
      app/i18n/ar/admin.php
  11. 1 0
      app/i18n/ar/user.php
  12. 1 1
      app/i18n/az/admin.php
  13. 1 0
      app/i18n/az/user.php
  14. 1 1
      app/i18n/be/admin.php
  15. 1 0
      app/i18n/be/user.php
  16. 1 1
      app/i18n/cs/admin.php
  17. 1 0
      app/i18n/cs/user.php
  18. 1 1
      app/i18n/de/admin.php
  19. 1 0
      app/i18n/de/user.php
  20. 1 1
      app/i18n/el/admin.php
  21. 1 0
      app/i18n/el/user.php
  22. 1 1
      app/i18n/en-US/admin.php
  23. 1 0
      app/i18n/en-US/user.php
  24. 1 1
      app/i18n/en/admin.php
  25. 1 0
      app/i18n/en/user.php
  26. 1 1
      app/i18n/es/admin.php
  27. 1 0
      app/i18n/es/user.php
  28. 1 1
      app/i18n/fa/admin.php
  29. 1 0
      app/i18n/fa/user.php
  30. 1 1
      app/i18n/fi/admin.php
  31. 1 0
      app/i18n/fi/user.php
  32. 1 1
      app/i18n/fr/admin.php
  33. 1 0
      app/i18n/fr/user.php
  34. 1 1
      app/i18n/he/admin.php
  35. 1 0
      app/i18n/he/user.php
  36. 1 1
      app/i18n/hu/admin.php
  37. 1 0
      app/i18n/hu/user.php
  38. 1 1
      app/i18n/id/admin.php
  39. 1 0
      app/i18n/id/user.php
  40. 1 1
      app/i18n/it/admin.php
  41. 1 0
      app/i18n/it/user.php
  42. 1 1
      app/i18n/ja/admin.php
  43. 1 0
      app/i18n/ja/user.php
  44. 1 1
      app/i18n/ko/admin.php
  45. 1 0
      app/i18n/ko/user.php
  46. 1 1
      app/i18n/lt/admin.php
  47. 1 0
      app/i18n/lt/user.php
  48. 1 1
      app/i18n/lv/admin.php
  49. 1 0
      app/i18n/lv/user.php
  50. 1 1
      app/i18n/nl/admin.php
  51. 1 0
      app/i18n/nl/user.php
  52. 1 1
      app/i18n/oc/admin.php
  53. 1 0
      app/i18n/oc/user.php
  54. 1 1
      app/i18n/pl/admin.php
  55. 1 0
      app/i18n/pl/user.php
  56. 1 1
      app/i18n/pt-BR/admin.php
  57. 1 0
      app/i18n/pt-BR/user.php
  58. 1 1
      app/i18n/pt-PT/admin.php
  59. 1 0
      app/i18n/pt-PT/user.php
  60. 1 1
      app/i18n/ru/admin.php
  61. 1 0
      app/i18n/ru/user.php
  62. 1 1
      app/i18n/sk/admin.php
  63. 1 0
      app/i18n/sk/user.php
  64. 1 1
      app/i18n/tr/admin.php
  65. 1 0
      app/i18n/tr/user.php
  66. 1 1
      app/i18n/uk/admin.php
  67. 1 0
      app/i18n/uk/user.php
  68. 1 1
      app/i18n/zh-CN/admin.php
  69. 1 0
      app/i18n/zh-CN/user.php
  70. 1 1
      app/i18n/zh-TW/admin.php
  71. 1 0
      app/i18n/zh-TW/user.php
  72. 17 7
      app/install.php
  73. 4 6
      app/views/auth/formLogin.phtml
  74. 4 6
      app/views/auth/reauth.phtml
  75. 6 3
      app/views/auth/register.phtml
  76. 1 0
      app/views/helpers/javascript_vars.phtml
  77. 0 4
      app/views/javascript/nonce.phtml
  78. 6 3
      app/views/user/details.phtml
  79. 5 2
      app/views/user/manage.phtml
  80. 20 14
      app/views/user/profile.phtml
  81. 1 0
      cli/create-user.php
  82. 1 0
      cli/update-user.php
  83. 3 3
      docs/en/admins/09_AccessControl.md
  84. 18 0
      lib/Minz/Log.php
  85. 8 4
      lib/Minz/Request.php
  86. 27 98
      p/scripts/extra.js
  87. 6 36
      p/scripts/install.js
  88. 0 1221
      p/scripts/vendor/bcrypt.js
  89. 2 1
      phpcs.xml
  90. 339 0
      tests/app/Models/FormAuthTest.php
  91. 0 28
      tests/app/Utils/passwordUtilTest.php

+ 7 - 8
README.fr.md

@@ -236,11 +236,11 @@ Voir le [dépôt dédié à ces extensions](https://github.com/FreshRSS/Extensio
 
 | Langage | Progression | |
 | - | - | - |
-| العربية (ar) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Far+%2F%28TODO%7CDIRTY%29%24%2F) |
+| العربية (ar) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Far+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Azərbaycanca (az) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Faz+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Беларуская (be) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fbe+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Čeština (cs) | ■■■■■■■■・・ 81% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fcs+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Deutsch (de) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fde+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Deutsch (de) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fde+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Ελληνικά (el) | ■■■■■■■・・・ 75% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fel+%2F%28TODO%7CDIRTY%29%24%2F) |
 | English (en) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fen+%2F%28TODO%7CDIRTY%29%24%2F) |
 | English (United States) (en-US) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fen-US+%2F%28TODO%7CDIRTY%29%24%2F) |
@@ -256,16 +256,16 @@ Voir le [dépôt dédié à ces extensions](https://github.com/FreshRSS/Extensio
 | 한국어 (ko) | ■■■■■■■■・・ 80% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fko+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Lietuvių (lt) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Flt+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Latviešu (lv) | ■■■■■■■■・・ 81% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Flv+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Nederlands (nl) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fnl+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Nederlands (nl) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fnl+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Occitan (oc) | ■■■■■■■・・・ 73% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Foc+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Polski (pl) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fpl+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Português (Brasil) (pt-BR) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fpt-BR+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Português (Portugal) (pt-PT) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fpt-PT+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Português (Portugal) (pt-PT) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fpt-PT+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Русский (ru) | ■■■■■■■■■・ 98% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fru+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Slovenčina (sk) | ■■■■■■■■・・ 81% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fsk+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Türkçe (tr) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ftr+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Slovenčina (sk) | ■■■■■■■■・・ 80% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fsk+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Türkçe (tr) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ftr+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Українська (uk) | ■■■■■■■■■・ 98% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fuk+%2F%28TODO%7CDIRTY%29%24%2F) |
-| 简体中文 (zh-CN) | ■■■■■■■■■・ 96% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fzh-CN+%2F%28TODO%7CDIRTY%29%24%2F) |
+| 简体中文 (zh-CN) | ■■■■■■■■■・ 95% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fzh-CN+%2F%28TODO%7CDIRTY%29%24%2F) |
 | 正體中文 (zh-TW) | ■■■■■■■■■・ 97% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fzh-TW+%2F%28TODO%7CDIRTY%29%24%2F) |
 
 </translations>
@@ -311,7 +311,6 @@ et [l’API Fever](https://freshrss.github.io/FreshRSS/fr/users/06_Fever_API.htm
 * [SimplePie](https://simplepie.org/)
 * [php-http-304](https://alexandre.alapetite.fr/doc-alex/php-http-304/)
 * [lib_opml](https://framagit.org/marienfressinaud/lib_opml)
-* [bcrypt.js](https://github.com/dcodeIO/bcrypt.js)
 * [PhpGt/CssXPath](https://github.com/PhpGt/CssXPath)
 * [PHPMailer](https://github.com/PHPMailer/PHPMailer)
 * [Chart.js](https://www.chartjs.org)

+ 7 - 8
README.md

@@ -132,11 +132,11 @@ See the [repository dedicated to those extensions](https://github.com/FreshRSS/E
 
 | Language | Progress | |
 | - | - | - |
-| العربية (ar) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Far+%2F%28TODO%7CDIRTY%29%24%2F) |
+| العربية (ar) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Far+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Azərbaycanca (az) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Faz+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Беларуская (be) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fbe+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Čeština (cs) | ■■■■■■■■・・ 81% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fcs+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Deutsch (de) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fde+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Deutsch (de) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fde+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Ελληνικά (el) | ■■■■■■■・・・ 75% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fel+%2F%28TODO%7CDIRTY%29%24%2F) |
 | English (en) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fen+%2F%28TODO%7CDIRTY%29%24%2F) |
 | English (United States) (en-US) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fen-US+%2F%28TODO%7CDIRTY%29%24%2F) |
@@ -152,16 +152,16 @@ See the [repository dedicated to those extensions](https://github.com/FreshRSS/E
 | 한국어 (ko) | ■■■■■■■■・・ 80% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fko+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Lietuvių (lt) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Flt+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Latviešu (lv) | ■■■■■■■■・・ 81% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Flv+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Nederlands (nl) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fnl+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Nederlands (nl) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fnl+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Occitan (oc) | ■■■■■■■・・・ 73% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Foc+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Polski (pl) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fpl+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Português (Brasil) (pt-BR) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fpt-BR+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Português (Portugal) (pt-PT) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fpt-PT+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Português (Portugal) (pt-PT) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fpt-PT+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Русский (ru) | ■■■■■■■■■・ 98% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fru+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Slovenčina (sk) | ■■■■■■■■・・ 81% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fsk+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Türkçe (tr) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ftr+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Slovenčina (sk) | ■■■■■■■■・・ 80% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fsk+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Türkçe (tr) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ftr+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Українська (uk) | ■■■■■■■■■・ 98% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fuk+%2F%28TODO%7CDIRTY%29%24%2F) |
-| 简体中文 (zh-CN) | ■■■■■■■■■・ 96% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fzh-CN+%2F%28TODO%7CDIRTY%29%24%2F) |
+| 简体中文 (zh-CN) | ■■■■■■■■■・ 95% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fzh-CN+%2F%28TODO%7CDIRTY%29%24%2F) |
 | 正體中文 (zh-TW) | ■■■■■■■■■・ 97% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fzh-TW+%2F%28TODO%7CDIRTY%29%24%2F) |
 
 </translations>
@@ -207,7 +207,6 @@ and [Fever API](https://freshrss.github.io/FreshRSS/en/developers/06_Fever_API.h
 * [SimplePie](https://simplepie.org/)
 * [php-http-304](https://alexandre.alapetite.fr/doc-alex/php-http-304/)
 * [lib_opml](https://framagit.org/marienfressinaud/lib_opml)
-* [bcrypt.js](https://github.com/dcodeIO/bcrypt.js)
 * [PhpGt/CssXPath](https://github.com/PhpGt/CssXPath)
 * [PHPMailer](https://github.com/PHPMailer/PHPMailer)
 * [Chart.js](https://www.chartjs.org)

+ 19 - 29
app/Controllers/authController.php

@@ -94,12 +94,12 @@ class FreshRSS_auth_Controller extends FreshRSS_ActionController {
 	 * This action handles form login page.
 	 *
 	 * If this action is reached through a POST request, username and password
-	 * are compared to login the current user.
+	 * are compared against the bcrypt password hash stored in the user configuration file
+	 * to authenticate the current user.
 	 *
 	 * Parameters are:
-	 *   - nonce (default: false)
 	 *   - username (default: '')
-	 *   - challenge (default: '')
+	 *   - passwordPlain (default: '')
 	 *   - keep_logged_in (default: false)
 	 *
 	 * @throws Exception
@@ -108,32 +108,25 @@ class FreshRSS_auth_Controller extends FreshRSS_ActionController {
 		invalidateHttpCache();
 
 		FreshRSS_View::prependTitle(_t('gen.auth.login') . ' · ');
-		FreshRSS_View::appendScript(Minz_Url::display('/scripts/vendor/bcrypt.js?' . @filemtime(PUBLIC_PATH . '/scripts/vendor/bcrypt.js')));
 		$this->view->auth_autofills = FreshRSS_FormAuth::getAutofilledFields();
 
 		$limits = FreshRSS_Context::systemConf()->limits;
 		$this->view->cookie_days = (int)round($limits['cookie_duration'] / 86400, 1);
 
-		$isPOST = Minz_Request::isPost() && !Minz_Session::paramBoolean('POST_to_GET');
-		Minz_Session::_param('POST_to_GET');
-
-		if ($isPOST) {
-			$nonce = Minz_Session::paramString('nonce');
-			Minz_Session::_param('nonce', false);	// One-time: consume the challenge so it cannot be replayed.
+		if (Minz_Request::isPost()) {
 			$username = Minz_Request::paramString('username');
-			$challenge = Minz_Request::paramString('challenge');
+			$passwordPlain = Minz_Request::paramString('passwordPlain', plaintext: true, trim: false);
 			$ip_address = Minz_Request::connectionRemoteAddress();
 
-			if ($nonce === '') {
-				Minz_Log::warning("Invalid session during login for user={$username}, nonce={$nonce}, ip_address={$ip_address}");
+			if (!Minz_Session::paramBoolean('sessionOk')) {
+				Minz_Log::warning("Invalid session during login for user={$username}, ip_address={$ip_address}");
 				header('HTTP/1.1 403 Forbidden');
-				Minz_Session::_param('POST_to_GET', true);	//Prevent infinite internal redirect
 				Minz_Request::setBadNotification(_t('install.session.nok'));
-				Minz_Request::forward(['c' => 'auth', 'a' => 'login'], false);
+				Minz_Request::forward(['c' => 'auth', 'a' => 'login'], redirect: true);
 				return;
 			}
 
-			usleep(random_int(100, 10000));	//Primitive mitigation of timing attacks, in μs
+			usleep(random_int(100, 10000));	// Primitive mitigation of timing attacks, in μs
 
 			FreshRSS_Context::initUser($username);
 			if (!FreshRSS_Context::hasUserConf()) {
@@ -144,25 +137,25 @@ class FreshRSS_auth_Controller extends FreshRSS_ActionController {
 			}
 
 			if (!FreshRSS_Context::userConf()->enabled || FreshRSS_Context::userConf()->passwordHash == '') {
-				usleep(random_int(100, 5000));	//Primitive mitigation of timing attacks, in μs
+				usleep(random_int(100, 5000));	// Primitive mitigation of timing attacks, in μs
 				Minz_Error::error(403, _t('feedback.auth.login.invalid'), false);
 				return;
 			}
 
 			$ok = FreshRSS_FormAuth::checkCredentials(
-				$username, FreshRSS_Context::userConf()->passwordHash, $nonce, $challenge
+				$username, FreshRSS_Context::userConf()->passwordHash, $passwordPlain
 			);
 			if ($ok) {
-				// Set session parameter to give access to the user.
 				try {
 					Minz_Session::regenerateID('FreshRSS');
 				} catch (RuntimeException $e) {
 					Minz_Log::error("Session could not be regenerated during login for user={$username}, ip_address={$ip_address}: {$e->getMessage()}");
 					header('HTTP/1.1 500 Internal Server Error');
 					Minz_Request::setBadNotification(_t('install.session.nok'));
-					Minz_Request::forward(['c' => 'auth', 'a' => 'login'], false);
+					Minz_Request::forward(['c' => 'auth', 'a' => 'login'], redirect: true);
 					return;
 				}
+				// Set session parameters to give access to the user.
 				Minz_Session::_params([
 					Minz_User::CURRENT_USER => $username,
 					'passwordHash' => FreshRSS_Context::userConf()->passwordHash,
@@ -192,14 +185,14 @@ class FreshRSS_auth_Controller extends FreshRSS_ActionController {
 					showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0
 				);
 			} else {
-				Minz_Log::warning("Password mismatch for user={$username}, nonce={$nonce}, c={$challenge}, ip_address={$ip_address}");
+				Minz_Log::warning("Password mismatch for user={$username}, ip_address={$ip_address}");
 				header('HTTP/1.1 403 Forbidden');
-				Minz_Session::_param('POST_to_GET', true);	//Prevent infinite internal redirect
 				Minz_Request::setBadNotification(_t('feedback.auth.login.invalid'));
-				Minz_Request::forward(['c' => 'auth', 'a' => 'login'], false);
+				Minz_Request::forward(['c' => 'auth', 'a' => 'login'], redirect: true);
 			}
 		} else {
 			Minz_Session::deleteLegacyCookie('FreshRSS');	// Delete legacy cookie (before 1.29.0)
+			Minz_Session::_param('sessionOk', true);	// To be checked in the POST login request.
 		}
 	}
 
@@ -216,12 +209,10 @@ class FreshRSS_auth_Controller extends FreshRSS_ActionController {
 		}
 		if (Minz_Request::isPost()) {
 			$username = Minz_User::name() ?? '';
-			$nonce = Minz_Session::paramString('nonce');
-			Minz_Session::_param('nonce', false);	// One-time: consume the challenge so it cannot be replayed.
-			$challenge = Minz_Request::paramString('challenge');
+			$passwordPlain = Minz_Request::paramString('passwordPlain', plaintext: true, trim: false);
 			if (!FreshRSS_FormAuth::checkCredentials(
-				$username, FreshRSS_Context::userConf()->passwordHash, $nonce, $challenge
-				)) {
+				$username, FreshRSS_Context::userConf()->passwordHash, $passwordPlain
+			)) {
 				Minz_Request::setBadNotification(_t('feedback.auth.login.invalid'));
 			} else {
 				try {
@@ -238,7 +229,6 @@ class FreshRSS_auth_Controller extends FreshRSS_ActionController {
 			}
 		}
 		FreshRSS_View::prependTitle(_t('gen.auth.reauth.title') . ' · ');
-		FreshRSS_View::appendScript(Minz_Url::display('/scripts/vendor/bcrypt.js?' . @filemtime(PUBLIC_PATH . '/scripts/vendor/bcrypt.js')));
 		$this->view->auth_autofills = FreshRSS_FormAuth::getAutofilledFields();
 	}
 

+ 0 - 44
app/Controllers/javascriptController.php

@@ -66,48 +66,4 @@ class FreshRSS_javascript_Controller extends FreshRSS_ActionController {
 		$tagDAO = FreshRSS_Factory::createTagDao();
 		$this->view->tags = $tagDAO->listTags(precounts: true);
 	}
-
-	//For Web-form login
-
-	/**
-	 * @throws Exception
-	 */
-	public function nonceAction(): void {
-		header('Content-Type: application/json; charset=UTF-8');
-		header('Last-Modified: ' . gmdate('D, d M Y H:i:s \\G\\M\\T'));
-		header('Expires: 0');
-		header('Cache-Control: private, no-cache, no-store, must-revalidate');
-		header('Pragma: no-cache');
-
-		$user = Minz_Request::paramString('user');
-		if ($user === '') {
-			Minz_Error::error(400);
-			return;
-		}
-		$user_conf = FreshRSS_UserConfiguration::getForUser($user);
-		if ($user_conf !== null) {
-			try {
-				$s = $user_conf->passwordHash;
-				if (strlen($s) >= 60) {
-					//CRYPT_BLOWFISH Salt: "$2a$", a two digit cost parameter, "$", and 22 characters from the alphabet "./0-9A-Za-z".
-					$this->view->salt1 = substr($s, 0, 29);
-					$this->view->nonce = hash('sha256', FreshRSS_Context::systemConf()->salt . $user . random_bytes(32));
-					Minz_Session::_param('nonce', $this->view->nonce);
-					return;	//Success
-				}
-			} catch (Minz_Exception $me) {
-				Minz_Log::warning('Nonce failure: ' . $me->getMessage());
-			}
-		} else {
-			Minz_Log::notice('Nonce failure due to invalid username! ' . $user);
-		}
-		//Failure: Return random data.
-		$this->view->salt1 = sprintf('$2a$%02d$', FreshRSS_password_Util::BCRYPT_COST);
-		$alphabet = './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
-		for ($i = 22; $i > 0; $i--) {
-			$this->view->salt1 .= $alphabet[random_int(0, 63)];
-		}
-		$this->view->nonce = hash('sha256', 'failure' . rand());
-		Minz_Session::_param('nonce', $this->view->nonce);
-	}
 }

+ 23 - 21
app/Controllers/userController.php

@@ -91,6 +91,9 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
 		}
 
 		if ($passwordPlain != '') {
+			if (!FreshRSS_FormAuth::passwordRequirementsMet($passwordPlain, ['enforceMinLength' => true])) {
+				return false;
+			}
 			$passwordHash = FreshRSS_password_Util::hash($passwordPlain);
 			$userConfig->passwordHash = $passwordHash;
 			if ($user === Minz_User::name()) {
@@ -119,7 +122,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
 			}
 
 			$username = Minz_Request::paramString('username');
-			$newPasswordPlain = Minz_User::name() !== $username ? Minz_Request::paramString('newPasswordPlain', true) : '';
+			$newPasswordPlain = Minz_User::name() !== $username ? Minz_Request::paramString('newPasswordPlain', plaintext: true, trim: false) : '';
 
 			$ok = self::updateUser($username, null, $newPasswordPlain, [
 				'token' => Minz_Request::paramString('token') ?: null,
@@ -162,26 +165,28 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
 
 		FreshRSS_View::prependTitle(_t('conf.profile.title') . ' · ');
 
-		FreshRSS_View::appendScript(Minz_Url::display('/scripts/vendor/bcrypt.js?' . @filemtime(PUBLIC_PATH . '/scripts/vendor/bcrypt.js')));
-
 		if (Minz_Request::isPost() && Minz_User::name() != null) {
 			$old_email = FreshRSS_Context::userConf()->mail_login;
 
 			$email = Minz_Request::paramString('email');
 
-			$challenge = Minz_Request::paramString('challenge');
-			$newPasswordPlain = '';
-			if ($challenge !== '') {
+			$passwordPlain = Minz_Request::paramString('passwordPlain', plaintext: true, trim: false);
+			$newPasswordPlain = Minz_Request::paramString('newPasswordPlain', plaintext: true, trim: false);
+			$confirmPasswordPlain = Minz_Request::paramString('confirmPasswordPlain', plaintext: true, trim: false);
+			if ($passwordPlain !== '') {
 				$username = Minz_User::name();
-				$nonce = Minz_Session::paramString('nonce');
-				Minz_Session::_param('nonce', false);	// One-time: consume the challenge so it cannot be replayed.
 
-				$newPasswordPlain = Minz_Request::paramString('newPasswordPlain', plaintext: true);
-				$confirmPasswordPlain = Minz_Request::paramString('confirmPasswordPlain', plaintext: true);
+				$passwordRequirementsMet = FreshRSS_FormAuth::passwordRequirementsMet(
+					$newPasswordPlain,
+					['enforceMinLength' => true]
+				);
 
-				if (!FreshRSS_FormAuth::checkCredentials(
-					$username, FreshRSS_Context::userConf()->passwordHash, $nonce, $challenge
-					) || strlen($newPasswordPlain) < 7) {
+				// Currently the only requirement is checking whether the password length is within the range of 7-72 bytes.
+				// This limit is enforced in the form using a custom JS validator as well, so no custom error message is needed so far
+				// on the server side.
+				if (!$passwordRequirementsMet || !FreshRSS_FormAuth::checkCredentials(
+					$username, FreshRSS_Context::userConf()->passwordHash, $passwordPlain
+				)) {
 					Minz_Session::_param('open', true); // Auto-expand `change password` section
 					Minz_Request::bad(
 						_t('feedback.auth.login.invalid'),
@@ -190,7 +195,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
 					return;
 				}
 
-				if ($newPasswordPlain !== $confirmPasswordPlain) {
+				if ($confirmPasswordPlain !== $newPasswordPlain) {
 					Minz_Session::_param('open', true); // Auto-expand `change password` section
 					Minz_Request::bad(
 						_t('feedback.profile.passwords_dont_match'),
@@ -436,7 +441,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
 		if (Minz_Request::isPost()) {
 			$new_user_name = Minz_Request::paramString('new_user_name');
 			$email = Minz_Request::paramString('new_user_email');
-			$passwordPlain = Minz_Request::paramString('new_user_passwordPlain', true);
+			$passwordPlain = Minz_Request::paramString('new_user_passwordPlain', plaintext: true, trim: false);
 			$badRedirectUrl = [
 				'c' => Minz_Request::paramString('originController') ?: 'auth',
 				'a' => Minz_Request::paramString('originAction') ?: 'register',
@@ -456,7 +461,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
 				);
 			}
 
-			if (!FreshRSS_password_Util::check($passwordPlain)) {
+			if (!FreshRSS_FormAuth::passwordRequirementsMet($passwordPlain)) {
 				Minz_Request::bad(
 					_t('user.password.invalid'),
 					$badRedirectUrl
@@ -708,13 +713,10 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
 			$ok = true;
 			if ($self_deletion) {
 				// We check the password if it’s a self-destruction
-				$nonce = Minz_Session::paramString('nonce');
-				Minz_Session::_param('nonce', false);	// One-time: consume the challenge so it cannot be replayed.
-				$challenge = Minz_Request::paramString('challenge');
+				$passwordPlain = Minz_Request::paramString('passwordPlain', plaintext: true, trim: false);
 
 				$ok &= FreshRSS_FormAuth::checkCredentials(
-					$username, FreshRSS_Context::userConf()->passwordHash,
-					$nonce, $challenge
+					$username, FreshRSS_Context::userConf()->passwordHash, $passwordPlain
 				);
 				if (!$ok) {
 					Minz_Request::bad(_t('feedback.auth.login.invalid'), ['c' => 'user', 'a' => 'profile']);

+ 0 - 1
app/FreshRSS.php

@@ -158,7 +158,6 @@ class FreshRSS extends Minz_FrontController {
 			Minz_Request::is('user', 'delete') ||
 			Minz_Request::is('auth', 'logout') ||
 			Minz_Request::is('feed', 'actualize') ||
-			Minz_Request::is('javascript', 'nonce') ||
 			Minz_Request::is('error', 'index')
 		);
 		if ($email_not_verified && !$action_is_allowed) {

+ 53 - 10
app/Models/FormAuth.php

@@ -2,23 +2,66 @@
 declare(strict_types=1);
 
 class FreshRSS_FormAuth {
-	public static function checkCredentials(string $username, #[\SensitiveParameter] string $hash,
-		string $nonce, #[\SensitiveParameter] string $challenge): bool {
-		if (!FreshRSS_user_Controller::checkUsername($username) ||
-				!ctype_graph($hash) ||
-				!ctype_graph($challenge) ||
-				!ctype_alnum($nonce)) {
-			Minz_Log::debug("Invalid credential parameters: user={$username}, challenge={$challenge}, nonce={$nonce}");
+	public const MIN_PASSWORD_LENGTH = 7; // Only to be enforced when setting new passwords.
+	public const MAX_PASSWORD_LENGTH = 72; // Bcrypt limits password lengths to 72 bytes.
+
+	/**
+	 * @param array{enforceMinLength?: bool} $options
+	 */
+	public static function passwordRequirementsMet(#[\SensitiveParameter] string $passwordPlain, array $options = []): bool {
+		$enforceMinLength = ($options['enforceMinLength'] ?? true) === true;
+		if (strlen($passwordPlain) > self::MAX_PASSWORD_LENGTH) {
+			return false;
+		}
+		if ($enforceMinLength && strlen($passwordPlain) < self::MIN_PASSWORD_LENGTH) {
+			return false;
+		}
+		if (trim($passwordPlain) === '') {
+			return false;
+		}
+		return true;
+	}
+
+	public static function checkCredentials(string $username, #[\SensitiveParameter] string $hash, #[\SensitiveParameter] string $passwordPlain): bool {
+		if (!FreshRSS_user_Controller::checkUsername($username) || !ctype_graph($hash)) {
+			Minz_Log::debug("Invalid credential parameters: user={$username}");
 			return false;
 		}
 
 		// Expecting bcrypt format, see: https://en.wikipedia.org/wiki/Bcrypt#Description
-		if (!preg_match('/^\$2[aby]\$(0[4-9]|10)\$[.\/0-9A-Za-z]{53}$/', $challenge)) {
-			Minz_Log::debug("Invalid challenge format: user={$username}, challenge={$challenge}, nonce={$nonce}");
+		// FreshRSS has not historically used any other algorithm for password hash generation,
+		// so no upgrades are required.
+		if (!preg_match('/^\$2[aby]\$(0[4-9]|10)\$[.\/0-9A-Za-z]{53}$/', $hash)) {
+			Minz_Log::debug("Invalid hash format: user={$username}");
+			return false;
+		}
+
+		$byteLen = strlen($passwordPlain);
+		$utf8Prefix = mb_check_encoding($passwordPlain, 'UTF-8')
+			? mb_strcut($passwordPlain, 0, self::MAX_PASSWORD_LENGTH, 'UTF-8')
+			: '';
+
+		$cutOffUtf8Character = $byteLen > self::MAX_PASSWORD_LENGTH &&
+			strlen($utf8Prefix) < self::MAX_PASSWORD_LENGTH &&
+			mb_strlen($passwordPlain, 'UTF-8') === mb_strlen($utf8Prefix, 'UTF-8') + 1;
+
+		// Only reject logging in with a password longer than 72 bytes if the limit does not
+		// cut off a valid UTF-8 character, since this may be an existing bcrypt password.
+		if (!$cutOffUtf8Character && $byteLen > self::MAX_PASSWORD_LENGTH) {
+			Minz_Log::warning("Exceeded maximum allowed password length during authentication: user={$username}");
 			return false;
 		}
 
-		return password_verify($hash . $nonce, $challenge);
+		// https://www.php.net/manual/function.password-verify.php
+		if (password_verify($passwordPlain, $hash)) {
+			if (trim($passwordPlain) === '') {
+				Minz_Log::warning("Refusing authentication with empty zero-length password: user={$username}");
+				return false;
+			}
+			return true;
+		}
+
+		return false;
 	}
 
 	/**

+ 0 - 4
app/Models/ViewJavascript.php

@@ -2,14 +2,10 @@
 declare(strict_types=1);
 
 final class FreshRSS_ViewJavascript extends FreshRSS_View {
-
 	/** @var array<int,FreshRSS_Category> where the key is the category ID */
 	public array $categories;
 	/** @var array<int,FreshRSS_Feed> where the key is the feed ID */
 	public array $feeds;
 	/** @var array<int,FreshRSS_Tag> where the key is the label ID */
 	public array $tags;
-
-	public string $nonce;
-	public string $salt1;
 }

+ 0 - 11
app/Utils/passwordUtil.php

@@ -18,17 +18,6 @@ class FreshRSS_password_Util {
 		return $passwordHash;
 	}
 
-	/**
-	 * Verify the given password is valid.
-	 *
-	 * A valid password is a string of at least 7 characters.
-	 *
-	 * @return bool True if the password is valid, false otherwise
-	 */
-	public static function check(#[\SensitiveParameter] string $password): bool {
-		return strlen($password) >= 7;
-	}
-
 	public static function cryptAvailable(): bool {
 		$hash = '$2y$04$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG';
 		return $hash === @crypt('password', $hash);

+ 1 - 1
app/i18n/ar/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'السماح بالقراءة المجهولة لمقالات المستخدم الافتراضي (%s)',
 		'allow_anonymous_refresh' => 'السماح بالتحديث المجهول للمقالات',
 		'api_enabled' => 'السماح بالوصول إلى <abbr>API</abbr> <small>(مطلوب لتطبيقات الجوال ومشاركة استعلامات المستخدم)</small>',
-		'form' => 'نموذج الويب (تقليدي، يتطلب JavaScript)',
+		'form' => 'نموذج الويب (تقليدي، يتطلب JavaScript)',	// DIRTY
 		'http' => 'HTTP (متقدم: يديره خادم الويب، OIDC، SSO…)',
 		'none' => 'لا شيء (خطير)',
 		'title' => 'المصادقة',

+ 1 - 0
app/i18n/ar/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'كلمة المرور غير صالحة.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/az/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Standart istifadəçinin məqalələrinin anonim oxunmasına icazə ver (%s)',
 		'allow_anonymous_refresh' => 'Məqalələrin anonim yenilənməsinə icazə ver',
 		'api_enabled' => '<abbr>API</abbr> girişinə icazə ver <small>(mobil tətbiqlər və istifadəçi sorğularının paylaşılması üçün tələb olunur)</small>',
-		'form' => 'Veb forma (ənənəvi, JavaScript tələb edir)',
+		'form' => 'Veb forma (ənənəvi)',
 		'http' => 'HTTP (qabaqcıl: veb server, OIDC, SSO… tərəfindən idarə olunur)',
 		'none' => 'Yoxdur (təhlükəlidir)',
 		'title' => 'Kimlik doğrulama',

+ 1 - 0
app/i18n/az/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Parol yanlışdır.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/be/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Дазволіць ананімнае чытанне артыкулаў прадвызначанага карыстальніка (%s)',
 		'allow_anonymous_refresh' => 'Дазволіць ананімнае абнаўленне артыкулаў',
 		'api_enabled' => 'Дазволіць доступ да <abbr>API</abbr> <small>(патрабуецца для мабільных праграм і абагульвання карыстальніцкіх запытаў)</small>',
-		'form' => 'Вэб-форма (традыцыйны спосаб, патрабуе JavaScript)',
+		'form' => 'Вэб-форма (традыцыйны спосаб)',
 		'http' => 'HTTP (для дасведчаных: кіруецца вэб-серверам, OIDC, SSO…)',
 		'none' => 'Няма (небяспечна)',
 		'title' => 'Аўтэнтыфікацыя',

+ 1 - 0
app/i18n/be/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Няправільны пароль.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/cs/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Povolit anonymní čtení článků výchozího uživatele (%s)',
 		'allow_anonymous_refresh' => 'Povolit anonymní obnovení článků',
 		'api_enabled' => 'Povolit přístup k <abbr>API</abbr> <small>(vyžadováno pro mobilní aplikace and sharing user queries)</small>',	// DIRTY
-		'form' => 'Webový formulář (tradiční, vyžaduje JavaScript)',
+		'form' => 'Webový formulář (tradiční)',
 		'http' => 'HTTP (advanced: managed by Web server, OIDC, SSO…)',	// TODO
 		'none' => 'Žádný (nebezpečné)',
 		'title' => 'Ověřování',

+ 1 - 0
app/i18n/cs/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Heslo je neplatné.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/de/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Anonymes Lesen der Artikel des Standardbenutzers (%s) erlauben',
 		'allow_anonymous_refresh' => 'Anonymes Aktualisieren der Artikel erlauben',
 		'api_enabled' => '<abbr>API</abbr>-Zugriff erlauben <small>(für mobile Apps und zum Teilen von Benutzerabfragen erforderlich)</small>',
-		'form' => 'Webformular (traditionell, benötigt JavaScript)',
+		'form' => 'Webformular (traditionell)',
 		'http' => 'HTTP (fortgeschritten: vom Webserver verwaltet, OIDC, SSO…)',
 		'none' => 'Keine (gefährlich)',
 		'title' => 'Authentifizierung',

+ 1 - 0
app/i18n/de/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Das Passwort ist ungültig.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/el/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Επιτρέψτε την ανώνυμη ανάγνωση των άρθρων του προεπιλεγμένου χρήστη (%s)',
 		'allow_anonymous_refresh' => 'Επιτρέψτε την ανώνυμη ανανέωση των άρθρων',
 		'api_enabled' => 'Επιτρέψτε την πρόσβαση <abbr>API</abbr> <small>(απαιτείται για εφαρμογές κινητών και κοινοποίηση ερωτημάτων χρηστών)</small>',
-		'form' => 'Φόρμα ιστού (σύνηθες, απαιτεί JavaScript)',
+		'form' => 'Φόρμα ιστού (σύνηθες)',
 		'http' => 'HTTP (για προχωρημένους: διαχειρίζεται από τον Web server, OIDC, SSO…)',
 		'none' => 'Καμία (ριψοκίνδυνο)',
 		'title' => 'Πιστοποίηση',

+ 1 - 0
app/i18n/el/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Ο κωδικός πρόσβασης δεν είναι έγκυρος.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/en-US/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Allow anonymous reading of the default user’s articles (%s)',	// IGNORE
 		'allow_anonymous_refresh' => 'Allow anonymous refresh of the articles',	// IGNORE
 		'api_enabled' => 'Allow <abbr>API</abbr> access <small>(required for mobile apps and sharing user queries)</small>',	// IGNORE
-		'form' => 'Web form (traditional, requires JavaScript)',	// IGNORE
+		'form' => 'Web form (traditional)',	// IGNORE
 		'http' => 'HTTP (advanced: managed by Web server, OIDC, SSO…)',	// IGNORE
 		'none' => 'None (dangerous)',	// IGNORE
 		'title' => 'Authentication',	// IGNORE

+ 1 - 0
app/i18n/en-US/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'The password is invalid.',	// IGNORE
+		'provided_password_too_long' => 'The provided password is too long.',	// IGNORE
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/en/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Allow anonymous reading of the default user’s articles (%s)',
 		'allow_anonymous_refresh' => 'Allow anonymous refresh of the articles',
 		'api_enabled' => 'Allow <abbr>API</abbr> access <small>(required for mobile apps and sharing user queries)</small>',
-		'form' => 'Web form (traditional, requires JavaScript)',
+		'form' => 'Web form (traditional)',
 		'http' => 'HTTP (advanced: managed by Web server, OIDC, SSO…)',
 		'none' => 'None (dangerous)',
 		'title' => 'Authentication',

+ 1 - 0
app/i18n/en/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'The password is invalid.',
+		'provided_password_too_long' => 'The provided password is too long.',
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/es/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Permitir la lectura anónima de los artículos del usuario por defecto (%s)',
 		'allow_anonymous_refresh' => 'Permitir la actualización anónima de los artículos',
 		'api_enabled' => 'Concederle acceso a la <abbr>API</abbr> <small>(necesario para apps de móvil y compartir vistas de usuario)</small>',
-		'form' => 'Formulario Web (el más habitual, requiere JavaScript)',
+		'form' => 'Formulario Web (el más habitual)',
 		'http' => 'HTTP (avanzado: gestionado por el servidor, OIDC, SSO…)',
 		'none' => 'Ninguno (peligroso)',
 		'title' => 'Identificación',

+ 1 - 0
app/i18n/es/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'La contraseña no es válida.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/fa/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => ' اجازه خواندن ناشناس مقالات کاربر پیش‌فرض (%s)',
 		'allow_anonymous_refresh' => ' اجازه بازخوانی ناشناس مقالات را بدهید',
 		'api_enabled' => 'اجازه دسترسی به <abbr>API</abbr> <small>(برای برنامه‌های موبایل و اشتراک‌گذاری پرس‌وجوهای کاربر لازم است)</small>',
-		'form' => 'فرم وب (سنتی، نیازمند JavaScript)',
+		'form' => 'فرم وب (سنتی، نیازمند JavaScript)',	// DIRTY
 		'http' => 'HTTP (پیشرفته: مدیریت‌شده توسط وب‌سرور، OIDC، SSO و...)',
 		'none' => ' هیچ (خطرناک)',
 		'title' => ' احراز هویت',

+ 1 - 0
app/i18n/fa/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => ' رمز عبور نامعتبر است.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/fi/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Salli kirjautumattomien käyttäjien lukea oletuskäyttäjän artikkeleita (%s)',
 		'allow_anonymous_refresh' => 'Salli kirjautumattomien käyttäjien päivittää artikkelit',
 		'api_enabled' => 'Salli <abbr>API</abbr>-käyttö <small>(pakollinen kännykkäsovelluksille ja käyttäjän kyselyjen jakamiselle)</small>',
-		'form' => 'Web-lomake (perinteinen, käyttää JavaScriptiä)',
+		'form' => 'Web-lomake (perinteinen)',
 		'http' => 'HTTP (lisäasetukset: web-palvelin, OIDC, SSO…)',
 		'none' => 'Ei mitään (vaarallinen)',
 		'title' => 'Todentaminen',

+ 1 - 0
app/i18n/fi/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Salasana on väärä.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/fr/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Autoriser la lecture anonyme des articles de l’utilisateur par défaut (%s)',
 		'allow_anonymous_refresh' => 'Autoriser le rafraîchissement anonyme des flux',
 		'api_enabled' => 'Autoriser l’accès par <abbr>API</abbr> <small>(nécessaire pour les applications mobiles et pour partager les filtres utilisateurs)</small>',
-		'form' => 'Formulaire (traditionnel, requiert JavaScript)',
+		'form' => 'Formulaire (traditionnel)',
 		'http' => 'HTTP (avancé : contrôlé par serveur Web, OIDC, SSO…)',
 		'none' => 'Aucune (dangereux)',
 		'title' => 'Authentification',

+ 1 - 0
app/i18n/fr/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Le mot de passe est invalide.',
+		'provided_password_too_long' => 'Le mot de passe fourni est trop long !',
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/he/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'הרשאה לאנונימיים לקרוא את מאמרי משתמש ברירת המחדל (%s)',
 		'allow_anonymous_refresh' => 'הרשאה לאנונימיים לרענן את רשימת המאמרים',
 		'api_enabled' => 'הרשאת גישה ל <abbr>API</abbr> <small>(נדרש ליישומים סלולריים and sharing user queries)</small>',	// DIRTY
-		'form' => 'טופס אינטרנטי (מסורתי, דורש JavaScript)',
+		'form' => 'טופס אינטרנטי (מסורתי)',
 		'http' => 'HTTP (advanced: managed by Web server, OIDC, SSO…)',	// TODO
 		'none' => 'ללא (מסוכן)',
 		'title' => 'Authentication',	// TODO

+ 1 - 0
app/i18n/he/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'The password is invalid.',	// TODO
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/hu/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Névtelen felhasználók olvashatják az alapértelmezett felhasználó cikkeit (%s)',
 		'allow_anonymous_refresh' => 'Névtelen felhasználók frissíthetik a cikkeket',
 		'api_enabled' => ' <abbr>API</abbr> elérés engedélyezése <small>(mobilalkalmazásokhoz és felhasználói lekérdezés megosztásához szükséges )</small>',
-		'form' => 'Webes űrlap (hagyományos, JavaScript szükséges hozzá)',
+		'form' => 'Webes űrlap (hagyományos)',
 		'http' => 'HTTP (haladó: Web szerver kezeli, OIDC, SSO…)',
 		'none' => 'nincs (veszélyes)',
 		'title' => 'Hitelesítés',

+ 1 - 0
app/i18n/hu/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'A jelszó érvénytelen.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/id/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Izinkan pembacaan artikel pengguna default (%s) secara awanama',
 		'allow_anonymous_refresh' => 'Izinkan untuk memuat ulang artikel awanama',
 		'api_enabled' => 'Izinkan akses <abbr>API</abbr> <small>(Diperlukan untuk aplikasi seluler dan untuk membagikan pencarian pengguna)</small>',
-		'form' => 'Formulir Web (klasik, membutuhkan JavaScript)',
+		'form' => 'Formulir Web (klasik)',
 		'http' => 'HTTP (tingkat lanjut: dikelola oleh server Web, OIDC, SSO, dll.)',
 		'none' => 'Tidak ada (berbahaya)',
 		'title' => 'Autentikasi',

+ 1 - 0
app/i18n/id/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Kata sandi tidak valid.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/it/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Consenti la lettura agli utenti anonimi degli articoli dell utente predefinito (%s)',
 		'allow_anonymous_refresh' => 'Consenti agli utenti anonimi di aggiornare gli articoli',
 		'api_enabled' => 'Consentire l’accesso all’<abbr>API</abbr> <small>(necessario per le app e per la condivisione delle query degli utenti)</small>',
-		'form' => 'Web form (tradizionale, richiede JavaScript)',
+		'form' => 'Web form (tradizionale)',
 		'http' => 'HTTP (avanzato: gestito da server Web, OIDC, SSO…)',
 		'none' => 'Nessuno (pericoloso)',
 		'title' => 'Autenticazione',

+ 1 - 0
app/i18n/it/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'La password non è valida.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/ja/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'ログインしていないユーザーにもデフォルトユーザー(%s)の記事の閲覧を許可する',
 		'allow_anonymous_refresh' => 'ログインしていないユーザーにも記事の更新を許可する',
 		'api_enabled' => '<abbr>API</abbr>アクセスを許可する<small>(モバイルアプリやユーザークエリの共有に必要)</small>',
-		'form' => 'ウェブフォーム(JavaScriptが必要です)',
+		'form' => 'ウェブフォーム(JavaScriptが必要です)',	// DIRTY
 		'http' => 'HTTP(上級者向け:Webサーバー、OIDC、SSOなどで管理)',
 		'none' => 'なし(危険)',
 		'title' => '認証',

+ 1 - 0
app/i18n/ja/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'このパスワードは無効です。',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/ko/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => '누구나 기본 사용자의 글을 읽을 수 있도록 합니다(%s)',
 		'allow_anonymous_refresh' => '누구나 피드를 갱신할 수 있도록 합니다',
 		'api_enabled' => '<abbr>API</abbr> 사용을 허가합니다<small>(모바일 애플리케이션을 사용할 때 필요합니다	and sharing user queries)</small>',	// DIRTY
-		'form' => '웹폼 (전통적인 방식, 자바스크립트 필요)',
+		'form' => '웹폼 (전통적인 방식)',
 		'http' => 'HTTP (advanced: managed by Web server, OIDC, SSO…)',	// TODO
 		'none' => '사용하지 않음 (위험)',
 		'title' => '인증',

+ 1 - 0
app/i18n/ko/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => '이 비밀번호는 유효하지 않습니다.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/lt/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Leisti anoniminį numatytojo naudotojo straipsnių skaitymą (%s)',
 		'allow_anonymous_refresh' => 'Leisti anoniminį straipsnių atnaujinimą',
 		'api_enabled' => 'Leisti <abbr>API</abbr> prieigą <small>(reikalinga mobiliosioms programoms ir naudotojo užklausų bendrinimui)</small>',
-		'form' => 'Žiniatinklio forma (tradicinė, reikia JavaScript)',
+		'form' => 'Žiniatinklio forma (tradicinė)',
 		'http' => 'HTTP (pažengusiems: valdo žiniatinklio serveris, OIDC, SSO…)',
 		'none' => 'Joks (pavojinga)',
 		'title' => 'Tapatybės nustatymas',

+ 1 - 0
app/i18n/lt/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Slaptažodis netinkamas.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/lv/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Ļaut anonīmi lasīt noklusējuma lietotāja rakstus (%s)',
 		'allow_anonymous_refresh' => 'Atļaut anonīmu rakstu atsvaidzināšanu',
 		'api_enabled' => 'Atļaut <abbr>API</abbr> piekļuvi <small>(nepieciešams mobilajām lietotnēm and sharing user queries)</small>',	// DIRTY
-		'form' => 'Tīmekļa veidlapa (tradicionālā, nepieciešams JavaScript)',
+		'form' => 'Tīmekļa veidlapa (tradicionālā)',
 		'http' => 'HTTP (advanced: managed by Web server, OIDC, SSO…)',	// TODO
 		'none' => 'Nav (bīstami)',
 		'title' => 'Autentifikācija',

+ 1 - 0
app/i18n/lv/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Parole ir nederīga.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/nl/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Sta bezoekers toe om artikelen te lezen van de standaard gebruiker (%s)',
 		'allow_anonymous_refresh' => 'Sta bezoekers toe om de artikelen te vernieuwen',
 		'api_enabled' => 'Sta <abbr>API</abbr> toegang toe <small>(nodig voor mobiele apps en het delen van gebruikersqueries)</small>',
-		'form' => 'Web formulier (traditioneel, JavaScript vereist)',
+		'form' => 'Web formulier (traditioneel)',
 		'http' => 'HTTP (geavanceerd: beheerd door webserver, OIDC, SSO…)',
 		'none' => 'Geen (gevaarlijk)',
 		'title' => 'Authenticatie',

+ 1 - 0
app/i18n/nl/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Het wachtwoord is ongeldig.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/oc/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Autorizar la lectura anonima dels articles de l’utilizaire per defaut (%s)',
 		'allow_anonymous_refresh' => 'Autorizar l’actualizacion anonime dels fluxes',
 		'api_enabled' => 'Autorizar l’accès per <abbr>API</abbr><small>(necessari per las aplicacions mobil and sharing user queries)</small>',	// DIRTY
-		'form' => 'Formulari (tradicional, demanda JavaScript)',
+		'form' => 'Formulari (tradicional)',
 		'http' => 'HTTP (advanced: managed by Web server, OIDC, SSO…)',	// TODO
 		'none' => 'Cap (perilhós)',
 		'title' => 'Autentificacion',

+ 1 - 0
app/i18n/oc/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Lo senhal es invalid.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/pl/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Pozwól na anonimowy odczyt wiadomości domyślnego użytkownika (%s)',
 		'allow_anonymous_refresh' => 'Pozwól na anonimowe odświeżanie wiadomości',
 		'api_enabled' => 'Pozwól na dostęp przez <abbr>API</abbr> <small>(wymagane dla aplikacji na telefon i udostępniania zapytań użytkownika)</small>',
-		'form' => 'Formularz na stronie (tradycyjna, wymagany JavaScript)',
+		'form' => 'Formularz na stronie (tradycyjna)',
 		'http' => 'HTTP (zaawansowane: zarządzane przez serwer WWW, OIDC, SSO…)',
 		'none' => 'Brak (niebezpieczna)',
 		'title' => 'Uwierzytelnianie',

+ 1 - 0
app/i18n/pl/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Hasło nie jest prawidłowe.',
+		'provided_password_too_long' => 'Podane hasło jest zbyt długie.',
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/pt-BR/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Permitir a leitura anônima dos artigos pelo usuário padrão (%s)',
 		'allow_anonymous_refresh' => 'Permitir atualização anônima dos artigos',
 		'api_enabled' => 'Permitir acesso à <abbr>API</abbr> <small>(Necessário para aplicativos móveis e compartilhamento de consultas de usuários)</small>',
-		'form' => 'Formulário Web(tradicional, Necessita de JavaScript)',
+		'form' => 'Formulário Web(tradicional)',
 		'http' => 'HTTP (avançado: gerenciado por servidor web, OIDC, SSO…)',
 		'none' => 'Nenhum (Perigoso)',
 		'title' => 'Autenticação',

+ 1 - 0
app/i18n/pt-BR/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Senha incorreta',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/pt-PT/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Permitir a leitura anónima dos artigos pelo utilizador predefinido (%s)',
 		'allow_anonymous_refresh' => 'Permitir atualização anónima dos artigos',
 		'api_enabled' => 'Permitir acesso à <abbr>API</abbr> <small>(Necessário para aplicações móveis e partilha de pesquisas personalizadas)</small>',
-		'form' => 'Formulário web (tradicional, requer JavaScript)',
+		'form' => 'Formulário web (tradicional)',
 		'http' => 'HTTP (avançado: gerido pelo servidor web, OIDC, SSO…)',
 		'none' => 'Nenhum (Perigoso)',
 		'title' => 'Autenticação',

+ 1 - 0
app/i18n/pt-PT/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Palavra-passe incorreta',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/ru/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Разрешить анонимное чтение статей пользователя по умолчанию (%s)',
 		'allow_anonymous_refresh' => 'Разрешить анонимное обновление статей',
 		'api_enabled' => 'Позволить <abbr>API</abbr> доступ <small>(необходимо для мобильных приложений and sharing user queries)</small>',
-		'form' => 'Веб-форма (традиционный, необходим JavaScript)',
+		'form' => 'Веб-форма (традиционный)',
 		'http' => 'HTTP (продвинутый: управляется веб-сервером, OIDC, SSO…)',
 		'none' => 'Без аутентификации (небезопасно)',
 		'title' => 'Аутентификации',

+ 1 - 0
app/i18n/ru/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Неверный пароль.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/sk/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Povoliť čítanie článkov prednastaveného používateľa (%s) bez prihlásenia.',
 		'allow_anonymous_refresh' => 'Povoliť obnovenie článkov bez prihlásenia',
 		'api_enabled' => 'Povoliť prístup cez <abbr>API</abbr> <small>(vyžadujú mobilné aplikácie and sharing user queries)</small>',	// DIRTY
-		'form' => 'Webový formulár (traditičný, vyžaduje JavaScript)',
+		'form' => 'Webový formulár (traditičný)',
 		'http' => 'HTTP (advanced: managed by Web server, OIDC, SSO…)',	// TODO
 		'none' => 'Žiadny (nebezpečné)',
 		'title' => 'Prihlásenie',

+ 1 - 0
app/i18n/sk/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Neplatné heslo.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/tr/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Varsayılan kullanıcının makalelerinin (%s) anonim okunmasına izin ver',
 		'allow_anonymous_refresh' => 'Makalelerin anonim olarak yenilenmesine izin ver',
 		'api_enabled' => '<abbr>API</abbr> erişimine izin ver <small>(mobil uygulamalar ve kullanıcı sorgularını paylaşmak için gereklidir)</small>',
-		'form' => 'Web formu (geleneksel, JavaScript gerektirir)',
+		'form' => 'Web formu (geleneksel)',
 		'http' => 'HTTP (gelişmiş: Web sunucusu, OIDC, SSO vb. tarafından yönetilir)',
 		'none' => 'Yok (tehlikeli)',
 		'title' => 'Kimlik Doğrulama',

+ 1 - 0
app/i18n/tr/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Parola geçersiz.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/uk/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => 'Дозволити анонімне читання статей типового користувача (%s)',
 		'allow_anonymous_refresh' => 'Дозволити анонімне оновлення статей',
 		'api_enabled' => 'Дозволити доступ через <abbr>API</abbr> <small>(треба для мобільних програм і поширення користувацьких запитів)</small>',
-		'form' => 'Вебформа (традиційно, треба JavaScript)',
+		'form' => 'Вебформа (традиційно)',
 		'http' => 'HTTP (складно: за допомогою вебсервера, OIDC, SSO…)',
 		'none' => 'Нема (небезпечно)',
 		'title' => 'Вхід',

+ 1 - 0
app/i18n/uk/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => 'Хибний пароль.',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/zh-CN/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => '允许匿名阅读默认用户(%s)的文章',
 		'allow_anonymous_refresh' => '允许匿名刷新文章',
 		'api_enabled' => '允许 <abbr>API</abbr> 访问 <small>(用于手机应用和分享用户查询)</small>',
-		'form' => '网页表单(传统方式, 需要 JavaScript)',
+		'form' => '网页表单(传统方式)',
 		'http' => 'HTTP(高级:由 Web 服务器、OIDC、SSO 管理…)',
 		'none' => '无(危险)',
 		'title' => '认证',

+ 1 - 0
app/i18n/zh-CN/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => '无效密码',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 1 - 1
app/i18n/zh-TW/admin.php

@@ -15,7 +15,7 @@ return array(
 		'allow_anonymous' => '允許匿名閱讀預設使用者的文章 (%s)',
 		'allow_anonymous_refresh' => '允許匿名刷新文章',
 		'api_enabled' => '允許 <abbr>API</abbr> 存取 <small>(行動應用程式與分享使用者查詢需要)</small>',
-		'form' => 'Web 表單 (傳統方式,需要 JavaScript)',
+		'form' => 'Web 表單 (傳統方式,需要 JavaScript)',	// DIRTY
 		'http' => 'HTTP (進階: 由 Web 伺服器、OIDC、SSO 等管理)',
 		'none' => '無 (危險)',
 		'title' => '身份驗證',

+ 1 - 0
app/i18n/zh-TW/user.php

@@ -41,6 +41,7 @@ return array(
 	),
 	'password' => array(
 		'invalid' => '密碼無效',
+		'provided_password_too_long' => 'The provided password is too long.',	// TODO
 	),
 	'tos' => array(
 		'feedback' => array(

+ 17 - 7
app/install.php

@@ -212,7 +212,7 @@ function saveStep3(): bool {
 		}
 
 		$password_plain = param('passwordPlain', '');
-		if (FreshRSS_Context::systemConf()->auth_type === 'form' && $password_plain == '') {
+		if (FreshRSS_Context::systemConf()->auth_type === 'form' && !FreshRSS_FormAuth::passwordRequirementsMet($password_plain)) {
 			return false;
 		}
 
@@ -613,7 +613,7 @@ function printStep2(): void {
 				<div class="stick">
 					<input type="password" id="pass" name="pass" value="<?=
 						$db_password ?? '' ?>" tabindex="4" autocomplete="off" />
-					<a class="btn toggle-password" data-toggle="pass" tabindex="5"><?= FreshRSS_Themes::icon('key') ?></a>
+					<a class="btn toggle-password" tabindex="5"><?= FreshRSS_Themes::icon('key') ?></a>
 				</div>
 			</div>
 		</div>
@@ -694,12 +694,15 @@ function printStep3(): void {
 			<label class="group-name" for="passwordPlain"><?= _t('admin.user.password_form') ?></label>
 			<div class="group-controls">
 				<div class="stick">
-					<input type="password" id="passwordPlain" name="passwordPlain" pattern=".{7,}"
-						autocomplete="off" <?= $auth_type === 'form' ? ' required="required"' : '' ?> tabindex="3" />
-					<button type="button" class="btn toggle-password" data-toggle="passwordPlain" tabindex="4"><?= FreshRSS_Themes::icon('key') ?></button>
+					<input
+						type="password" id="passwordPlain" name="passwordPlain"
+						autocomplete="off" <?= $auth_type === 'form' ? ' required="required"' : '' ?> tabindex="3"
+						minlength="<?= FreshRSS_FormAuth::MIN_PASSWORD_LENGTH ?>"
+						pattern=".{<?= FreshRSS_FormAuth::MIN_PASSWORD_LENGTH ?>,<?= FreshRSS_FormAuth::MAX_PASSWORD_LENGTH ?>}"
+						data-max-bytes="<?= FreshRSS_FormAuth::MAX_PASSWORD_LENGTH ?>" />
+					<button type="button" class="btn toggle-password" tabindex="4"><?= FreshRSS_Themes::icon('key') ?></button>
 				</div>
 				<p class="help"><?= _i('help') ?> <?= _t('admin.user.password_format') ?></p>
-				<noscript><b><?= _t('gen.js.should_be_activated') ?></b></noscript>
 			</div>
 		</div>
 
@@ -774,7 +777,13 @@ if (_t('gen.dir') === 'rtl') {
 	<head>
 		<meta charset="UTF-8" />
 		<meta name="viewport" content="initial-scale=1.0" />
-		<script id="jsonVars" type="application/json">{}</script>
+		<script id="jsonVars" type="application/json">
+			<?= json_encode([
+				'i18n' => [
+					'provided_password_too_long' => _t('user.password.provided_password_too_long'),
+				]
+			], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) ?>
+		</script>
 		<title><?= _t('install.title') ?>: <?= _t('install.step', STEP + 1) ?></title>
 		<link rel="stylesheet" href="../themes/base-theme/frss.css?<?= @filemtime(PUBLIC_PATH . '/themes/base-theme/frss.css') ?>" />
 		<link rel="stylesheet" href="../themes/Origine/origine.css?<?= @filemtime(PUBLIC_PATH . '/themes/Origine/origine.css') ?>" />
@@ -874,5 +883,6 @@ if (_t('gen.dir') === 'rtl') {
 	</main>
 </div>
 	<script src="../scripts/install.js?<?= @filemtime(PUBLIC_PATH . '/scripts/install.js') ?>"></script>
+	<script src="../scripts/extra.js?<?= @filemtime(PUBLIC_PATH . '/scripts/extra.js') ?>"></script>
 	</body>
 </html>

+ 4 - 6
app/views/auth/formLogin.phtml

@@ -18,7 +18,7 @@
 		<?php endif; ?>
 	<?php endif; ?>
 
-	<form name="crypto_form" class="crypto-form" method="post" action="<?= _url('auth', 'login') ?>">
+	<form method="post" action="<?= _url('auth', 'login') ?>">
 		<input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" />
 		<input type="hidden" name="original_request" value="<?= Minz_Url::serialize(Minz_Request::originalRequest())?>" />
 
@@ -32,12 +32,10 @@
 		<div class="form-group">
 			<label for="passwordPlain"><?= _t('gen.auth.password') ?></label>
 			<div class="stick">
-				<input type="password" id="passwordPlain"<?= $this->auth_autofills['password'] !== '' ? ' value="' . $this->auth_autofills['password'] . '"' : '' ?>
-					class="passwordPlain" required="required" />
+				<input type="password"<?= $this->auth_autofills['password'] !== '' ? ' value="' . $this->auth_autofills['password'] . '"' : '' ?>
+					id="passwordPlain" name="passwordPlain" class="passwordPlain" required="required" />
 				<button type="button" class="btn toggle-password"><?= _i('key') ?></button>
 			</div>
-			<input type="hidden" id="challenge" name="challenge" />
-			<noscript><strong><?= _t('gen.js.should_be_activated') ?></strong></noscript>
 		</div>
 
 		<div class="form-group">
@@ -50,7 +48,7 @@
 		<?= Minz_ExtensionManager::callHookString(Minz_HookType::BeforeLoginBtn) ?>
 
 		<div class="form-group form-group-actions">
-			<button id="loginButton" type="submit" class="btn btn-important" disabled="disabled">
+			<button id="loginButton" type="submit" class="btn btn-important">
 				<?= _t('gen.auth.login') ?>
 			</button>
 		</div>

+ 4 - 6
app/views/auth/reauth.phtml

@@ -6,25 +6,23 @@
 <main class="prompt">
 	<h1><?= _t('gen.auth.reauth.header') ?></h1>
 
-	<form name="crypto_form" class="crypto-form" method="post">
+	<form method="post">
 		<input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" />
 		<input type="hidden" id="username" value="<?= Minz_User::name() ?>" />
 		<div class="form-group">
 			<label for="passwordPlain"><?= _t('gen.auth.password') ?></label>
 			<div class="stick">
-				<input type="password" id="passwordPlain"<?= $this->auth_autofills['password'] !== '' ? ' value="' . $this->auth_autofills['password'] . '"' : '' ?>
-					class="passwordPlain" required="required" />
+				<input type="password"<?= $this->auth_autofills['password'] !== '' ? ' value="' . $this->auth_autofills['password'] . '"' : '' ?>
+					id="passwordPlain" name="passwordPlain" class="passwordPlain" required="required" />
 				<button type="button" class="btn toggle-password"><?= _i('key') ?></button>
 			</div>
-			<input type="hidden" id="challenge" name="challenge" />
-			<noscript><strong><?= _t('gen.js.should_be_activated') ?></strong></noscript>
 		</div>
 		<?php
 			$reauth_time = FreshRSS_Context::systemConf()->reauth_time;
 		?>
 		<p class="help"><?= _i('help') ?> <?= _t('gen.auth.reauth.tip', intval($reauth_time / 60)) ?></p>
 		<div class="form-group form-group-actions">
-			<button type="submit" class="btn btn-important" disabled="disabled">
+			<button type="submit" class="btn btn-important">
 				<?= _t('gen.auth.login') ?>
 			</button>
 		</div>

+ 6 - 3
app/views/auth/register.phtml

@@ -50,11 +50,14 @@
 		<div class="form-group">
 			<label for="new_user_passwordPlain"><?= _t('gen.auth.password') ?></label>
 			<div class="stick">
-				<input type="password" id="new_user_passwordPlain" name="new_user_passwordPlain"<?= $this->auth_autofills['password'] !== '' ? ' value="' . $this->auth_autofills['password'] . '"' : '' ?>
-				required="required" autocomplete="new-password" pattern=".{7,}" />
+				<input
+					type="password" id="new_user_passwordPlain" name="new_user_passwordPlain"<?= $this->auth_autofills['password'] !== '' ? ' value="' . $this->auth_autofills['password'] . '"' : '' ?>
+					required="required" autocomplete="new-password"
+					minlength="<?= FreshRSS_FormAuth::MIN_PASSWORD_LENGTH ?>"
+					pattern=".{<?= FreshRSS_FormAuth::MIN_PASSWORD_LENGTH ?>,<?= FreshRSS_FormAuth::MAX_PASSWORD_LENGTH ?>}"
+					data-max-bytes="<?= FreshRSS_FormAuth::MAX_PASSWORD_LENGTH ?>" />
 				<button type="button" class="btn toggle-password"><?= _i('key') ?></button>
 			</div>
-			<noscript><b><?= _t('gen.js.should_be_activated') ?></b></noscript>
 			<p class="help"><?= _i('help') ?> <?= _t('gen.auth.password.format') ?></p>
 
 		</div>

+ 1 - 0
app/views/helpers/javascript_vars.phtml

@@ -89,6 +89,7 @@ echo json_encode([
 		'favicon_size_exceeded' => _t('feedback.sub.feed.favicon.too_large', format_bytes(FreshRSS_Context::systemConf()->limits['max_favicon_upload_size'])),
 		'language' => FreshRSS_Context::userConf()->language,
 		'unsafe_csp_header' => _t('gen.js.unsafe_csp_header'),
+		'provided_password_too_long' => _t('user.password.provided_password_too_long'),
 	],
 	'icons' => [
 		'read' => rawurlencode(_i('read')),

+ 0 - 4
app/views/javascript/nonce.phtml

@@ -1,4 +0,0 @@
-<?php
-declare(strict_types=1);
-/** @var FreshRSS_ViewJavascript $this */
-echo json_encode(['salt1' => $this->salt1, 'nonce' => $this->nonce]);

+ 6 - 3
app/views/user/details.phtml

@@ -59,8 +59,12 @@
 			<label class="group-name" for="newPasswordPlain"><?= _t('admin.user.password_form') ?></label>
 			<div class="group-controls">
 				<div class="stick">
-					<input type="password" id="newPasswordPlain" name="newPasswordPlain" autocomplete="new-password"
-						pattern=".{7,}" <?= FreshRSS_password_Util::cryptAvailable() && Minz_User::name() !== $this->username ? '' : 'disabled="disabled" ' ?>/>
+					<input
+						type="password" id="newPasswordPlain" name="newPasswordPlain" autocomplete="new-password"
+						<?= FreshRSS_password_Util::cryptAvailable() && Minz_User::name() !== $this->username ? '' : 'disabled="disabled" ' ?>
+						minlength="<?= FreshRSS_FormAuth::MIN_PASSWORD_LENGTH ?>"
+						pattern=".{<?= FreshRSS_FormAuth::MIN_PASSWORD_LENGTH ?>,<?= FreshRSS_FormAuth::MAX_PASSWORD_LENGTH ?>}"
+						data-max-bytes="<?= FreshRSS_FormAuth::MAX_PASSWORD_LENGTH ?>" />
 					<button type="button" class="btn toggle-password"><?= _i('key') ?></button>
 				</div>
 				<p class="help"><?= _i('help'); ?> <?= _t('admin.user.password_format') ?></p>
@@ -68,7 +72,6 @@
 		</div>
 
 		<div class="form-group form-actions">
-			<noscript><b><?= _t('gen.js.should_be_activated'); ?></b></noscript>
 			<?php
 				$disabledIfAjax = Minz_Request::paramBoolean('ajax') ? ' disabled="disabled"' : '';
 			?>

+ 5 - 2
app/views/user/manage.phtml

@@ -70,11 +70,14 @@
 			<label class="group-name" for="new_user_passwordPlain"><?= _t('admin.user.password_form') ?></label>
 			<div class="group-controls">
 				<div class="stick">
-					<input type="password" id="new_user_passwordPlain" name="new_user_passwordPlain" autocomplete="new-password" pattern=".{7,}" />
+					<input
+						type="password" id="new_user_passwordPlain" name="new_user_passwordPlain" autocomplete="new-password"
+						minlength="<?= FreshRSS_FormAuth::MIN_PASSWORD_LENGTH ?>"
+						pattern=".{<?= FreshRSS_FormAuth::MIN_PASSWORD_LENGTH ?>,<?= FreshRSS_FormAuth::MAX_PASSWORD_LENGTH ?>}"
+						data-max-bytes="<?= FreshRSS_FormAuth::MAX_PASSWORD_LENGTH ?>" />
 					<button type="button" class="btn toggle-password"><?= _i('key') ?></button>
 				</div>
 				<p class="help"><?= _i('help') ?> <?= _t('admin.user.password_format') ?></p>
-				<noscript><b><?= _t('gen.js.should_be_activated') ?></b></noscript>
 			</div>
 		</div>
 

+ 20 - 14
app/views/user/profile.phtml

@@ -7,7 +7,7 @@
 ?>
 
 <main class="post">
-	<form name="crypto_form" class="crypto-form" method="post" action="<?= _url('user', 'profile') ?>" data-auto-leave-validation="1">
+	<form method="post" action="<?= _url('user', 'profile') ?>" data-auto-leave-validation="1">
 		<input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" />
 		<h1><?= _t('conf.profile') ?></h1>
 
@@ -57,28 +57,30 @@
 			Minz_Session::_param('open', false);
 		?>
 
-		<details class="form-advanced" data-challenge-if-open="1"<?= $open ? ' open="open"' : ''?>>
+		<details class="form-advanced" <?= $open ? ' open="open"' : ''?>>
 			<summary class="form-advanced-title"><?= _t('conf.profile.change_password') ?></summary>
 			<div class="form-group">
 				<label class="group-name" for="currentPasswordPlain"><?= _t('conf.profile.current_password') ?></label>
 				<div class="group-controls">
 					<input type="hidden" id="username" value="<?= Minz_User::name() ?? '' ?>" />
 					<div class="stick">
-						<input type="password" id="currentPasswordPlain" class="passwordPlain" data-required-if-open="1" data-no-leave-validation="1" />
+						<input
+							type="password" id="currentPasswordPlain" name="passwordPlain" class="passwordPlain"
+							data-required-if-open="1" data-no-leave-validation="1" />
 						<button type="button" class="btn toggle-password"><?= _i('key') ?></button>
 					</div>
-
-					<noscript>
-						<br />
-						<strong><?= _t('gen.js.should_be_activated') ?></strong>
-					</noscript>
 				</div>
 			</div>
 			<div class="form-group">
 				<label class="group-name" for="newPasswordPlain"><?= _t('conf.profile.new_password') ?></label>
 				<div class="group-controls">
 					<div class="stick">
-						<input type="password" id="newPasswordPlain" name="newPasswordPlain" autocomplete="new-password" pattern=".{7,}" data-required-if-open="1" />
+						<input
+							type="password" id="newPasswordPlain" name="newPasswordPlain" autocomplete="new-password"
+							minlength="<?= FreshRSS_FormAuth::MIN_PASSWORD_LENGTH ?>"
+							pattern=".{<?= FreshRSS_FormAuth::MIN_PASSWORD_LENGTH ?>,<?= FreshRSS_FormAuth::MAX_PASSWORD_LENGTH ?>}"
+							data-max-bytes="<?= FreshRSS_FormAuth::MAX_PASSWORD_LENGTH ?>"
+							data-required-if-open="1" />
 						<button type="button" class="btn toggle-password"><?= _i('key') ?></button>
 					</div>
 					<p class="help">
@@ -90,7 +92,12 @@
 				<label class="group-name" for="confirmPasswordPlain"><?= _t('conf.profile.confirm_new_password') ?></label>
 				<div class="group-controls">
 					<div class="stick">
-						<input type="password" id="confirmPasswordPlain" name="confirmPasswordPlain" autocomplete="new-password" pattern=".{7,}" data-required-if-open="1" />
+						<input
+							type="password" id="confirmPasswordPlain" name="confirmPasswordPlain" autocomplete="new-password"
+							minlength="<?= FreshRSS_FormAuth::MIN_PASSWORD_LENGTH ?>"
+							pattern=".{<?= FreshRSS_FormAuth::MIN_PASSWORD_LENGTH ?>,<?= FreshRSS_FormAuth::MAX_PASSWORD_LENGTH ?>}"
+							data-max-bytes="<?= FreshRSS_FormAuth::MAX_PASSWORD_LENGTH ?>"
+							data-required-if-open="1" />
 						<button type="button" class="btn toggle-password"><?= _i('key') ?></button>
 					</div>
 				</div>
@@ -149,7 +156,7 @@
 	<?php if (!FreshRSS_Auth::hasAccess('admin')) { ?>
 	<h2><?= _t('conf.profile.delete') ?></h2>
 
-	<form name="crypto_form" class="crypto-form" method="post" action="<?= _url('user', 'delete') ?>">
+	<form method="post" action="<?= _url('user', 'delete') ?>">
 		<input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" />
 
 		<p class="alert alert-warn"><span class="alert-head"><?= _t('gen.short.attention') ?></span> <?= _t('conf.profile.delete.warn') ?></p>
@@ -158,11 +165,10 @@
 			<label class="group-name" for="passwordPlain"><?= _t('gen.auth.password') ?></label>
 			<div class="group-controls">
 				<div class="stick">
-					<input type="password" id="passwordPlain" class="passwordPlain" required="required" />
+					<input type="password" id="passwordPlain" name="passwordPlain" class="passwordPlain" required="required" />
 					<button type="button" class="btn toggle-password"><?= _i('key') ?></button>
 				</div>
-					<input type="hidden" id="challenge" name="challenge" /><br />
-					<noscript><strong><?= _t('gen.js.should_be_activated') ?></strong></noscript>
+				<br />
 			</div>
 		</div>
 

+ 1 - 0
cli/create-user.php

@@ -86,6 +86,7 @@ $ok = FreshRSS_user_Controller::createUser(
 );
 
 if (!$ok) {
+	// TODO: Refactor with exceptions in updateUser to be able to show the exact reason.
 	fail('FreshRSS could not create user!');
 }
 

+ 1 - 0
cli/update-user.php

@@ -75,6 +75,7 @@ $ok = FreshRSS_user_Controller::updateUser(
 	$values);
 
 if (!$ok) {
+	// TODO: Refactor with exceptions in updateUser to be able to show the exact reason.
 	fail('FreshRSS could not update user!');
 }
 

+ 3 - 3
docs/en/admins/09_AccessControl.md

@@ -1,6 +1,6 @@
 # Access control
 
-FreshRSS offers three methods of Access control: Form Authentication using JavaScript, HTTP based Authentication, or an uncontrolled state with no authentication required.
+FreshRSS offers three methods of Access control: Form Authentication using a standard HTML form, HTTP based Authentication, or an uncontrolled state with no authentication required.
 
 ## Server-side feed fetching & security considerations
 
@@ -40,9 +40,9 @@ These steps are not necessary for trusted, single-user deployments, but are stro
 
 ## Form Authentication
 
-Form Authentication requires the use of JavaScript. It will work on any supported version of PHP.
+Form Authentication is using a standard HTML form, it does not require any JavaScript since FreshRSS v1.31.0. It will work on any supported version of PHP (>=8.1).
 
-This option requires nothing more than selecting Form Authentication during installation.
+This option requires nothing more than selecting Form Authentication during installation, and properly configured PHP sessions.
 
 ## HTTP Authentication
 

+ 18 - 0
lib/Minz/Log.php

@@ -23,6 +23,8 @@ class Minz_Log {
 		'debug' => LOG_DEBUG,
 	];
 
+	private static string $lastLog = '';
+
 	/**
 	 * Records a message in a specific log file
 	 * The message is not logged if
@@ -56,6 +58,8 @@ class Minz_Log {
 			};
 		}
 
+		self::$lastLog = $information;
+
 		if (! ($env === 'silent' || $level > self::LOG_LEVELS[$log_level])) {
 			$username = Minz_User::name() ?? Minz_User::INTERNAL_USER;
 			if ($file_name == null) {
@@ -82,6 +86,20 @@ class Minz_Log {
 		}
 	}
 
+	/**
+	 * Returns the most recently logged message as a string value, and clears it.
+	 * Mainly for use in PHPUnit tests.
+	 *
+	 * @phpstan-impure The return value might change, even though input arguments are the same between calls.
+	 */
+	public static function getLastLog(bool $clear_last_log = true): string {
+		$lastLog = self::$lastLog;
+		if ($clear_last_log) {
+			self::$lastLog = '';
+		}
+		return $lastLog;
+	}
+
 	/**
 	 * Make sure we do not waste a huge amount of disk space with old log messages.
 	 *

+ 8 - 4
lib/Minz/Request.php

@@ -145,12 +145,15 @@ class Minz_Request {
 
 	/**
 	 * @param bool $plaintext `true` to return special characters without any escaping (unsafe), `false` (default) to XML-encode them
+	 * @param bool $trim `false` to skip trimming whitespace surrounding the parameter, `true` (default) to trim whitespace
 	 */
-	public static function paramStringNull(string $key, bool $plaintext = false): ?string {
+	public static function paramStringNull(string $key, bool $plaintext = false, bool $trim = true): ?string {
 		if (isset(self::$params[$key])) {
 			$s = self::$params[$key];
 			if (is_string($s)) {
-				$s = trim($s);
+				if ($trim) {
+					$s = trim($s);
+				}
 				return $plaintext ? $s : htmlspecialchars($s, ENT_COMPAT, 'UTF-8');
 			}
 			if (is_int($s) || is_bool($s)) {
@@ -162,9 +165,10 @@ class Minz_Request {
 
 	/**
 	 * @param bool $plaintext `true` to return special characters without any escaping (unsafe), `false` (default) to XML-encode them
+	 * @param bool $trim `false` to skip trimming whitespace surrounding the parameter, `true` (default) to trim whitespace
 	 */
-	public static function paramString(string $key, bool $plaintext = false): string {
-		return self::paramStringNull($key, $plaintext) ?? '';
+	public static function paramString(string $key, bool $plaintext = false, bool $trim = true): string {
+		return self::paramStringNull($key, $plaintext, $trim) ?? '';
 	}
 
 	/**

+ 27 - 98
p/scripts/extra.js

@@ -1,106 +1,11 @@
 // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0
 'use strict';
-/* globals context, notifs_html5_is_supported, openNotification, xmlHttpRequestJson */
-
-// <crypto form (Web login)>
-function poormanSalt() {	// If crypto.getRandomValues is not available
-	const base = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789/abcdefghijklmnopqrstuvwxyz';
-	let text = '$2a$04$';
-	for (let i = 22; i > 0; i--) {
-		text += base.charAt(Math.floor(Math.random() * 64));
-	}
-	return text;
-}
+/* globals context, notifs_html5_is_supported */
 
 function forgetOpenCategories() {
 	localStorage.removeItem('FreshRSS_open_categories');
 }
 
-function init_crypto_forms() {
-	const crypto_forms = document.querySelectorAll('.crypto-form');
-	if (crypto_forms.length === 0) {
-		return;
-	}
-
-	if (!(window.bcrypt)) {
-		if (window.console) {
-			console.log('FreshRSS waiting for bcrypt.js…');
-		}
-		setTimeout(init_crypto_forms, 100);
-		return;
-	}
-
-	/* globals bcrypt */
-	crypto_forms.forEach(crypto_form => {
-		const submit_button = crypto_form.querySelector('[type="submit"]');
-		if (submit_button) {
-			submit_button.disabled = false;
-		}
-
-		crypto_form.onsubmit = function (e) {
-			let challenge = crypto_form.querySelector('#challenge');
-			if (!challenge) {
-				crypto_form.querySelectorAll('details[data-challenge-if-open]').forEach(el => {
-					if (el.open && !challenge) {
-						crypto_form.insertAdjacentHTML('beforeend', '<input type="hidden" id="challenge" name="challenge" />');
-						challenge = crypto_form.querySelector('#challenge');
-					}
-				});
-				if (!challenge) {
-					return true;
-				}
-			}
-
-			e.preventDefault();
-
-			if (!submit_button) {
-				return false;
-			}
-			submit_button.disabled = true;
-
-			const req = new XMLHttpRequest();
-			req.open('GET', './?c=javascript&a=nonce&user=' + crypto_form.querySelector('#username').value, true);
-
-			req.onerror = function () {
-				openNotification('Communication error!', 'bad');
-				submit_button.disabled = false;
-			};
-
-			req.onload = function () {
-				if (req.status == 200) {
-					const json = xmlHttpRequestJson(req);
-					if (!json.salt1 || !json.nonce) {
-						openNotification('Invalid user!', 'bad');
-					} else {
-						try {
-							const strong = window.Uint32Array && window.crypto && (typeof window.crypto.getRandomValues === 'function');
-							const s = bcrypt.hashSync(crypto_form.querySelector('.passwordPlain').value, json.salt1);
-							const c = bcrypt.hashSync(s + json.nonce, strong ? bcrypt.genSaltSync(4) : poormanSalt());
-							challenge.value = c;
-							if (!s || !c) {
-								openNotification('Crypto error!', 'bad');
-							} else {
-								crypto_form.removeEventListener('submit', crypto_form.onsubmit);
-								crypto_form.submit();
-							}
-						} catch (ex) {
-							openNotification('Crypto exception! ' + ex, 'bad');
-						}
-					}
-				} else {
-					req.onerror();
-				}
-				submit_button.disabled = false;
-			};
-
-			req.send();
-		};
-	});
-}
-// </crypto form (Web login)>
-
-// <show password>
-
 function init_display(parent) {
 	const theme = parent.querySelector('select#theme');
 	if (!theme) {
@@ -113,6 +18,7 @@ function init_display(parent) {
 	});
 }
 
+// <password forms>
 function togglePW(btn) {
 	if (btn.classList.contains('active')) {
 		hidePW(btn);
@@ -144,7 +50,29 @@ function init_password_observers(parent) {
 		btn.onclick = () => togglePW(btn);
 	});
 }
-// </show password>
+
+function init_password_byte_length_validation(parent) {
+	parent.querySelectorAll('input[type="password"][data-max-bytes]').forEach(input => {
+		function validate() {
+			// Display custom validation error for the password input or unset any custom errors.
+			if (new TextEncoder().encode(input.value).length > +input.dataset.maxBytes) {
+				input.setCustomValidity(context.i18n.provided_password_too_long);
+			} else {
+				input.setCustomValidity('');
+			}
+
+			if (input.validity.customError) {
+				// Update the displayed validation status immediately before
+				// the user attempts to submit the form.
+				input.reportValidity();
+			}
+		}
+
+		input.addEventListener('input', validate);
+		validate();
+	});
+}
+// </password forms>
 
 function init_archiving(parent) {
 	parent.addEventListener('change', function (e) {
@@ -646,8 +574,8 @@ function init_extra_afterDOM() {
 	if (loginButton) {
 		loginButton.addEventListener('click', forgetOpenCategories);
 	}
-	init_crypto_forms();
 	init_password_observers(document.body);
+	init_password_byte_length_validation(document.body);
 	init_select_observers();
 	init_configuration_alert();
 	init_2stateButton();
@@ -662,6 +590,7 @@ function init_extra_afterDOM() {
 	if (slider) {
 		slider.addEventListener('freshrss:slider-load', function (e) {
 			init_password_observers(slider);
+			init_password_byte_length_validation(slider);
 		});
 		init_slider(slider);
 		init_archiving(slider);

+ 6 - 36
p/scripts/install.js

@@ -1,42 +1,12 @@
 // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0
 'use strict';
 
-let timeoutHide;
-
-function showPW_this() {
-	const id_passwordField = this.getAttribute('data-toggle');
-	if (this.classList.contains('active')) {
-		hidePW(id_passwordField);
-	} else {
-		showPW(id_passwordField);
-	}
-	return false;
-}
-
-function showPW(id_passwordField) {
-	const passwordField = document.getElementById(id_passwordField);
-	passwordField.setAttribute('type', 'text');
-	passwordField.nextElementSibling.classList.add('active');
-	clearTimeout(timeoutHide);
-	timeoutHide = setTimeout(function () { hidePW(id_passwordField); }, 5000);
-	return false;
-}
-
-function hidePW(id_passwordField) {
-	clearTimeout(timeoutHide);
-	const passwordField = document.getElementById(id_passwordField);
-	passwordField.setAttribute('type', 'password');
-	passwordField.nextElementSibling.classList.remove('active');
-	return false;
-}
-
-function init_password_observers(parent) {
-	parent.querySelectorAll('.toggle-password').forEach(function (btn) {
-		btn.addEventListener('click', showPW_this);
-	});
-}
-
-init_password_observers(document.body);
+(function parseJsonVars() {
+	const jsonVars = document.getElementById('jsonVars');
+	const json = JSON.parse(jsonVars.innerHTML);
+	jsonVars.outerHTML = '';
+	window.context = { 'i18n': json.i18n };
+}());
 
 const auth_type = document.getElementById('auth_type');
 function auth_type_change() {

+ 0 - 1221
p/scripts/vendor/bcrypt.js

@@ -1,1221 +0,0 @@
-// GENERATED FILE. DO NOT EDIT.
-(function (global, factory) {
-  function preferDefault(exports) {
-    return exports.default || exports;
-  }
-  if (typeof define === "function" && define.amd) {
-    define(["crypto"], function (_crypto) {
-      var exports = {};
-      factory(exports, _crypto);
-      return preferDefault(exports);
-    });
-  } else if (typeof exports === "object") {
-    factory(exports, require("crypto"));
-    if (typeof module === "object") module.exports = preferDefault(exports);
-  } else {
-    (function () {
-      var exports = {};
-      factory(exports, global.crypto);
-      global.bcrypt = preferDefault(exports);
-    })();
-  }
-})(
-  typeof globalThis !== "undefined"
-    ? globalThis
-    : typeof self !== "undefined"
-      ? self
-      : this,
-  function (_exports, _crypto) {
-    "use strict";
-
-    Object.defineProperty(_exports, "__esModule", {
-      value: true,
-    });
-    _exports.compare = compare;
-    _exports.compareSync = compareSync;
-    _exports.decodeBase64 = decodeBase64;
-    _exports.default = void 0;
-    _exports.encodeBase64 = encodeBase64;
-    _exports.genSalt = genSalt;
-    _exports.genSaltSync = genSaltSync;
-    _exports.getRounds = getRounds;
-    _exports.getSalt = getSalt;
-    _exports.hash = hash;
-    _exports.hashSync = hashSync;
-    _exports.setRandomFallback = setRandomFallback;
-    _exports.truncates = truncates;
-    _crypto = _interopRequireDefault(_crypto);
-    function _interopRequireDefault(e) {
-      return e && e.__esModule ? e : { default: e };
-    }
-    /*
-   Copyright (c) 2012 Nevins Bartolomeo <nevins.bartolomeo@gmail.com>
-   Copyright (c) 2012 Shane Girish <shaneGirish@gmail.com>
-   Copyright (c) 2025 Daniel Wirtz <dcode@dcode.io>
-  
-   Redistribution and use in source and binary forms, with or without
-   modification, are permitted provided that the following conditions
-   are met:
-   1. Redistributions of source code must retain the above copyright
-   notice, this list of conditions and the following disclaimer.
-   2. Redistributions in binary form must reproduce the above copyright
-   notice, this list of conditions and the following disclaimer in the
-   documentation and/or other materials provided with the distribution.
-   3. The name of the author may not be used to endorse or promote products
-   derived from this software without specific prior written permission.
-  
-   THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
-   IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
-   OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
-   IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
-   INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
-   NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-   DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-   THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-   (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
-   THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-   */
-
-    // The Node.js crypto module is used as a fallback for the Web Crypto API. When
-    // building for the browser, inclusion of the crypto module should be disabled,
-    // which the package hints at in its package.json for bundlers that support it.
-
-    /**
-     * The random implementation to use as a fallback.
-     * @type {?function(number):!Array.<number>}
-     * @inner
-     */
-    var randomFallback = null;
-
-    /**
-     * Generates cryptographically secure random bytes.
-     * @function
-     * @param {number} len Bytes length
-     * @returns {!Array.<number>} Random bytes
-     * @throws {Error} If no random implementation is available
-     * @inner
-     */
-    function randomBytes(len) {
-      // Web Crypto API. Globally available in the browser and in Node.js >=23.
-      try {
-        return crypto.getRandomValues(new Uint8Array(len));
-      } catch {}
-      // Node.js crypto module for non-browser environments.
-      try {
-        return _crypto.default.randomBytes(len);
-      } catch {}
-      // Custom fallback specified with `setRandomFallback`.
-      if (!randomFallback) {
-        throw Error(
-          "Neither WebCryptoAPI nor a crypto module is available. Use bcrypt.setRandomFallback to set an alternative",
-        );
-      }
-      return randomFallback(len);
-    }
-
-    /**
-     * Sets the pseudo random number generator to use as a fallback if neither node's `crypto` module nor the Web Crypto
-     *  API is available. Please note: It is highly important that the PRNG used is cryptographically secure and that it
-     *  is seeded properly!
-     * @param {?function(number):!Array.<number>} random Function taking the number of bytes to generate as its
-     *  sole argument, returning the corresponding array of cryptographically secure random byte values.
-     * @see http://nodejs.org/api/crypto.html
-     * @see http://www.w3.org/TR/WebCryptoAPI/
-     */
-    function setRandomFallback(random) {
-      randomFallback = random;
-    }
-
-    /**
-     * Synchronously generates a salt.
-     * @param {number=} rounds Number of rounds to use, defaults to 10 if omitted
-     * @param {number=} seed_length Not supported.
-     * @returns {string} Resulting salt
-     * @throws {Error} If a random fallback is required but not set
-     */
-    function genSaltSync(rounds, seed_length) {
-      rounds = rounds || GENSALT_DEFAULT_LOG2_ROUNDS;
-      if (typeof rounds !== "number")
-        throw Error(
-          "Illegal arguments: " + typeof rounds + ", " + typeof seed_length,
-        );
-      if (rounds < 4) rounds = 4;
-      else if (rounds > 31) rounds = 31;
-      var salt = [];
-      salt.push("$2b$");
-      if (rounds < 10) salt.push("0");
-      salt.push(rounds.toString());
-      salt.push("$");
-      salt.push(base64_encode(randomBytes(BCRYPT_SALT_LEN), BCRYPT_SALT_LEN)); // May throw
-      return salt.join("");
-    }
-
-    /**
-     * Asynchronously generates a salt.
-     * @param {(number|function(Error, string=))=} rounds Number of rounds to use, defaults to 10 if omitted
-     * @param {(number|function(Error, string=))=} seed_length Not supported.
-     * @param {function(Error, string=)=} callback Callback receiving the error, if any, and the resulting salt
-     * @returns {!Promise} If `callback` has been omitted
-     * @throws {Error} If `callback` is present but not a function
-     */
-    function genSalt(rounds, seed_length, callback) {
-      if (typeof seed_length === "function")
-        (callback = seed_length), (seed_length = undefined); // Not supported.
-      if (typeof rounds === "function")
-        (callback = rounds), (rounds = undefined);
-      if (typeof rounds === "undefined") rounds = GENSALT_DEFAULT_LOG2_ROUNDS;
-      else if (typeof rounds !== "number")
-        throw Error("illegal arguments: " + typeof rounds);
-      function _async(callback) {
-        nextTick(function () {
-          // Pretty thin, but salting is fast enough
-          try {
-            callback(null, genSaltSync(rounds));
-          } catch (err) {
-            callback(err);
-          }
-        });
-      }
-      if (callback) {
-        if (typeof callback !== "function")
-          throw Error("Illegal callback: " + typeof callback);
-        _async(callback);
-      } else
-        return new Promise(function (resolve, reject) {
-          _async(function (err, res) {
-            if (err) {
-              reject(err);
-              return;
-            }
-            resolve(res);
-          });
-        });
-    }
-
-    /**
-     * Synchronously generates a hash for the given password.
-     * @param {string} password Password to hash
-     * @param {(number|string)=} salt Salt length to generate or salt to use, default to 10
-     * @returns {string} Resulting hash
-     */
-    function hashSync(password, salt) {
-      if (typeof salt === "undefined") salt = GENSALT_DEFAULT_LOG2_ROUNDS;
-      if (typeof salt === "number") salt = genSaltSync(salt);
-      if (typeof password !== "string" || typeof salt !== "string")
-        throw Error(
-          "Illegal arguments: " + typeof password + ", " + typeof salt,
-        );
-      return _hash(password, salt);
-    }
-
-    /**
-     * Asynchronously generates a hash for the given password.
-     * @param {string} password Password to hash
-     * @param {number|string} salt Salt length to generate or salt to use
-     * @param {function(Error, string=)=} callback Callback receiving the error, if any, and the resulting hash
-     * @param {function(number)=} progressCallback Callback successively called with the percentage of rounds completed
-     *  (0.0 - 1.0), maximally once per `MAX_EXECUTION_TIME = 100` ms.
-     * @returns {!Promise} If `callback` has been omitted
-     * @throws {Error} If `callback` is present but not a function
-     */
-    function hash(password, salt, callback, progressCallback) {
-      function _async(callback) {
-        if (typeof password === "string" && typeof salt === "number")
-          genSalt(salt, function (err, salt) {
-            _hash(password, salt, callback, progressCallback);
-          });
-        else if (typeof password === "string" && typeof salt === "string")
-          _hash(password, salt, callback, progressCallback);
-        else
-          nextTick(
-            callback.bind(
-              this,
-              Error(
-                "Illegal arguments: " + typeof password + ", " + typeof salt,
-              ),
-            ),
-          );
-      }
-      if (callback) {
-        if (typeof callback !== "function")
-          throw Error("Illegal callback: " + typeof callback);
-        _async(callback);
-      } else
-        return new Promise(function (resolve, reject) {
-          _async(function (err, res) {
-            if (err) {
-              reject(err);
-              return;
-            }
-            resolve(res);
-          });
-        });
-    }
-
-    /**
-     * Compares two strings of the same length in constant time.
-     * @param {string} known Must be of the correct length
-     * @param {string} unknown Must be the same length as `known`
-     * @returns {boolean}
-     * @inner
-     */
-    function safeStringCompare(known, unknown) {
-      var diff = known.length ^ unknown.length;
-      for (var i = 0; i < known.length; ++i) {
-        diff |= known.charCodeAt(i) ^ unknown.charCodeAt(i);
-      }
-      return diff === 0;
-    }
-
-    /**
-     * Synchronously tests a password against a hash.
-     * @param {string} password Password to compare
-     * @param {string} hash Hash to test against
-     * @returns {boolean} true if matching, otherwise false
-     * @throws {Error} If an argument is illegal
-     */
-    function compareSync(password, hash) {
-      if (typeof password !== "string" || typeof hash !== "string")
-        throw Error(
-          "Illegal arguments: " + typeof password + ", " + typeof hash,
-        );
-      if (hash.length !== 60) return false;
-      return safeStringCompare(
-        hashSync(password, hash.substring(0, hash.length - 31)),
-        hash,
-      );
-    }
-
-    /**
-     * Asynchronously tests a password against a hash.
-     * @param {string} password Password to compare
-     * @param {string} hashValue Hash to test against
-     * @param {function(Error, boolean)=} callback Callback receiving the error, if any, otherwise the result
-     * @param {function(number)=} progressCallback Callback successively called with the percentage of rounds completed
-     *  (0.0 - 1.0), maximally once per `MAX_EXECUTION_TIME = 100` ms.
-     * @returns {!Promise} If `callback` has been omitted
-     * @throws {Error} If `callback` is present but not a function
-     */
-    function compare(password, hashValue, callback, progressCallback) {
-      function _async(callback) {
-        if (typeof password !== "string" || typeof hashValue !== "string") {
-          nextTick(
-            callback.bind(
-              this,
-              Error(
-                "Illegal arguments: " +
-                  typeof password +
-                  ", " +
-                  typeof hashValue,
-              ),
-            ),
-          );
-          return;
-        }
-        if (hashValue.length !== 60) {
-          nextTick(callback.bind(this, null, false));
-          return;
-        }
-        hash(
-          password,
-          hashValue.substring(0, 29),
-          function (err, comp) {
-            if (err) callback(err);
-            else callback(null, safeStringCompare(comp, hashValue));
-          },
-          progressCallback,
-        );
-      }
-      if (callback) {
-        if (typeof callback !== "function")
-          throw Error("Illegal callback: " + typeof callback);
-        _async(callback);
-      } else
-        return new Promise(function (resolve, reject) {
-          _async(function (err, res) {
-            if (err) {
-              reject(err);
-              return;
-            }
-            resolve(res);
-          });
-        });
-    }
-
-    /**
-     * Gets the number of rounds used to encrypt the specified hash.
-     * @param {string} hash Hash to extract the used number of rounds from
-     * @returns {number} Number of rounds used
-     * @throws {Error} If `hash` is not a string
-     */
-    function getRounds(hash) {
-      if (typeof hash !== "string")
-        throw Error("Illegal arguments: " + typeof hash);
-      return parseInt(hash.split("$")[2], 10);
-    }
-
-    /**
-     * Gets the salt portion from a hash. Does not validate the hash.
-     * @param {string} hash Hash to extract the salt from
-     * @returns {string} Extracted salt part
-     * @throws {Error} If `hash` is not a string or otherwise invalid
-     */
-    function getSalt(hash) {
-      if (typeof hash !== "string")
-        throw Error("Illegal arguments: " + typeof hash);
-      if (hash.length !== 60)
-        throw Error("Illegal hash length: " + hash.length + " != 60");
-      return hash.substring(0, 29);
-    }
-
-    /**
-     * Tests if a password will be truncated when hashed, that is its length is
-     * greater than 72 bytes when converted to UTF-8.
-     * @param {string} password The password to test
-     * @returns {boolean} `true` if truncated, otherwise `false`
-     */
-    function truncates(password) {
-      if (typeof password !== "string")
-        throw Error("Illegal arguments: " + typeof password);
-      return utf8Length(password) > 72;
-    }
-
-    /**
-     * Continues with the callback on the next tick.
-     * @function
-     * @param {function(...[*])} callback Callback to execute
-     * @inner
-     */
-    var nextTick =
-      typeof process !== "undefined" &&
-      process &&
-      typeof process.nextTick === "function"
-        ? typeof setImmediate === "function"
-          ? setImmediate
-          : process.nextTick
-        : setTimeout;
-
-    /** Calculates the byte length of a string encoded as UTF8. */
-    function utf8Length(string) {
-      var len = 0,
-        c = 0;
-      for (var i = 0; i < string.length; ++i) {
-        c = string.charCodeAt(i);
-        if (c < 128) len += 1;
-        else if (c < 2048) len += 2;
-        else if (
-          (c & 0xfc00) === 0xd800 &&
-          (string.charCodeAt(i + 1) & 0xfc00) === 0xdc00
-        ) {
-          ++i;
-          len += 4;
-        } else len += 3;
-      }
-      return len;
-    }
-
-    /** Converts a string to an array of UTF8 bytes. */
-    function utf8Array(string) {
-      var offset = 0,
-        c1,
-        c2;
-      var buffer = new Array(utf8Length(string));
-      for (var i = 0, k = string.length; i < k; ++i) {
-        c1 = string.charCodeAt(i);
-        if (c1 < 128) {
-          buffer[offset++] = c1;
-        } else if (c1 < 2048) {
-          buffer[offset++] = (c1 >> 6) | 192;
-          buffer[offset++] = (c1 & 63) | 128;
-        } else if (
-          (c1 & 0xfc00) === 0xd800 &&
-          ((c2 = string.charCodeAt(i + 1)) & 0xfc00) === 0xdc00
-        ) {
-          c1 = 0x10000 + ((c1 & 0x03ff) << 10) + (c2 & 0x03ff);
-          ++i;
-          buffer[offset++] = (c1 >> 18) | 240;
-          buffer[offset++] = ((c1 >> 12) & 63) | 128;
-          buffer[offset++] = ((c1 >> 6) & 63) | 128;
-          buffer[offset++] = (c1 & 63) | 128;
-        } else {
-          buffer[offset++] = (c1 >> 12) | 224;
-          buffer[offset++] = ((c1 >> 6) & 63) | 128;
-          buffer[offset++] = (c1 & 63) | 128;
-        }
-      }
-      return buffer;
-    }
-
-    // A base64 implementation for the bcrypt algorithm. This is partly non-standard.
-
-    /**
-     * bcrypt's own non-standard base64 dictionary.
-     * @type {!Array.<string>}
-     * @const
-     * @inner
-     **/
-    var BASE64_CODE =
-      "./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split(
-        "",
-      );
-
-    /**
-     * @type {!Array.<number>}
-     * @const
-     * @inner
-     **/
-    var BASE64_INDEX = [
-      -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-      -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-      -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, 1, 54, 55, 56, 57, 58, 59, 60,
-      61, 62, 63, -1, -1, -1, -1, -1, -1, -1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
-      12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, -1, -1,
-      -1, -1, -1, -1, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41,
-      42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, -1, -1, -1, -1, -1,
-    ];
-
-    /**
-     * Encodes a byte array to base64 with up to len bytes of input.
-     * @param {!Array.<number>} b Byte array
-     * @param {number} len Maximum input length
-     * @returns {string}
-     * @inner
-     */
-    function base64_encode(b, len) {
-      var off = 0,
-        rs = [],
-        c1,
-        c2;
-      if (len <= 0 || len > b.length) throw Error("Illegal len: " + len);
-      while (off < len) {
-        c1 = b[off++] & 0xff;
-        rs.push(BASE64_CODE[(c1 >> 2) & 0x3f]);
-        c1 = (c1 & 0x03) << 4;
-        if (off >= len) {
-          rs.push(BASE64_CODE[c1 & 0x3f]);
-          break;
-        }
-        c2 = b[off++] & 0xff;
-        c1 |= (c2 >> 4) & 0x0f;
-        rs.push(BASE64_CODE[c1 & 0x3f]);
-        c1 = (c2 & 0x0f) << 2;
-        if (off >= len) {
-          rs.push(BASE64_CODE[c1 & 0x3f]);
-          break;
-        }
-        c2 = b[off++] & 0xff;
-        c1 |= (c2 >> 6) & 0x03;
-        rs.push(BASE64_CODE[c1 & 0x3f]);
-        rs.push(BASE64_CODE[c2 & 0x3f]);
-      }
-      return rs.join("");
-    }
-
-    /**
-     * Decodes a base64 encoded string to up to len bytes of output.
-     * @param {string} s String to decode
-     * @param {number} len Maximum output length
-     * @returns {!Array.<number>}
-     * @inner
-     */
-    function base64_decode(s, len) {
-      var off = 0,
-        slen = s.length,
-        olen = 0,
-        rs = [],
-        c1,
-        c2,
-        c3,
-        c4,
-        o,
-        code;
-      if (len <= 0) throw Error("Illegal len: " + len);
-      while (off < slen - 1 && olen < len) {
-        code = s.charCodeAt(off++);
-        c1 = code < BASE64_INDEX.length ? BASE64_INDEX[code] : -1;
-        code = s.charCodeAt(off++);
-        c2 = code < BASE64_INDEX.length ? BASE64_INDEX[code] : -1;
-        if (c1 == -1 || c2 == -1) break;
-        o = (c1 << 2) >>> 0;
-        o |= (c2 & 0x30) >> 4;
-        rs.push(String.fromCharCode(o));
-        if (++olen >= len || off >= slen) break;
-        code = s.charCodeAt(off++);
-        c3 = code < BASE64_INDEX.length ? BASE64_INDEX[code] : -1;
-        if (c3 == -1) break;
-        o = ((c2 & 0x0f) << 4) >>> 0;
-        o |= (c3 & 0x3c) >> 2;
-        rs.push(String.fromCharCode(o));
-        if (++olen >= len || off >= slen) break;
-        code = s.charCodeAt(off++);
-        c4 = code < BASE64_INDEX.length ? BASE64_INDEX[code] : -1;
-        o = ((c3 & 0x03) << 6) >>> 0;
-        o |= c4;
-        rs.push(String.fromCharCode(o));
-        ++olen;
-      }
-      var res = [];
-      for (off = 0; off < olen; off++) res.push(rs[off].charCodeAt(0));
-      return res;
-    }
-
-    /**
-     * @type {number}
-     * @const
-     * @inner
-     */
-    var BCRYPT_SALT_LEN = 16;
-
-    /**
-     * @type {number}
-     * @const
-     * @inner
-     */
-    var GENSALT_DEFAULT_LOG2_ROUNDS = 10;
-
-    /**
-     * @type {number}
-     * @const
-     * @inner
-     */
-    var BLOWFISH_NUM_ROUNDS = 16;
-
-    /**
-     * @type {number}
-     * @const
-     * @inner
-     */
-    var MAX_EXECUTION_TIME = 100;
-
-    /**
-     * @type {Array.<number>}
-     * @const
-     * @inner
-     */
-    var P_ORIG = [
-      0x243f6a88, 0x85a308d3, 0x13198a2e, 0x03707344, 0xa4093822, 0x299f31d0,
-      0x082efa98, 0xec4e6c89, 0x452821e6, 0x38d01377, 0xbe5466cf, 0x34e90c6c,
-      0xc0ac29b7, 0xc97c50dd, 0x3f84d5b5, 0xb5470917, 0x9216d5d9, 0x8979fb1b,
-    ];
-
-    /**
-     * @type {Array.<number>}
-     * @const
-     * @inner
-     */
-    var S_ORIG = [
-      0xd1310ba6, 0x98dfb5ac, 0x2ffd72db, 0xd01adfb7, 0xb8e1afed, 0x6a267e96,
-      0xba7c9045, 0xf12c7f99, 0x24a19947, 0xb3916cf7, 0x0801f2e2, 0x858efc16,
-      0x636920d8, 0x71574e69, 0xa458fea3, 0xf4933d7e, 0x0d95748f, 0x728eb658,
-      0x718bcd58, 0x82154aee, 0x7b54a41d, 0xc25a59b5, 0x9c30d539, 0x2af26013,
-      0xc5d1b023, 0x286085f0, 0xca417918, 0xb8db38ef, 0x8e79dcb0, 0x603a180e,
-      0x6c9e0e8b, 0xb01e8a3e, 0xd71577c1, 0xbd314b27, 0x78af2fda, 0x55605c60,
-      0xe65525f3, 0xaa55ab94, 0x57489862, 0x63e81440, 0x55ca396a, 0x2aab10b6,
-      0xb4cc5c34, 0x1141e8ce, 0xa15486af, 0x7c72e993, 0xb3ee1411, 0x636fbc2a,
-      0x2ba9c55d, 0x741831f6, 0xce5c3e16, 0x9b87931e, 0xafd6ba33, 0x6c24cf5c,
-      0x7a325381, 0x28958677, 0x3b8f4898, 0x6b4bb9af, 0xc4bfe81b, 0x66282193,
-      0x61d809cc, 0xfb21a991, 0x487cac60, 0x5dec8032, 0xef845d5d, 0xe98575b1,
-      0xdc262302, 0xeb651b88, 0x23893e81, 0xd396acc5, 0x0f6d6ff3, 0x83f44239,
-      0x2e0b4482, 0xa4842004, 0x69c8f04a, 0x9e1f9b5e, 0x21c66842, 0xf6e96c9a,
-      0x670c9c61, 0xabd388f0, 0x6a51a0d2, 0xd8542f68, 0x960fa728, 0xab5133a3,
-      0x6eef0b6c, 0x137a3be4, 0xba3bf050, 0x7efb2a98, 0xa1f1651d, 0x39af0176,
-      0x66ca593e, 0x82430e88, 0x8cee8619, 0x456f9fb4, 0x7d84a5c3, 0x3b8b5ebe,
-      0xe06f75d8, 0x85c12073, 0x401a449f, 0x56c16aa6, 0x4ed3aa62, 0x363f7706,
-      0x1bfedf72, 0x429b023d, 0x37d0d724, 0xd00a1248, 0xdb0fead3, 0x49f1c09b,
-      0x075372c9, 0x80991b7b, 0x25d479d8, 0xf6e8def7, 0xe3fe501a, 0xb6794c3b,
-      0x976ce0bd, 0x04c006ba, 0xc1a94fb6, 0x409f60c4, 0x5e5c9ec2, 0x196a2463,
-      0x68fb6faf, 0x3e6c53b5, 0x1339b2eb, 0x3b52ec6f, 0x6dfc511f, 0x9b30952c,
-      0xcc814544, 0xaf5ebd09, 0xbee3d004, 0xde334afd, 0x660f2807, 0x192e4bb3,
-      0xc0cba857, 0x45c8740f, 0xd20b5f39, 0xb9d3fbdb, 0x5579c0bd, 0x1a60320a,
-      0xd6a100c6, 0x402c7279, 0x679f25fe, 0xfb1fa3cc, 0x8ea5e9f8, 0xdb3222f8,
-      0x3c7516df, 0xfd616b15, 0x2f501ec8, 0xad0552ab, 0x323db5fa, 0xfd238760,
-      0x53317b48, 0x3e00df82, 0x9e5c57bb, 0xca6f8ca0, 0x1a87562e, 0xdf1769db,
-      0xd542a8f6, 0x287effc3, 0xac6732c6, 0x8c4f5573, 0x695b27b0, 0xbbca58c8,
-      0xe1ffa35d, 0xb8f011a0, 0x10fa3d98, 0xfd2183b8, 0x4afcb56c, 0x2dd1d35b,
-      0x9a53e479, 0xb6f84565, 0xd28e49bc, 0x4bfb9790, 0xe1ddf2da, 0xa4cb7e33,
-      0x62fb1341, 0xcee4c6e8, 0xef20cada, 0x36774c01, 0xd07e9efe, 0x2bf11fb4,
-      0x95dbda4d, 0xae909198, 0xeaad8e71, 0x6b93d5a0, 0xd08ed1d0, 0xafc725e0,
-      0x8e3c5b2f, 0x8e7594b7, 0x8ff6e2fb, 0xf2122b64, 0x8888b812, 0x900df01c,
-      0x4fad5ea0, 0x688fc31c, 0xd1cff191, 0xb3a8c1ad, 0x2f2f2218, 0xbe0e1777,
-      0xea752dfe, 0x8b021fa1, 0xe5a0cc0f, 0xb56f74e8, 0x18acf3d6, 0xce89e299,
-      0xb4a84fe0, 0xfd13e0b7, 0x7cc43b81, 0xd2ada8d9, 0x165fa266, 0x80957705,
-      0x93cc7314, 0x211a1477, 0xe6ad2065, 0x77b5fa86, 0xc75442f5, 0xfb9d35cf,
-      0xebcdaf0c, 0x7b3e89a0, 0xd6411bd3, 0xae1e7e49, 0x00250e2d, 0x2071b35e,
-      0x226800bb, 0x57b8e0af, 0x2464369b, 0xf009b91e, 0x5563911d, 0x59dfa6aa,
-      0x78c14389, 0xd95a537f, 0x207d5ba2, 0x02e5b9c5, 0x83260376, 0x6295cfa9,
-      0x11c81968, 0x4e734a41, 0xb3472dca, 0x7b14a94a, 0x1b510052, 0x9a532915,
-      0xd60f573f, 0xbc9bc6e4, 0x2b60a476, 0x81e67400, 0x08ba6fb5, 0x571be91f,
-      0xf296ec6b, 0x2a0dd915, 0xb6636521, 0xe7b9f9b6, 0xff34052e, 0xc5855664,
-      0x53b02d5d, 0xa99f8fa1, 0x08ba4799, 0x6e85076a, 0x4b7a70e9, 0xb5b32944,
-      0xdb75092e, 0xc4192623, 0xad6ea6b0, 0x49a7df7d, 0x9cee60b8, 0x8fedb266,
-      0xecaa8c71, 0x699a17ff, 0x5664526c, 0xc2b19ee1, 0x193602a5, 0x75094c29,
-      0xa0591340, 0xe4183a3e, 0x3f54989a, 0x5b429d65, 0x6b8fe4d6, 0x99f73fd6,
-      0xa1d29c07, 0xefe830f5, 0x4d2d38e6, 0xf0255dc1, 0x4cdd2086, 0x8470eb26,
-      0x6382e9c6, 0x021ecc5e, 0x09686b3f, 0x3ebaefc9, 0x3c971814, 0x6b6a70a1,
-      0x687f3584, 0x52a0e286, 0xb79c5305, 0xaa500737, 0x3e07841c, 0x7fdeae5c,
-      0x8e7d44ec, 0x5716f2b8, 0xb03ada37, 0xf0500c0d, 0xf01c1f04, 0x0200b3ff,
-      0xae0cf51a, 0x3cb574b2, 0x25837a58, 0xdc0921bd, 0xd19113f9, 0x7ca92ff6,
-      0x94324773, 0x22f54701, 0x3ae5e581, 0x37c2dadc, 0xc8b57634, 0x9af3dda7,
-      0xa9446146, 0x0fd0030e, 0xecc8c73e, 0xa4751e41, 0xe238cd99, 0x3bea0e2f,
-      0x3280bba1, 0x183eb331, 0x4e548b38, 0x4f6db908, 0x6f420d03, 0xf60a04bf,
-      0x2cb81290, 0x24977c79, 0x5679b072, 0xbcaf89af, 0xde9a771f, 0xd9930810,
-      0xb38bae12, 0xdccf3f2e, 0x5512721f, 0x2e6b7124, 0x501adde6, 0x9f84cd87,
-      0x7a584718, 0x7408da17, 0xbc9f9abc, 0xe94b7d8c, 0xec7aec3a, 0xdb851dfa,
-      0x63094366, 0xc464c3d2, 0xef1c1847, 0x3215d908, 0xdd433b37, 0x24c2ba16,
-      0x12a14d43, 0x2a65c451, 0x50940002, 0x133ae4dd, 0x71dff89e, 0x10314e55,
-      0x81ac77d6, 0x5f11199b, 0x043556f1, 0xd7a3c76b, 0x3c11183b, 0x5924a509,
-      0xf28fe6ed, 0x97f1fbfa, 0x9ebabf2c, 0x1e153c6e, 0x86e34570, 0xeae96fb1,
-      0x860e5e0a, 0x5a3e2ab3, 0x771fe71c, 0x4e3d06fa, 0x2965dcb9, 0x99e71d0f,
-      0x803e89d6, 0x5266c825, 0x2e4cc978, 0x9c10b36a, 0xc6150eba, 0x94e2ea78,
-      0xa5fc3c53, 0x1e0a2df4, 0xf2f74ea7, 0x361d2b3d, 0x1939260f, 0x19c27960,
-      0x5223a708, 0xf71312b6, 0xebadfe6e, 0xeac31f66, 0xe3bc4595, 0xa67bc883,
-      0xb17f37d1, 0x018cff28, 0xc332ddef, 0xbe6c5aa5, 0x65582185, 0x68ab9802,
-      0xeecea50f, 0xdb2f953b, 0x2aef7dad, 0x5b6e2f84, 0x1521b628, 0x29076170,
-      0xecdd4775, 0x619f1510, 0x13cca830, 0xeb61bd96, 0x0334fe1e, 0xaa0363cf,
-      0xb5735c90, 0x4c70a239, 0xd59e9e0b, 0xcbaade14, 0xeecc86bc, 0x60622ca7,
-      0x9cab5cab, 0xb2f3846e, 0x648b1eaf, 0x19bdf0ca, 0xa02369b9, 0x655abb50,
-      0x40685a32, 0x3c2ab4b3, 0x319ee9d5, 0xc021b8f7, 0x9b540b19, 0x875fa099,
-      0x95f7997e, 0x623d7da8, 0xf837889a, 0x97e32d77, 0x11ed935f, 0x16681281,
-      0x0e358829, 0xc7e61fd6, 0x96dedfa1, 0x7858ba99, 0x57f584a5, 0x1b227263,
-      0x9b83c3ff, 0x1ac24696, 0xcdb30aeb, 0x532e3054, 0x8fd948e4, 0x6dbc3128,
-      0x58ebf2ef, 0x34c6ffea, 0xfe28ed61, 0xee7c3c73, 0x5d4a14d9, 0xe864b7e3,
-      0x42105d14, 0x203e13e0, 0x45eee2b6, 0xa3aaabea, 0xdb6c4f15, 0xfacb4fd0,
-      0xc742f442, 0xef6abbb5, 0x654f3b1d, 0x41cd2105, 0xd81e799e, 0x86854dc7,
-      0xe44b476a, 0x3d816250, 0xcf62a1f2, 0x5b8d2646, 0xfc8883a0, 0xc1c7b6a3,
-      0x7f1524c3, 0x69cb7492, 0x47848a0b, 0x5692b285, 0x095bbf00, 0xad19489d,
-      0x1462b174, 0x23820e00, 0x58428d2a, 0x0c55f5ea, 0x1dadf43e, 0x233f7061,
-      0x3372f092, 0x8d937e41, 0xd65fecf1, 0x6c223bdb, 0x7cde3759, 0xcbee7460,
-      0x4085f2a7, 0xce77326e, 0xa6078084, 0x19f8509e, 0xe8efd855, 0x61d99735,
-      0xa969a7aa, 0xc50c06c2, 0x5a04abfc, 0x800bcadc, 0x9e447a2e, 0xc3453484,
-      0xfdd56705, 0x0e1e9ec9, 0xdb73dbd3, 0x105588cd, 0x675fda79, 0xe3674340,
-      0xc5c43465, 0x713e38d8, 0x3d28f89e, 0xf16dff20, 0x153e21e7, 0x8fb03d4a,
-      0xe6e39f2b, 0xdb83adf7, 0xe93d5a68, 0x948140f7, 0xf64c261c, 0x94692934,
-      0x411520f7, 0x7602d4f7, 0xbcf46b2e, 0xd4a20068, 0xd4082471, 0x3320f46a,
-      0x43b7d4b7, 0x500061af, 0x1e39f62e, 0x97244546, 0x14214f74, 0xbf8b8840,
-      0x4d95fc1d, 0x96b591af, 0x70f4ddd3, 0x66a02f45, 0xbfbc09ec, 0x03bd9785,
-      0x7fac6dd0, 0x31cb8504, 0x96eb27b3, 0x55fd3941, 0xda2547e6, 0xabca0a9a,
-      0x28507825, 0x530429f4, 0x0a2c86da, 0xe9b66dfb, 0x68dc1462, 0xd7486900,
-      0x680ec0a4, 0x27a18dee, 0x4f3ffea2, 0xe887ad8c, 0xb58ce006, 0x7af4d6b6,
-      0xaace1e7c, 0xd3375fec, 0xce78a399, 0x406b2a42, 0x20fe9e35, 0xd9f385b9,
-      0xee39d7ab, 0x3b124e8b, 0x1dc9faf7, 0x4b6d1856, 0x26a36631, 0xeae397b2,
-      0x3a6efa74, 0xdd5b4332, 0x6841e7f7, 0xca7820fb, 0xfb0af54e, 0xd8feb397,
-      0x454056ac, 0xba489527, 0x55533a3a, 0x20838d87, 0xfe6ba9b7, 0xd096954b,
-      0x55a867bc, 0xa1159a58, 0xcca92963, 0x99e1db33, 0xa62a4a56, 0x3f3125f9,
-      0x5ef47e1c, 0x9029317c, 0xfdf8e802, 0x04272f70, 0x80bb155c, 0x05282ce3,
-      0x95c11548, 0xe4c66d22, 0x48c1133f, 0xc70f86dc, 0x07f9c9ee, 0x41041f0f,
-      0x404779a4, 0x5d886e17, 0x325f51eb, 0xd59bc0d1, 0xf2bcc18f, 0x41113564,
-      0x257b7834, 0x602a9c60, 0xdff8e8a3, 0x1f636c1b, 0x0e12b4c2, 0x02e1329e,
-      0xaf664fd1, 0xcad18115, 0x6b2395e0, 0x333e92e1, 0x3b240b62, 0xeebeb922,
-      0x85b2a20e, 0xe6ba0d99, 0xde720c8c, 0x2da2f728, 0xd0127845, 0x95b794fd,
-      0x647d0862, 0xe7ccf5f0, 0x5449a36f, 0x877d48fa, 0xc39dfd27, 0xf33e8d1e,
-      0x0a476341, 0x992eff74, 0x3a6f6eab, 0xf4f8fd37, 0xa812dc60, 0xa1ebddf8,
-      0x991be14c, 0xdb6e6b0d, 0xc67b5510, 0x6d672c37, 0x2765d43b, 0xdcd0e804,
-      0xf1290dc7, 0xcc00ffa3, 0xb5390f92, 0x690fed0b, 0x667b9ffb, 0xcedb7d9c,
-      0xa091cf0b, 0xd9155ea3, 0xbb132f88, 0x515bad24, 0x7b9479bf, 0x763bd6eb,
-      0x37392eb3, 0xcc115979, 0x8026e297, 0xf42e312d, 0x6842ada7, 0xc66a2b3b,
-      0x12754ccc, 0x782ef11c, 0x6a124237, 0xb79251e7, 0x06a1bbe6, 0x4bfb6350,
-      0x1a6b1018, 0x11caedfa, 0x3d25bdd8, 0xe2e1c3c9, 0x44421659, 0x0a121386,
-      0xd90cec6e, 0xd5abea2a, 0x64af674e, 0xda86a85f, 0xbebfe988, 0x64e4c3fe,
-      0x9dbc8057, 0xf0f7c086, 0x60787bf8, 0x6003604d, 0xd1fd8346, 0xf6381fb0,
-      0x7745ae04, 0xd736fccc, 0x83426b33, 0xf01eab71, 0xb0804187, 0x3c005e5f,
-      0x77a057be, 0xbde8ae24, 0x55464299, 0xbf582e61, 0x4e58f48f, 0xf2ddfda2,
-      0xf474ef38, 0x8789bdc2, 0x5366f9c3, 0xc8b38e74, 0xb475f255, 0x46fcd9b9,
-      0x7aeb2661, 0x8b1ddf84, 0x846a0e79, 0x915f95e2, 0x466e598e, 0x20b45770,
-      0x8cd55591, 0xc902de4c, 0xb90bace1, 0xbb8205d0, 0x11a86248, 0x7574a99e,
-      0xb77f19b6, 0xe0a9dc09, 0x662d09a1, 0xc4324633, 0xe85a1f02, 0x09f0be8c,
-      0x4a99a025, 0x1d6efe10, 0x1ab93d1d, 0x0ba5a4df, 0xa186f20f, 0x2868f169,
-      0xdcb7da83, 0x573906fe, 0xa1e2ce9b, 0x4fcd7f52, 0x50115e01, 0xa70683fa,
-      0xa002b5c4, 0x0de6d027, 0x9af88c27, 0x773f8641, 0xc3604c06, 0x61a806b5,
-      0xf0177a28, 0xc0f586e0, 0x006058aa, 0x30dc7d62, 0x11e69ed7, 0x2338ea63,
-      0x53c2dd94, 0xc2c21634, 0xbbcbee56, 0x90bcb6de, 0xebfc7da1, 0xce591d76,
-      0x6f05e409, 0x4b7c0188, 0x39720a3d, 0x7c927c24, 0x86e3725f, 0x724d9db9,
-      0x1ac15bb4, 0xd39eb8fc, 0xed545578, 0x08fca5b5, 0xd83d7cd3, 0x4dad0fc4,
-      0x1e50ef5e, 0xb161e6f8, 0xa28514d9, 0x6c51133c, 0x6fd5c7e7, 0x56e14ec4,
-      0x362abfce, 0xddc6c837, 0xd79a3234, 0x92638212, 0x670efa8e, 0x406000e0,
-      0x3a39ce37, 0xd3faf5cf, 0xabc27737, 0x5ac52d1b, 0x5cb0679e, 0x4fa33742,
-      0xd3822740, 0x99bc9bbe, 0xd5118e9d, 0xbf0f7315, 0xd62d1c7e, 0xc700c47b,
-      0xb78c1b6b, 0x21a19045, 0xb26eb1be, 0x6a366eb4, 0x5748ab2f, 0xbc946e79,
-      0xc6a376d2, 0x6549c2c8, 0x530ff8ee, 0x468dde7d, 0xd5730a1d, 0x4cd04dc6,
-      0x2939bbdb, 0xa9ba4650, 0xac9526e8, 0xbe5ee304, 0xa1fad5f0, 0x6a2d519a,
-      0x63ef8ce2, 0x9a86ee22, 0xc089c2b8, 0x43242ef6, 0xa51e03aa, 0x9cf2d0a4,
-      0x83c061ba, 0x9be96a4d, 0x8fe51550, 0xba645bd6, 0x2826a2f9, 0xa73a3ae1,
-      0x4ba99586, 0xef5562e9, 0xc72fefd3, 0xf752f7da, 0x3f046f69, 0x77fa0a59,
-      0x80e4a915, 0x87b08601, 0x9b09e6ad, 0x3b3ee593, 0xe990fd5a, 0x9e34d797,
-      0x2cf0b7d9, 0x022b8b51, 0x96d5ac3a, 0x017da67d, 0xd1cf3ed6, 0x7c7d2d28,
-      0x1f9f25cf, 0xadf2b89b, 0x5ad6b472, 0x5a88f54c, 0xe029ac71, 0xe019a5e6,
-      0x47b0acfd, 0xed93fa9b, 0xe8d3c48d, 0x283b57cc, 0xf8d56629, 0x79132e28,
-      0x785f0191, 0xed756055, 0xf7960e44, 0xe3d35e8c, 0x15056dd4, 0x88f46dba,
-      0x03a16125, 0x0564f0bd, 0xc3eb9e15, 0x3c9057a2, 0x97271aec, 0xa93a072a,
-      0x1b3f6d9b, 0x1e6321f5, 0xf59c66fb, 0x26dcf319, 0x7533d928, 0xb155fdf5,
-      0x03563482, 0x8aba3cbb, 0x28517711, 0xc20ad9f8, 0xabcc5167, 0xccad925f,
-      0x4de81751, 0x3830dc8e, 0x379d5862, 0x9320f991, 0xea7a90c2, 0xfb3e7bce,
-      0x5121ce64, 0x774fbe32, 0xa8b6e37e, 0xc3293d46, 0x48de5369, 0x6413e680,
-      0xa2ae0810, 0xdd6db224, 0x69852dfd, 0x09072166, 0xb39a460a, 0x6445c0dd,
-      0x586cdecf, 0x1c20c8ae, 0x5bbef7dd, 0x1b588d40, 0xccd2017f, 0x6bb4e3bb,
-      0xdda26a7e, 0x3a59ff45, 0x3e350a44, 0xbcb4cdd5, 0x72eacea8, 0xfa6484bb,
-      0x8d6612ae, 0xbf3c6f47, 0xd29be463, 0x542f5d9e, 0xaec2771b, 0xf64e6370,
-      0x740e0d8d, 0xe75b1357, 0xf8721671, 0xaf537d5d, 0x4040cb08, 0x4eb4e2cc,
-      0x34d2466a, 0x0115af84, 0xe1b00428, 0x95983a1d, 0x06b89fb4, 0xce6ea048,
-      0x6f3f3b82, 0x3520ab82, 0x011a1d4b, 0x277227f8, 0x611560b1, 0xe7933fdc,
-      0xbb3a792b, 0x344525bd, 0xa08839e1, 0x51ce794b, 0x2f32c9b7, 0xa01fbac9,
-      0xe01cc87e, 0xbcc7d1f6, 0xcf0111c3, 0xa1e8aac7, 0x1a908749, 0xd44fbd9a,
-      0xd0dadecb, 0xd50ada38, 0x0339c32a, 0xc6913667, 0x8df9317c, 0xe0b12b4f,
-      0xf79e59b7, 0x43f5bb3a, 0xf2d519ff, 0x27d9459c, 0xbf97222c, 0x15e6fc2a,
-      0x0f91fc71, 0x9b941525, 0xfae59361, 0xceb69ceb, 0xc2a86459, 0x12baa8d1,
-      0xb6c1075e, 0xe3056a0c, 0x10d25065, 0xcb03a442, 0xe0ec6e0e, 0x1698db3b,
-      0x4c98a0be, 0x3278e964, 0x9f1f9532, 0xe0d392df, 0xd3a0342b, 0x8971f21e,
-      0x1b0a7441, 0x4ba3348c, 0xc5be7120, 0xc37632d8, 0xdf359f8d, 0x9b992f2e,
-      0xe60b6f47, 0x0fe3f11d, 0xe54cda54, 0x1edad891, 0xce6279cf, 0xcd3e7e6f,
-      0x1618b166, 0xfd2c1d05, 0x848fd2c5, 0xf6fb2299, 0xf523f357, 0xa6327623,
-      0x93a83531, 0x56cccd02, 0xacf08162, 0x5a75ebb5, 0x6e163697, 0x88d273cc,
-      0xde966292, 0x81b949d0, 0x4c50901b, 0x71c65614, 0xe6c6c7bd, 0x327a140a,
-      0x45e1d006, 0xc3f27b9a, 0xc9aa53fd, 0x62a80f00, 0xbb25bfe2, 0x35bdd2f6,
-      0x71126905, 0xb2040222, 0xb6cbcf7c, 0xcd769c2b, 0x53113ec0, 0x1640e3d3,
-      0x38abbd60, 0x2547adf0, 0xba38209c, 0xf746ce76, 0x77afa1c5, 0x20756060,
-      0x85cbfe4e, 0x8ae88dd8, 0x7aaaf9b0, 0x4cf9aa7e, 0x1948c25c, 0x02fb8a8c,
-      0x01c36ae4, 0xd6ebe1f9, 0x90d4f869, 0xa65cdea0, 0x3f09252d, 0xc208e69f,
-      0xb74e6132, 0xce77e25b, 0x578fdfe3, 0x3ac372e6,
-    ];
-
-    /**
-     * @type {Array.<number>}
-     * @const
-     * @inner
-     */
-    var C_ORIG = [
-      0x4f727068, 0x65616e42, 0x65686f6c, 0x64657253, 0x63727944, 0x6f756274,
-    ];
-
-    /**
-     * @param {Array.<number>} lr
-     * @param {number} off
-     * @param {Array.<number>} P
-     * @param {Array.<number>} S
-     * @returns {Array.<number>}
-     * @inner
-     */
-    function _encipher(lr, off, P, S) {
-      // This is our bottleneck: 1714/1905 ticks / 90% - see profile.txt
-      var n,
-        l = lr[off],
-        r = lr[off + 1];
-      l ^= P[0];
-
-      /*
-      for (var i=0, k=BLOWFISH_NUM_ROUNDS-2; i<=k;)
-          // Feistel substitution on left word
-          n  = S[l >>> 24],
-          n += S[0x100 | ((l >> 16) & 0xff)],
-          n ^= S[0x200 | ((l >> 8) & 0xff)],
-          n += S[0x300 | (l & 0xff)],
-          r ^= n ^ P[++i],
-          // Feistel substitution on right word
-          n  = S[r >>> 24],
-          n += S[0x100 | ((r >> 16) & 0xff)],
-          n ^= S[0x200 | ((r >> 8) & 0xff)],
-          n += S[0x300 | (r & 0xff)],
-          l ^= n ^ P[++i];
-      */
-
-      //The following is an unrolled version of the above loop.
-      //Iteration 0
-      n = S[l >>> 24];
-      n += S[0x100 | ((l >> 16) & 0xff)];
-      n ^= S[0x200 | ((l >> 8) & 0xff)];
-      n += S[0x300 | (l & 0xff)];
-      r ^= n ^ P[1];
-      n = S[r >>> 24];
-      n += S[0x100 | ((r >> 16) & 0xff)];
-      n ^= S[0x200 | ((r >> 8) & 0xff)];
-      n += S[0x300 | (r & 0xff)];
-      l ^= n ^ P[2];
-      //Iteration 1
-      n = S[l >>> 24];
-      n += S[0x100 | ((l >> 16) & 0xff)];
-      n ^= S[0x200 | ((l >> 8) & 0xff)];
-      n += S[0x300 | (l & 0xff)];
-      r ^= n ^ P[3];
-      n = S[r >>> 24];
-      n += S[0x100 | ((r >> 16) & 0xff)];
-      n ^= S[0x200 | ((r >> 8) & 0xff)];
-      n += S[0x300 | (r & 0xff)];
-      l ^= n ^ P[4];
-      //Iteration 2
-      n = S[l >>> 24];
-      n += S[0x100 | ((l >> 16) & 0xff)];
-      n ^= S[0x200 | ((l >> 8) & 0xff)];
-      n += S[0x300 | (l & 0xff)];
-      r ^= n ^ P[5];
-      n = S[r >>> 24];
-      n += S[0x100 | ((r >> 16) & 0xff)];
-      n ^= S[0x200 | ((r >> 8) & 0xff)];
-      n += S[0x300 | (r & 0xff)];
-      l ^= n ^ P[6];
-      //Iteration 3
-      n = S[l >>> 24];
-      n += S[0x100 | ((l >> 16) & 0xff)];
-      n ^= S[0x200 | ((l >> 8) & 0xff)];
-      n += S[0x300 | (l & 0xff)];
-      r ^= n ^ P[7];
-      n = S[r >>> 24];
-      n += S[0x100 | ((r >> 16) & 0xff)];
-      n ^= S[0x200 | ((r >> 8) & 0xff)];
-      n += S[0x300 | (r & 0xff)];
-      l ^= n ^ P[8];
-      //Iteration 4
-      n = S[l >>> 24];
-      n += S[0x100 | ((l >> 16) & 0xff)];
-      n ^= S[0x200 | ((l >> 8) & 0xff)];
-      n += S[0x300 | (l & 0xff)];
-      r ^= n ^ P[9];
-      n = S[r >>> 24];
-      n += S[0x100 | ((r >> 16) & 0xff)];
-      n ^= S[0x200 | ((r >> 8) & 0xff)];
-      n += S[0x300 | (r & 0xff)];
-      l ^= n ^ P[10];
-      //Iteration 5
-      n = S[l >>> 24];
-      n += S[0x100 | ((l >> 16) & 0xff)];
-      n ^= S[0x200 | ((l >> 8) & 0xff)];
-      n += S[0x300 | (l & 0xff)];
-      r ^= n ^ P[11];
-      n = S[r >>> 24];
-      n += S[0x100 | ((r >> 16) & 0xff)];
-      n ^= S[0x200 | ((r >> 8) & 0xff)];
-      n += S[0x300 | (r & 0xff)];
-      l ^= n ^ P[12];
-      //Iteration 6
-      n = S[l >>> 24];
-      n += S[0x100 | ((l >> 16) & 0xff)];
-      n ^= S[0x200 | ((l >> 8) & 0xff)];
-      n += S[0x300 | (l & 0xff)];
-      r ^= n ^ P[13];
-      n = S[r >>> 24];
-      n += S[0x100 | ((r >> 16) & 0xff)];
-      n ^= S[0x200 | ((r >> 8) & 0xff)];
-      n += S[0x300 | (r & 0xff)];
-      l ^= n ^ P[14];
-      //Iteration 7
-      n = S[l >>> 24];
-      n += S[0x100 | ((l >> 16) & 0xff)];
-      n ^= S[0x200 | ((l >> 8) & 0xff)];
-      n += S[0x300 | (l & 0xff)];
-      r ^= n ^ P[15];
-      n = S[r >>> 24];
-      n += S[0x100 | ((r >> 16) & 0xff)];
-      n ^= S[0x200 | ((r >> 8) & 0xff)];
-      n += S[0x300 | (r & 0xff)];
-      l ^= n ^ P[16];
-      lr[off] = r ^ P[BLOWFISH_NUM_ROUNDS + 1];
-      lr[off + 1] = l;
-      return lr;
-    }
-
-    /**
-     * @param {Array.<number>} data
-     * @param {number} offp
-     * @returns {{key: number, offp: number}}
-     * @inner
-     */
-    function _streamtoword(data, offp) {
-      for (var i = 0, word = 0; i < 4; ++i)
-        (word = (word << 8) | (data[offp] & 0xff)),
-          (offp = (offp + 1) % data.length);
-      return {
-        key: word,
-        offp: offp,
-      };
-    }
-
-    /**
-     * @param {Array.<number>} key
-     * @param {Array.<number>} P
-     * @param {Array.<number>} S
-     * @inner
-     */
-    function _key(key, P, S) {
-      var offset = 0,
-        lr = [0, 0],
-        plen = P.length,
-        slen = S.length,
-        sw;
-      for (var i = 0; i < plen; i++)
-        (sw = _streamtoword(key, offset)),
-          (offset = sw.offp),
-          (P[i] = P[i] ^ sw.key);
-      for (i = 0; i < plen; i += 2)
-        (lr = _encipher(lr, 0, P, S)), (P[i] = lr[0]), (P[i + 1] = lr[1]);
-      for (i = 0; i < slen; i += 2)
-        (lr = _encipher(lr, 0, P, S)), (S[i] = lr[0]), (S[i + 1] = lr[1]);
-    }
-
-    /**
-     * Expensive key schedule Blowfish.
-     * @param {Array.<number>} data
-     * @param {Array.<number>} key
-     * @param {Array.<number>} P
-     * @param {Array.<number>} S
-     * @inner
-     */
-    function _ekskey(data, key, P, S) {
-      var offp = 0,
-        lr = [0, 0],
-        plen = P.length,
-        slen = S.length,
-        sw;
-      for (var i = 0; i < plen; i++)
-        (sw = _streamtoword(key, offp)),
-          (offp = sw.offp),
-          (P[i] = P[i] ^ sw.key);
-      offp = 0;
-      for (i = 0; i < plen; i += 2)
-        (sw = _streamtoword(data, offp)),
-          (offp = sw.offp),
-          (lr[0] ^= sw.key),
-          (sw = _streamtoword(data, offp)),
-          (offp = sw.offp),
-          (lr[1] ^= sw.key),
-          (lr = _encipher(lr, 0, P, S)),
-          (P[i] = lr[0]),
-          (P[i + 1] = lr[1]);
-      for (i = 0; i < slen; i += 2)
-        (sw = _streamtoword(data, offp)),
-          (offp = sw.offp),
-          (lr[0] ^= sw.key),
-          (sw = _streamtoword(data, offp)),
-          (offp = sw.offp),
-          (lr[1] ^= sw.key),
-          (lr = _encipher(lr, 0, P, S)),
-          (S[i] = lr[0]),
-          (S[i + 1] = lr[1]);
-    }
-
-    /**
-     * Internaly crypts a string.
-     * @param {Array.<number>} b Bytes to crypt
-     * @param {Array.<number>} salt Salt bytes to use
-     * @param {number} rounds Number of rounds
-     * @param {function(Error, Array.<number>=)=} callback Callback receiving the error, if any, and the resulting bytes. If
-     *  omitted, the operation will be performed synchronously.
-     *  @param {function(number)=} progressCallback Callback called with the current progress
-     * @returns {!Array.<number>|undefined} Resulting bytes if callback has been omitted, otherwise `undefined`
-     * @inner
-     */
-    function _crypt(b, salt, rounds, callback, progressCallback) {
-      var cdata = C_ORIG.slice(),
-        clen = cdata.length,
-        err;
-
-      // Validate
-      if (rounds < 4 || rounds > 31) {
-        err = Error("Illegal number of rounds (4-31): " + rounds);
-        if (callback) {
-          nextTick(callback.bind(this, err));
-          return;
-        } else throw err;
-      }
-      if (salt.length !== BCRYPT_SALT_LEN) {
-        err = Error(
-          "Illegal salt length: " + salt.length + " != " + BCRYPT_SALT_LEN,
-        );
-        if (callback) {
-          nextTick(callback.bind(this, err));
-          return;
-        } else throw err;
-      }
-      rounds = (1 << rounds) >>> 0;
-      var P,
-        S,
-        i = 0,
-        j;
-
-      //Use typed arrays when available - huge speedup!
-      if (typeof Int32Array === "function") {
-        P = new Int32Array(P_ORIG);
-        S = new Int32Array(S_ORIG);
-      } else {
-        P = P_ORIG.slice();
-        S = S_ORIG.slice();
-      }
-      _ekskey(salt, b, P, S);
-
-      /**
-       * Calcualtes the next round.
-       * @returns {Array.<number>|undefined} Resulting array if callback has been omitted, otherwise `undefined`
-       * @inner
-       */
-      function next() {
-        if (progressCallback) progressCallback(i / rounds);
-        if (i < rounds) {
-          var start = Date.now();
-          for (; i < rounds; ) {
-            i = i + 1;
-            _key(b, P, S);
-            _key(salt, P, S);
-            if (Date.now() - start > MAX_EXECUTION_TIME) break;
-          }
-        } else {
-          for (i = 0; i < 64; i++)
-            for (j = 0; j < clen >> 1; j++) _encipher(cdata, j << 1, P, S);
-          var ret = [];
-          for (i = 0; i < clen; i++)
-            ret.push(((cdata[i] >> 24) & 0xff) >>> 0),
-              ret.push(((cdata[i] >> 16) & 0xff) >>> 0),
-              ret.push(((cdata[i] >> 8) & 0xff) >>> 0),
-              ret.push((cdata[i] & 0xff) >>> 0);
-          if (callback) {
-            callback(null, ret);
-            return;
-          } else return ret;
-        }
-        if (callback) nextTick(next);
-      }
-
-      // Async
-      if (typeof callback !== "undefined") {
-        next();
-
-        // Sync
-      } else {
-        var res;
-        while (true)
-          if (typeof (res = next()) !== "undefined") return res || [];
-      }
-    }
-
-    /**
-     * Internally hashes a password.
-     * @param {string} password Password to hash
-     * @param {?string} salt Salt to use, actually never null
-     * @param {function(Error, string=)=} callback Callback receiving the error, if any, and the resulting hash. If omitted,
-     *  hashing is performed synchronously.
-     *  @param {function(number)=} progressCallback Callback called with the current progress
-     * @returns {string|undefined} Resulting hash if callback has been omitted, otherwise `undefined`
-     * @inner
-     */
-    function _hash(password, salt, callback, progressCallback) {
-      var err;
-      if (typeof password !== "string" || typeof salt !== "string") {
-        err = Error("Invalid string / salt: Not a string");
-        if (callback) {
-          nextTick(callback.bind(this, err));
-          return;
-        } else throw err;
-      }
-
-      // Validate the salt
-      var minor, offset;
-      if (salt.charAt(0) !== "$" || salt.charAt(1) !== "2") {
-        err = Error("Invalid salt version: " + salt.substring(0, 2));
-        if (callback) {
-          nextTick(callback.bind(this, err));
-          return;
-        } else throw err;
-      }
-      if (salt.charAt(2) === "$")
-        (minor = String.fromCharCode(0)), (offset = 3);
-      else {
-        minor = salt.charAt(2);
-        if (
-          (minor !== "a" && minor !== "b" && minor !== "y") ||
-          salt.charAt(3) !== "$"
-        ) {
-          err = Error("Invalid salt revision: " + salt.substring(2, 4));
-          if (callback) {
-            nextTick(callback.bind(this, err));
-            return;
-          } else throw err;
-        }
-        offset = 4;
-      }
-
-      // Extract number of rounds
-      if (salt.charAt(offset + 2) > "$") {
-        err = Error("Missing salt rounds");
-        if (callback) {
-          nextTick(callback.bind(this, err));
-          return;
-        } else throw err;
-      }
-      var r1 = parseInt(salt.substring(offset, offset + 1), 10) * 10,
-        r2 = parseInt(salt.substring(offset + 1, offset + 2), 10),
-        rounds = r1 + r2,
-        real_salt = salt.substring(offset + 3, offset + 25);
-      password += minor >= "a" ? "\x00" : "";
-      var passwordb = utf8Array(password),
-        saltb = base64_decode(real_salt, BCRYPT_SALT_LEN);
-
-      /**
-       * Finishes hashing.
-       * @param {Array.<number>} bytes Byte array
-       * @returns {string}
-       * @inner
-       */
-      function finish(bytes) {
-        var res = [];
-        res.push("$2");
-        if (minor >= "a") res.push(minor);
-        res.push("$");
-        if (rounds < 10) res.push("0");
-        res.push(rounds.toString());
-        res.push("$");
-        res.push(base64_encode(saltb, saltb.length));
-        res.push(base64_encode(bytes, C_ORIG.length * 4 - 1));
-        return res.join("");
-      }
-
-      // Sync
-      if (typeof callback == "undefined")
-        return finish(_crypt(passwordb, saltb, rounds));
-      // Async
-      else {
-        _crypt(
-          passwordb,
-          saltb,
-          rounds,
-          function (err, bytes) {
-            if (err) callback(err, null);
-            else callback(null, finish(bytes));
-          },
-          progressCallback,
-        );
-      }
-    }
-
-    /**
-     * Encodes a byte array to base64 with up to len bytes of input, using the custom bcrypt alphabet.
-     * @function
-     * @param {!Array.<number>} bytes Byte array
-     * @param {number} length Maximum input length
-     * @returns {string}
-     */
-    function encodeBase64(bytes, length) {
-      return base64_encode(bytes, length);
-    }
-
-    /**
-     * Decodes a base64 encoded string to up to len bytes of output, using the custom bcrypt alphabet.
-     * @function
-     * @param {string} string String to decode
-     * @param {number} length Maximum output length
-     * @returns {!Array.<number>}
-     */
-    function decodeBase64(string, length) {
-      return base64_decode(string, length);
-    }
-    var _default = (_exports.default = {
-      setRandomFallback,
-      genSaltSync,
-      genSalt,
-      hashSync,
-      hash,
-      compareSync,
-      compare,
-      getRounds,
-      getSalt,
-      truncates,
-      encodeBase64,
-      decodeBase64,
-    });
-  },
-);

+ 2 - 1
phpcs.xml

@@ -5,7 +5,7 @@
 	<exclude-pattern>/\.git/*</exclude-pattern>
 	<exclude-pattern>/data/config.php</exclude-pattern>
 	<exclude-pattern>/data/update.php</exclude-pattern>
-	<exclude-pattern>/data/users/*/config.php</exclude-pattern>
+	<exclude-pattern>/data/users/*</exclude-pattern>
 	<exclude-pattern>/(?-i:extensions)/*</exclude-pattern><!-- Case sensitive to allow our Extensions repo -->
 	<exclude-pattern>/lib/http-conditional.php</exclude-pattern>
 	<exclude-pattern>/lib/marienfressinaud/</exclude-pattern>
@@ -32,6 +32,7 @@
 		<exclude name="PSR12.Classes.OpeningBraceSpace.Found"/><!-- Consider using PSR12 defaults instead -->
 		<exclude name="PSR12.ControlStructures.ControlStructureSpacing.CloseParenthesisLine"/>
 		<exclude name="PSR12.ControlStructures.ControlStructureSpacing.FirstExpressionLine"/><!-- Consider using PSR12 defaults instead -->
+		<exclude name="PSR12.ControlStructures.ControlStructureSpacing.LineIndent"/>
 		<exclude name="PSR12.Files.FileHeader.IncorrectOrder"/><!-- Consider using PSR12 defaults instead -->
 		<exclude name="PSR12.Files.FileHeader.SpacingAfterBlock"/><!-- Legacy PHPCS 3 -->
 		<exclude name="PSR12.Files.FileHeader.SpacingAfterDeclareBlock"/><!-- Consider using PSR12 defaults instead -->

+ 339 - 0
tests/app/Models/FormAuthTest.php

@@ -0,0 +1,339 @@
+<?php
+declare(strict_types=1);
+
+final class FormAuthTest extends \PHPUnit\Framework\TestCase {
+	public function testCheck(): void {
+		$password = '1234567';
+		$ok = FreshRSS_FormAuth::passwordRequirementsMet($password);
+		self::assertTrue($ok);
+	}
+
+	public function testReqsCheckReturnsFalseIfEmpty(): void {
+		$password = '';
+		$ok = FreshRSS_FormAuth::passwordRequirementsMet($password);
+		self::assertFalse($ok);
+	}
+
+	public function testReqsCheckReturnsFalseIfLessThan7Characters(): void {
+		$password = '123456';
+		$ok = FreshRSS_FormAuth::passwordRequirementsMet($password);
+		self::assertFalse($ok);
+	}
+
+	public function testReqsCheckReturnsFalseIfLessThan7Utf8Bytes(): void {
+		$password = str_repeat('é', 3);
+		self::assertSame(6, strlen($password));
+
+		$ok = FreshRSS_FormAuth::passwordRequirementsMet($password);
+		self::assertFalse($ok);
+	}
+
+	public function testReqsCheckReturnsTrueIfAtExactly72Characters(): void {
+		$password = str_repeat('A', 72);
+		$ok = FreshRSS_FormAuth::passwordRequirementsMet($password);
+		self::assertTrue($ok);
+	}
+
+	public function testReqsCheckReturnsFalseIfMoreThan72Characters(): void {
+		$password = str_repeat('A', 73);
+		$ok = FreshRSS_FormAuth::passwordRequirementsMet($password);
+		self::assertFalse($ok);
+	}
+
+	public function testReqsCheckReturnsTrueIfAtExactly72Utf8Bytes(): void {
+		$password = str_repeat('é', 36);
+		self::assertSame(72, strlen($password));
+
+		$ok = FreshRSS_FormAuth::passwordRequirementsMet($password);
+		self::assertTrue($ok);
+	}
+
+	public function testReqsCheckReturnsFalseIfMoreThan72Utf8Bytes(): void {
+		$password = str_repeat('é', 37);
+		self::assertSame(74, strlen($password));
+
+		$ok = FreshRSS_FormAuth::passwordRequirementsMet($password);
+		self::assertFalse($ok);
+	}
+
+	public function testReqsCheckReturnsFalseIfAtExactly73Utf8Bytes(): void {
+		$password = str_repeat('é', 36) . 'a';
+		self::assertSame(73, strlen($password));
+
+		$ok = FreshRSS_FormAuth::passwordRequirementsMet($password);
+		self::assertFalse($ok);
+	}
+
+	public function testReqsCheckAcceptsOtherMultibyteBoundary(): void {
+		$password = str_repeat('€', 24);
+		self::assertSame(72, strlen($password));
+		self::assertTrue(FreshRSS_FormAuth::passwordRequirementsMet($password));
+
+		$password .= '€';
+		self::assertSame(75, strlen($password));
+		self::assertFalse(FreshRSS_FormAuth::passwordRequirementsMet($password));
+	}
+
+	public function testReqsCheckReturnsTrueIfLessThan7CharactersAndWithNoMinLengthEnforcement(): void {
+		$password = '123456';
+		$ok = FreshRSS_FormAuth::passwordRequirementsMet($password, ['enforceMinLength' => false]);
+		self::assertTrue($ok);
+	}
+
+	public function testReqsCheckReturnsTrueIfLessThan7Utf8BytesAndWithNoMinLengthEnforcement(): void {
+		$password = str_repeat('é', 3);
+		self::assertSame(6, strlen($password));
+
+		$ok = FreshRSS_FormAuth::passwordRequirementsMet($password, ['enforceMinLength' => false]);
+		self::assertTrue($ok);
+	}
+
+	public function testAuthWithInvalidUsernameAndCorrectCredentialsFail(): void {
+		$username = ':-)';
+		$password = '1234567';
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertFalse($ok);
+		self::assertSame("Invalid credential parameters: user={$username}", Minz_Log::getLastLog());
+	}
+
+	public function testAuthWithValidUsernameAndWrongCredentialsFail(): void {
+		$username = 'admin';
+		$password = 'correctpassword';
+		$hash = FreshRSS_password_Util::hash('badpassword');
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertFalse($ok);
+		self::assertSame('', Minz_Log::getLastLog());
+	}
+
+	public function testAuthWithValidUsernameAndCorrectCredentialsOk(): void {
+		$username = 'admin';
+		$password = 'correctpassword';
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertTrue($ok);
+		self::assertSame('', Minz_Log::getLastLog());
+	}
+
+	public function testAuthWithValidUsernameAndCorrectCredentialsAndTooLongUtf8PasswordOk(): void {
+		$username = 'admin';
+		$password = str_repeat('a', 71) . 'é';
+		self::assertSame(73, strlen($password));
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertTrue($ok); // Login is successful with a cut-off UTF-8 character at the end
+		self::assertSame('', Minz_Log::getLastLog());
+
+		$password .= 'é';
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertFalse($ok);
+		self::assertSame("Exceeded maximum allowed password length during authentication: user={$username}", Minz_Log::getLastLog());
+	}
+
+	public function testAuthWithValidUsernameAndCorrectCredentialsAndTooLongUtf8PasswordWithCutOffCharacterFollowedByAnotherCharacterFail(): void {
+		$username = 'admin';
+		$password = str_repeat('a', 71) . 'éa';
+		self::assertSame(74, strlen($password));
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertFalse($ok);
+		self::assertSame("Exceeded maximum allowed password length during authentication: user={$username}", Minz_Log::getLastLog());
+	}
+
+	public function testAuthWithValidUsernameAndCorrectCredentialsAndTooLongUtf8PasswordWithThreeByteCharacterOk(): void {
+		$username = 'admin';
+		$password = str_repeat('a', 71) . '€';
+		self::assertSame(74, strlen($password));
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertTrue($ok); // Login is successful with a cut-off UTF-8 character at the end
+		self::assertSame('', Minz_Log::getLastLog());
+
+		$password .= '€';
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertFalse($ok);
+		self::assertSame("Exceeded maximum allowed password length during authentication: user={$username}", Minz_Log::getLastLog());
+	}
+
+	public function testAuthWithValidUsernameAndCorrectCredentialsAndTooLongUtf8PasswordWithFourByteCharacterOk(): void {
+		$username = 'admin';
+		$password = str_repeat('a', 71) . '😀';
+		self::assertSame(75, strlen($password));
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertTrue($ok); // Login is successful with a cut-off UTF-8 character at the end
+		self::assertSame('', Minz_Log::getLastLog());
+
+		$password .= '😀';
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertFalse($ok);
+		self::assertSame("Exceeded maximum allowed password length during authentication: user={$username}", Minz_Log::getLastLog());
+	}
+
+	public function testAuthWithValidUsernameAndCorrectCredentialsAndTooLongUtf8PasswordWithThreeByteCharacterCutOffAfterTwoBytesOk(): void {
+		$username = 'admin';
+		$password = str_repeat('a', 70) . '€';
+		self::assertSame(73, strlen($password));
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertTrue($ok);
+		self::assertSame('', Minz_Log::getLastLog());
+	}
+
+	public function testAuthWithValidUsernameAndCorrectCredentialsAndTooLongUtf8PasswordWithFourByteCharacterCutOffAfterTwoBytesOk(): void {
+		$username = 'admin';
+		$password = str_repeat('a', 70) . '😀';
+		self::assertSame(74, strlen($password));
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertTrue($ok);
+		self::assertSame('', Minz_Log::getLastLog());
+	}
+
+	public function testAuthWithValidUsernameAndCorrectCredentialsAndTooLongUtf8PasswordWithFourByteCharacterCutOffAfterThreeBytesOk(): void {
+		$username = 'admin';
+		$password = str_repeat('a', 69) . '😀';
+		self::assertSame(73, strlen($password));
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertTrue($ok);
+		self::assertSame('', Minz_Log::getLastLog());
+	}
+
+	public function testAuthWithValidUsernameAndCorrectCredentialsAndTooLongUtf8PasswordWithoutCutOffCharacter(): void {
+		$username = 'admin';
+		$password = str_repeat('a', 72) . 'é';
+		self::assertSame(74, strlen($password));
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertFalse($ok);
+		self::assertSame("Exceeded maximum allowed password length during authentication: user={$username}", Minz_Log::getLastLog());
+	}
+
+	public function testAuthWithValidUsernameAndCorrectCredentialsAndHugeUtf8Password(): void {
+		$username = 'admin';
+		$password = str_repeat('a', 10000) . 'é';
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertFalse($ok);
+		self::assertSame("Exceeded maximum allowed password length during authentication: user={$username}", Minz_Log::getLastLog());
+	}
+
+	public function testAuthWithValidUsernameAndCorrectCredentialsAndEmptyPasswordFail(): void {
+		$username = 'admin';
+		$password = '';
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertFalse($ok);
+		self::assertSame("Refusing authentication with empty zero-length password: user={$username}", Minz_Log::getLastLog());
+	}
+
+	public function testAuthWithValidUsernameAndCorrectCredentialsAndTooLongPasswordFail(): void {
+		$username = 'admin';
+		$password = str_repeat('a', 73);
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertFalse($ok);
+		self::assertSame("Exceeded maximum allowed password length during authentication: user={$username}", Minz_Log::getLastLog());
+
+		// It's fine if the user truncates their own password though
+		// Note: There are separate tests for UTF-8 passwords to test the case
+		// of a cut-off UTF-8 character at the end, which can't easily be truncated
+		// by a user.
+		$password = str_repeat('a', 72);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertTrue($ok);
+		self::assertSame('', Minz_Log::getLastLog());
+
+		// If truncated down to less than 72 characters, login should fail
+		$password = str_repeat('a', 71);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertFalse($ok);
+		self::assertSame('', Minz_Log::getLastLog());
+	}
+
+	public function testAuthWithValidUsernameAndCorrectCredentialsAndWrongPassAlgorithmFail(): void {
+		$username = 'admin';
+		$password = '1234567';
+		$hash = crypt($password, 'ab'); // Uses `password_verify()` compatible algorithm
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertFalse($ok);
+		self::assertSame("Invalid hash format: user={$username}", Minz_Log::getLastLog());
+	}
+
+	public function testAuthWithValidUsernameAndAnyCredentialsAndMalformedBcryptHashFail(): void {
+		$username = 'admin';
+		$password = 'whatever';
+		$hash = '$2y$04$00000000$'; // See: https://github.com/php/php-src/security/advisories/GHSA-7fj2-8x79-rjf4
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertFalse($ok);
+		self::assertSame("Invalid hash format: user={$username}", Minz_Log::getLastLog());
+	}
+
+	public function testAuthWithValidUsernameAndAnyCredentialsAndEmptyHashFail(): void {
+		$username = 'admin';
+		$password = 'whatever';
+		$hash = '';
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $password
+		);
+		self::assertFalse($ok);
+		self::assertSame("Invalid credential parameters: user={$username}", Minz_Log::getLastLog()); // `ctype_graph($hash)` returns false on empty string values
+	}
+
+	public function testAuthWithValidUsernameAndCorrectHashAsPasswordFail(): void {
+		$username = 'admin';
+		$password = 'whatever';
+		$hash = FreshRSS_password_Util::hash($password);
+		$ok = FreshRSS_FormAuth::checkCredentials(
+			$username, $hash, $hash
+		);
+		self::assertFalse($ok);
+		self::assertSame('', Minz_Log::getLastLog());
+	}
+}

+ 0 - 28
tests/app/Utils/passwordUtilTest.php

@@ -1,28 +0,0 @@
-<?php
-declare(strict_types=1);
-
-final class passwordUtilTest extends \PHPUnit\Framework\TestCase {
-	public function testCheck(): void {
-		$password = '1234567';
-
-		$ok = FreshRSS_password_Util::check($password);
-
-		self::assertTrue($ok);
-	}
-
-	public function testCheckReturnsFalseIfEmpty(): void {
-		$password = '';
-
-		$ok = FreshRSS_password_Util::check($password);
-
-		self::assertFalse($ok);
-	}
-
-	public function testCheckReturnsFalseIfLessThan7Characters(): void {
-		$password = '123456';
-
-		$ok = FreshRSS_password_Util::check($password);
-
-		self::assertFalse($ok);
-	}
-}