Remove the SSHA512 password hashes

All passwords are migrated to bcrypt, so {SSHA512} hashes are no longer
checked; an unknown scheme raises like before. The README documents that
Dovecot must support BLF-CRYPT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
finnandClaude Opus 5.5 committed 2026-10-22 19:18:50 +00:00
1 parent 4cbe1435dc
commit 2e40c0ff47
2 files changed
+6 -34

No files matched your search

+5
View File
@@ -24,6 +24,11 @@ value the client sends. Max uses this address for audit logging. For nginx:
proxy_set_header X-Real-IP $remote_addr;
```
## Dovecot
Dovecot reads the password hashes written by Max from the `users` table. Max stores bcrypt
hashes as `{BLF-CRYPT}$2b$12$…`. Dovecot must therefore support `BLF-CRYPT` (check with `doveadm pw -l`).
## Dependencies
Direct dependencies are listed in `requirements.in` (production) and
+1 -34
View File
@@ -1,17 +1,13 @@
import base64
import hashlib
import bcrypt
from flask import current_app, session
from max.crypto import constant_time_compare, force_bytes
from max.crypto import 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
@@ -100,8 +96,6 @@ def hash_password(password: str):
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
@@ -115,31 +109,4 @@ def check_password(password: str, hash: str):
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)
sha_and_salt = base64.b64encode(sha.digest() + salt)
return SSHA512_PREFIX + sha_and_salt.decode()
def get_ssha512_salt(hash: str):
sha_and_salt = base64.b64decode(hash[len(SSHA512_PREFIX) :]) # noqa
return sha_and_salt[64:]
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)