Hash passwords with bcrypt instead of a single SHA-512 round
New passwords are stored as {BLF-CRYPT} with cost 12, which Dovecot 2.4
verifies. Existing {SSHA512} hashes are still checked and replaced at the
next login. Unknown users are checked against a dummy hash, so the
response time does not reveal whether an email exists.
bcrypt only uses 72 bytes and bcrypt 5 raises for longer passwords, so
validate_password limits passwords to 72 bytes when creating a user,
changing a password and in the CLI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b1c412f3d7
commit
5eea5da396
11 files changed
+189
-20
No files matched your search
@@ -1,4 +1,9 @@
|
||||
from .auth import change_password, check_password, hash_password # noqa
|
||||
from .auth import ( # noqa
|
||||
change_password,
|
||||
check_password,
|
||||
hash_password,
|
||||
validate_password,
|
||||
)
|
||||
from .middlewares import AuthMiddleware, no_authentication_required # noqa
|
||||
from .routes import init_routes
|
||||
|
||||
|
||||
+80
-12
@@ -1,16 +1,31 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from flask import session
|
||||
import bcrypt
|
||||
from flask import current_app, session
|
||||
|
||||
from max.crypto import constant_time_compare, force_bytes
|
||||
from max.db import get_user_by_email, update_user
|
||||
from max.exceptions import InvalidPassword
|
||||
from max.sessions import session_storage
|
||||
from max.translations import t
|
||||
|
||||
BLF_CRYPT_PREFIX = "{BLF-CRYPT}"
|
||||
SSHA512_PREFIX = "{SSHA512}"
|
||||
BCRYPT_ROUNDS = 12
|
||||
# bcrypt only uses the first 72 bytes
|
||||
MAX_PASSWORD_BYTES = 72
|
||||
# Hash of a random, unknown password. It is checked for unknown users, so the
|
||||
# response time does not tell whether an email exists.
|
||||
DUMMY_HASH = (
|
||||
BLF_CRYPT_PREFIX + "$2b$12$J27D9dEpQ5ff/I48HG05p.97oJGTO7UiaKGIbVuzV6nw0lo6gZ5wq"
|
||||
)
|
||||
|
||||
LOGIN_FAILED_MESSAGE = "Email or password is wrong, or the account is deactivated"
|
||||
PASSWORD_TOO_LONG_MESSAGE = (
|
||||
"The password must be at most 72 bytes long "
|
||||
"(72 characters, fewer with umlauts or special characters)"
|
||||
)
|
||||
|
||||
|
||||
def login(email, password):
|
||||
@@ -21,6 +36,7 @@ def login(email, password):
|
||||
|
||||
user = get_user_by_email(email)
|
||||
if user is None:
|
||||
check_password(password, DUMMY_HASH)
|
||||
return t(LOGIN_FAILED_MESSAGE)
|
||||
|
||||
if not check_password(password, user.passwordhash):
|
||||
@@ -29,6 +45,8 @@ def login(email, password):
|
||||
if not user.enabled:
|
||||
return t(LOGIN_FAILED_MESSAGE)
|
||||
|
||||
rehash_password_if_needed(user, password)
|
||||
|
||||
# Start a new session (new id, new CSRF token) against session fixation.
|
||||
session.clear()
|
||||
session["user_id"] = user.id
|
||||
@@ -51,10 +69,63 @@ def change_password(user, password):
|
||||
session_storage.remove_user_sessions(user.id)
|
||||
|
||||
|
||||
def hash_password(password: str, salt: bytes = None):
|
||||
if salt is None:
|
||||
salt = os.urandom(32)
|
||||
def rehash_password_if_needed(user, password):
|
||||
"""Replaces an outdated hash, the plain password is only known at the login"""
|
||||
if not needs_rehash(user.passwordhash):
|
||||
return
|
||||
error = validate_password(password)
|
||||
if error:
|
||||
current_app.logger.warning(
|
||||
'Cannot rehash the password of user %s: "%s"', user.id, error
|
||||
)
|
||||
return
|
||||
update_user(user.id, passwordhash=hash_password(password))
|
||||
current_app.logger.info("Rehashed the password of user %s", user.id)
|
||||
|
||||
|
||||
def validate_password(password: str):
|
||||
"""Returns the (untranslated) error message, or None if the password is valid"""
|
||||
if len(force_bytes(password)) > MAX_PASSWORD_BYTES:
|
||||
return PASSWORD_TOO_LONG_MESSAGE
|
||||
return None
|
||||
|
||||
|
||||
def hash_password(password: str):
|
||||
error = validate_password(password)
|
||||
if error:
|
||||
raise InvalidPassword(error)
|
||||
|
||||
hash = bcrypt.hashpw(force_bytes(password), bcrypt.gensalt(rounds=BCRYPT_ROUNDS))
|
||||
return BLF_CRYPT_PREFIX + hash.decode()
|
||||
|
||||
|
||||
def needs_rehash(hash: str):
|
||||
if not hash.startswith(BLF_CRYPT_PREFIX):
|
||||
return True
|
||||
# "$2b$12$…": the cost factor follows the version
|
||||
rounds = int(hash[len(BLF_CRYPT_PREFIX) :].split("$")[2]) # noqa
|
||||
return rounds < BCRYPT_ROUNDS
|
||||
|
||||
|
||||
def check_password(password: str, hash: str):
|
||||
if hash.startswith(BLF_CRYPT_PREFIX):
|
||||
# Such a password cannot have been set, and bcrypt would raise
|
||||
if validate_password(password):
|
||||
return False
|
||||
bcrypt_hash = hash[len(BLF_CRYPT_PREFIX) :] # noqa
|
||||
return bcrypt.checkpw(force_bytes(password), force_bytes(bcrypt_hash))
|
||||
|
||||
if hash.startswith(SSHA512_PREFIX):
|
||||
return check_ssha512_password(password, hash)
|
||||
|
||||
raise RuntimeError("The hash has an unknown scheme")
|
||||
|
||||
|
||||
# {SSHA512} is outdated. Existing hashes are still checked and replaced at the
|
||||
# next login. Remove this once no hash uses it anymore (see F-08).
|
||||
|
||||
|
||||
def hash_ssha512_password(password: str, salt: bytes):
|
||||
sha = hashlib.sha512()
|
||||
sha.update(force_bytes(password))
|
||||
sha.update(salt)
|
||||
@@ -62,16 +133,13 @@ def hash_password(password: str, salt: bytes = None):
|
||||
return SSHA512_PREFIX + sha_and_salt.decode()
|
||||
|
||||
|
||||
def get_salt(hash: str):
|
||||
if not hash.startswith(SSHA512_PREFIX):
|
||||
raise RuntimeError(f"The hash does not start with '{SSHA512_PREFIX}''")
|
||||
|
||||
def get_ssha512_salt(hash: str):
|
||||
sha_and_salt = base64.b64decode(hash[len(SSHA512_PREFIX) :]) # noqa
|
||||
return sha_and_salt[64:]
|
||||
|
||||
|
||||
def check_password(password: str, hash: str):
|
||||
salt = get_salt(hash)
|
||||
hash_from_password = hash_password(password, salt=salt)
|
||||
def check_ssha512_password(password: str, hash: str):
|
||||
salt = get_ssha512_salt(hash)
|
||||
hash_from_password = hash_ssha512_password(password, salt)
|
||||
|
||||
return constant_time_compare(hash, hash_from_password)
|
||||
+14
-1
@@ -1,6 +1,10 @@
|
||||
import click
|
||||
|
||||
from .auth import check_password as auth_check_password, hash_password
|
||||
from .auth import (
|
||||
check_password as auth_check_password,
|
||||
hash_password,
|
||||
validate_password,
|
||||
)
|
||||
from .db import (
|
||||
add_domain as db_add_domain,
|
||||
remove_domain as db_remove_domain,
|
||||
@@ -17,10 +21,17 @@ def register_cli(app):
|
||||
app.cli.command("remove-domain")(remove_domain)
|
||||
|
||||
|
||||
def abort_if_password_invalid(password):
|
||||
error = validate_password(password)
|
||||
if error:
|
||||
raise click.ClickException(error)
|
||||
|
||||
|
||||
@click.argument("email")
|
||||
@click.argument("password")
|
||||
def reset_user(email, password):
|
||||
"""Resets or create a user given by the email"""
|
||||
abort_if_password_invalid(password)
|
||||
hash = hash_password(password)
|
||||
reset_or_create_user(email, hash, False)
|
||||
|
||||
@@ -29,6 +40,7 @@ def reset_user(email, password):
|
||||
@click.argument("password")
|
||||
def reset_admin(email, password):
|
||||
"""Resets or create an admin given by the email"""
|
||||
abort_if_password_invalid(password)
|
||||
hash = hash_password(password)
|
||||
reset_or_create_user(email, hash, True)
|
||||
|
||||
@@ -36,6 +48,7 @@ def reset_admin(email, password):
|
||||
@click.argument("password")
|
||||
def make_hash(password):
|
||||
"""Creates a salted hash from the password"""
|
||||
abort_if_password_invalid(password)
|
||||
print(hash_password(password))
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,12 @@ class PermissionDenied(MaxException):
|
||||
"""
|
||||
|
||||
|
||||
class InvalidPassword(MaxException):
|
||||
"""
|
||||
Callers must check the password with validate_password before hashing it.
|
||||
"""
|
||||
|
||||
|
||||
class LanguageNotSet(MaxException):
|
||||
"""
|
||||
Something is translated outside of the I18NMiddleware, which sets the
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"Note changed successfully!": "Notiz wurde erfolgreich bearbeitet!",
|
||||
"Both passwords must be given": "Beide Passwörter müssen angegeben werden",
|
||||
"Both passwords must be the same": "Beide Passwörter müssen übereinstimmen",
|
||||
"The password must be at most 72 bytes long (72 characters, fewer with umlauts or special characters)": "Das Passwort darf höchstens 72 Bytes lang sein (72 Zeichen, mit Umlauten oder Sonderzeichen weniger)",
|
||||
"Current password": "Aktuelles Passwort",
|
||||
"The current password must be given": "Das aktuelle Passwort muss angegeben werden",
|
||||
"The current password is wrong": "Das aktuelle Passwort ist falsch",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from flask import flash
|
||||
|
||||
from max.auth import change_password, check_password
|
||||
from max.auth import change_password, check_password, validate_password
|
||||
from max.permissions import AllowAdminOrSelf
|
||||
from max.translations import t
|
||||
|
||||
@@ -45,6 +45,11 @@ class UserChangePassword(AllowAdminOrSelf, BackToUsersMixin, FetchUserTemplateGe
|
||||
flash(t("Both passwords must be the same"), category="error")
|
||||
return
|
||||
|
||||
password_error = validate_password(pw1)
|
||||
if password_error:
|
||||
flash(t(password_error), category="error")
|
||||
return
|
||||
|
||||
change_password(self.user, pw1)
|
||||
flash(t("Password changed successfully!"), category="success")
|
||||
return self.redirect()
|
||||
@@ -1,6 +1,6 @@
|
||||
from flask import flash, url_for
|
||||
|
||||
from max.auth import hash_password
|
||||
from max.auth import hash_password, validate_password
|
||||
from max.db import EmailAlreadyExists, create_user, get_domains
|
||||
from max.permissions import AllowAdmin
|
||||
from max.translations import t
|
||||
@@ -47,6 +47,11 @@ class UserCreate(AllowAdmin, CheckEmailMixin, BaseTemplateGetView):
|
||||
elif pw1 != pw2:
|
||||
flash(t("Both passwords must be the same"), category="error")
|
||||
error = True
|
||||
else:
|
||||
password_error = validate_password(pw1)
|
||||
if password_error:
|
||||
flash(t(password_error), category="error")
|
||||
error = True
|
||||
else:
|
||||
self.password = pw1
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
{% endif %}
|
||||
<div class="pure-control-group">
|
||||
<label for="password1">{{ t("Password") }}</label>
|
||||
<input id="password1" type="password" name="password1" autocomplete="new-password" placeholder="{{ t("New password") }}">
|
||||
<input id="password1" type="password" name="password1" autocomplete="new-password" maxlength="72" placeholder="{{ t("New password") }}">
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="password2">{{ t("Re-enter password") }}</label>
|
||||
<input id="password2" type="password" name="password2" autocomplete="new-password" placeholder="{{ t("New password") }}">
|
||||
<input id="password2" type="password" name="password2" autocomplete="new-password" maxlength="72" placeholder="{{ t("New password") }}">
|
||||
</div>
|
||||
|
||||
<div class="pure-controls">
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="password1">{{ t("Password") }}</label>
|
||||
<input id="password1" type="password" name="password1" autocomplete="new-password" placeholder="{{ t("Password") }}">
|
||||
<input id="password1" type="password" name="password1" autocomplete="new-password" maxlength="72" placeholder="{{ t("Password") }}">
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="password2">{{ t("Re-enter password") }}</label>
|
||||
<input id="password2" type="password" name="password2" autocomplete="new-password" placeholder="{{ t("Re-enter password") }}">
|
||||
<input id="password2" type="password" name="password2" autocomplete="new-password" maxlength="72" placeholder="{{ t("Re-enter password") }}">
|
||||
</div>
|
||||
|
||||
<div class="pure-controls checkbox">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Direct dependencies of MAX. Pinned versions with hashes are in requirements.txt,
|
||||
# generated by `make lock-deps` (see README).
|
||||
bcrypt>=5,<6
|
||||
Flask>=3.1,<4
|
||||
gunicorn>=26,<27
|
||||
psycopg2>=2.9,<3
|
||||
@@ -4,6 +4,71 @@
|
||||
#
|
||||
# pip-compile --allow-unsafe --generate-hashes --strip-extras requirements.in
|
||||
#
|
||||
bcrypt==5.0.0 \
|
||||
--hash=sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4 \
|
||||
--hash=sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a \
|
||||
--hash=sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464 \
|
||||
--hash=sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4 \
|
||||
--hash=sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746 \
|
||||
--hash=sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2 \
|
||||
--hash=sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41 \
|
||||
--hash=sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd \
|
||||
--hash=sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9 \
|
||||
--hash=sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e \
|
||||
--hash=sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538 \
|
||||
--hash=sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10 \
|
||||
--hash=sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb \
|
||||
--hash=sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef \
|
||||
--hash=sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4 \
|
||||
--hash=sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23 \
|
||||
--hash=sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef \
|
||||
--hash=sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75 \
|
||||
--hash=sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42 \
|
||||
--hash=sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a \
|
||||
--hash=sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172 \
|
||||
--hash=sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683 \
|
||||
--hash=sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2 \
|
||||
--hash=sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4 \
|
||||
--hash=sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba \
|
||||
--hash=sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da \
|
||||
--hash=sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493 \
|
||||
--hash=sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254 \
|
||||
--hash=sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534 \
|
||||
--hash=sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f \
|
||||
--hash=sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c \
|
||||
--hash=sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c \
|
||||
--hash=sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83 \
|
||||
--hash=sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff \
|
||||
--hash=sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d \
|
||||
--hash=sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861 \
|
||||
--hash=sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5 \
|
||||
--hash=sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9 \
|
||||
--hash=sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b \
|
||||
--hash=sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac \
|
||||
--hash=sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e \
|
||||
--hash=sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f \
|
||||
--hash=sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb \
|
||||
--hash=sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86 \
|
||||
--hash=sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980 \
|
||||
--hash=sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd \
|
||||
--hash=sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d \
|
||||
--hash=sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1 \
|
||||
--hash=sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911 \
|
||||
--hash=sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993 \
|
||||
--hash=sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191 \
|
||||
--hash=sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4 \
|
||||
--hash=sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2 \
|
||||
--hash=sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8 \
|
||||
--hash=sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db \
|
||||
--hash=sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927 \
|
||||
--hash=sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be \
|
||||
--hash=sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb \
|
||||
--hash=sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e \
|
||||
--hash=sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf \
|
||||
--hash=sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd \
|
||||
--hash=sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822 \
|
||||
--hash=sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b
|
||||
# via -r requirements.in
|
||||
blinker==1.9.0 \
|
||||
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
|
||||
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
|
||||
|
||||
Reference in new issue
Block a user